Compare commits

..
Author SHA1 Message Date
Nils Andreas Svee 50fe83a4c8 docs(irtt-client): document caller-owned cadence
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo clippy (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 33s
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 7s
Rust CI / cargo doc (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 33s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m4s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
2026-10-02 00:31:17 +07:00
Nils Andreas Svee f2fce2f330 refactor(irtt-client): move cadence into managed runner 2026-10-02 00:31:17 +07:00
renovate Bot f350c82dae chore(deps): maintain cargo lockfile
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 48s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 24s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m4s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 5m37s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 30s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
2026-10-01 23:25:15 +07:00
renovate Bot 22d3f6cb27 fix(deps): update rust crate thiserror to 2.0.21 2026-10-01 23:25:15 +07:00
renovate Bot 85238682b8 chore(deps): update github actions to 2892aa5 2026-10-01 23:25:05 +07:00
Nils Andreas Svee 9240152e4b chore(release): irtt-client 0.5.3
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 51s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 8s
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 1m3s
Semver / cargo-semver-checks (push) Failing after 48s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Add socket routing configuration for interface binding, packet marks, and FreeBSD FIB selection.
2026-09-28 14:37:30 +07:00
Nils Andreas Svee 7bbc89e1c0 feat(socket): support advanced routing configuration for client socket
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 51s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 21s
Supply chain / cargo-deny advisories (push) Successful in 9s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 30s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Supply chain / actionlint (push) Successful in 2s
Netem Interop / netem interop (push) Successful in 1m5s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Enable binding to a specific network interface, applying a firewall mark, or selecting a routing table (FIB) for outgoing packets on supported platforms.
2026-09-28 14:24:21 +07:00
Nils Andreas Svee b6043e4961 refactor(stats): use shared measurement statistics
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 48s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 21s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
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 2s
Netem Interop / netem interop (push) Successful in 1m2s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
2026-09-27 00:03:35 +07:00
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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
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-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
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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 42s
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-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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
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 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
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
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
81 changed files with 2424 additions and 4532 deletions

No files matched your search

+7 -4
View File
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
components: rustfmt components: rustfmt
cache: false cache: false
@@ -48,7 +48,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
components: clippy components: clippy
cache: false cache: false
@@ -109,7 +109,7 @@ jobs:
sudo apt-get install --no-install-recommends musl-tools sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
cache: false cache: false
@@ -147,7 +147,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
cache: false cache: false
@@ -158,3 +158,6 @@ jobs:
--all-features \ --all-features \
--no-deps \ --no-deps \
--locked --locked
- name: Run doctests
run: cargo test --workspace --doc --all-features --locked
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
# (native builds, no emulation), but buildx still needs a driver that # (native builds, no emulation), but buildx still needs a driver that
# can assemble a multi-platform manifest list. # can assemble a multi-platform manifest list.
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
@@ -76,7 +76,7 @@ jobs:
# below), never a separate build, so the hash matches everywhere. # below), never a separate build, so the hash matches everywhere.
- name: Build and push to Gitea - name: Build and push to Gitea
id: build id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
+3 -3
View File
@@ -1,4 +1,4 @@
name: Proto Fuzz Smoke name: Fuzz Smoke
on: on:
push: push:
@@ -33,7 +33,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install stable Rust toolchain - name: Install stable Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
cache: false cache: false
@@ -71,7 +71,7 @@ jobs:
# regression; this is not meant to be exhaustive fuzzing. # regression; this is not meant to be exhaustive fuzzing.
run: | run: |
set -euo pipefail set -euo pipefail
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
echo "::group::fuzz smoke: ${target}" echo "::group::fuzz smoke: ${target}"
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \ RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
-max_total_time=10 \ -max_total_time=10 \
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
cache: false cache: false
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
cache: false cache: false
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
# It remains here while portable actionlint runs with Gitea CI, but scans # It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set. # both workflow directories through its GitHub Actions rule set.
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with: with:
persona: regular persona: regular
inputs: | inputs: |
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
sudo apt-get install --no-install-recommends musl-tools sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
cache-key: ${{ matrix.task }}-${{ matrix.target }} cache-key: ${{ matrix.task }}-${{ matrix.target }}
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs: jobs:
scan-scheduled: scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with: with:
# Example of specifying custom arguments # Example of specifying custom arguments
scan-args: |- scan-args: |-
@@ -40,7 +40,7 @@ jobs:
./ ./
scan-pr: scan-pr:
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1 uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with: with:
# Example of specifying custom arguments # Example of specifying custom arguments
scan-args: |- scan-args: |-
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Install Rust toolchain - name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with: with:
components: clippy components: clippy
cache-key: clippy-sarif cache-key: clippy-sarif
@@ -60,7 +60,7 @@ jobs:
exit 0 exit 0
- name: Upload Clippy SARIF - name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4 uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
if: always() if: always()
with: with:
sarif_file: rust-clippy-results.sarif sarif_file: rust-clippy-results.sarif
+1
View File
@@ -173,6 +173,7 @@ Use the narrowest useful checks first. For substantive workspace changes, final
cargo fmt --all -- --check cargo fmt --all -- --check
cargo check --workspace --all-targets --all-features --locked cargo check --workspace --all-targets --all-features --locked
cargo test --workspace --all-targets --all-features --locked cargo test --workspace --all-targets --all-features --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
``` ```
+36
View File
@@ -10,6 +10,12 @@ release. The format is loosely based on
## irtt-proto ## irtt-proto
### 0.5.1
#### Added
- Added a crate-specific README and runnable API doctests to make the published crate's protocol types and codecs easier to discover and use.
### 0.5.0 ### 0.5.0
#### Added #### Added
@@ -28,6 +34,18 @@ release. The format is loosely based on
## irtt-client ## irtt-client
### 0.5.3
#### Added
- `SocketConfig` can bind UDP sockets to a network interface and set a packet mark on Linux, Android, and Fuchsia, or select a routing FIB on FreeBSD.
### 0.5.2
#### Fixed
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
### 0.5.1 ### 0.5.1
#### Added #### Added
@@ -56,6 +74,12 @@ release. The format is loosely based on
## irtt-server ## irtt-server
### 0.5.3
#### Added
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
### 0.5.2 ### 0.5.2
#### Changed #### Changed
@@ -95,6 +119,12 @@ release. The format is loosely based on
## irtt-stats ## irtt-stats
### 0.5.1
#### Added
- Added a crate-specific README, a finite-versus-continuous statistics example, and runnable API doctests for the published crate.
### 0.5.0 ### 0.5.0
#### Fixed #### Fixed
@@ -108,6 +138,12 @@ release. The format is loosely based on
## irtt-rs ## irtt-rs
### 0.7.1
#### Fixed
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
### 0.7.0 ### 0.7.0
#### Added #### Added
Generated
+98 -91
View File
@@ -141,9 +141,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.1" version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@@ -201,9 +201,9 @@ dependencies = [
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
@@ -213,9 +213,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.6" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -223,9 +223,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.6" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -235,21 +235,21 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.4" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.4", "syn 3.0.6",
] ]
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "1.1.0" version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]] [[package]]
name = "cmov" name = "cmov"
@@ -313,7 +313,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"crossterm_winapi", "crossterm_winapi",
"derive_more", "derive_more",
"document-features", "document-features",
@@ -403,7 +403,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 3.0.4", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -414,7 +414,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 3.0.4", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -479,7 +479,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -554,9 +554,9 @@ dependencies = [
[[package]] [[package]]
name = "finl_unicode" name = "finl_unicode"
version = "1.4.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
[[package]] [[package]]
name = "fixedbitset" name = "fixedbitset"
@@ -678,9 +678,9 @@ dependencies = [
[[package]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.14" version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [ dependencies = [
"typenum", "typenum",
] ]
@@ -702,27 +702,27 @@ dependencies = [
[[package]] [[package]]
name = "instability" name = "instability"
version = "0.3.13" version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
dependencies = [ dependencies = [
"darling", "darling",
"indoc", "indoc",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.4", "syn 3.0.6",
] ]
[[package]] [[package]]
name = "irtt-client" name = "irtt-client"
version = "0.5.1" version = "0.5.3"
dependencies = [ dependencies = [
"irtt-proto", "irtt-proto",
"irtt-server", "irtt-server",
"nix 0.31.3", "nix 0.31.3",
"proptest", "proptest",
"socket2", "socket2",
"thiserror 2.0.20", "thiserror 2.0.21",
"tokio", "tokio",
] ]
@@ -738,18 +738,18 @@ dependencies = [
[[package]] [[package]]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"hmac", "hmac",
"md-5", "md-5",
"proptest", "proptest",
"subtle", "subtle",
"thiserror 2.0.20", "thiserror 2.0.21",
] ]
[[package]] [[package]]
name = "irtt-rs" name = "irtt-rs"
version = "0.7.0" version = "0.7.1"
dependencies = [ dependencies = [
"clap", "clap",
"crossterm", "crossterm",
@@ -764,21 +764,22 @@ dependencies = [
[[package]] [[package]]
name = "irtt-server" name = "irtt-server"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"irtt-proto", "irtt-proto",
"nix 0.31.3", "nix 0.31.3",
"socket2", "socket2",
"thiserror 2.0.20", "thiserror 2.0.21",
"tokio", "tokio",
] ]
[[package]] [[package]]
name = "irtt-stats" name = "irtt-stats"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"irtt-client", "irtt-client",
"measurement-stats",
"proptest", "proptest",
] ]
@@ -805,9 +806,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.104" version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -822,7 +823,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
"portable-atomic", "portable-atomic",
"thiserror 2.0.20", "thiserror 2.0.21",
] ]
[[package]] [[package]]
@@ -855,7 +856,7 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
] ]
[[package]] [[package]]
@@ -887,9 +888,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.18.3" version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
] ]
@@ -914,6 +915,12 @@ dependencies = [
"digest 0.11.3", "digest 0.11.3",
] ]
[[package]]
name = "measurement-stats"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edbfa45a060f44b15568637075ecebbe258c22f86ce6f2b5d58d7a64cbdb689"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@@ -943,9 +950,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.2" version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
@@ -959,7 +966,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -972,7 +979,7 @@ version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -1118,9 +1125,9 @@ dependencies = [
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.9.0" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
dependencies = [ dependencies = [
"memchr", "memchr",
"ucd-trie", "ucd-trie",
@@ -1128,9 +1135,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.9.0" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@@ -1138,9 +1145,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.9.0" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
@@ -1151,9 +1158,9 @@ dependencies = [
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.9.0" version = "2.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
dependencies = [ dependencies = [
"pest", "pest",
] ]
@@ -1254,7 +1261,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [ dependencies = [
"bit-set 0.8.0", "bit-set 0.8.0",
"bit-vec 0.8.0", "bit-vec 0.8.0",
"bitflags 2.13.1", "bitflags 2.13.2",
"num-traits", "num-traits",
"rand 0.9.5", "rand 0.9.5",
"rand_chacha", "rand_chacha",
@@ -1367,7 +1374,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"compact_str", "compact_str",
"critical-section", "critical-section",
"hashbrown 0.17.1", "hashbrown 0.17.1",
@@ -1377,7 +1384,7 @@ dependencies = [
"palette", "palette",
"serde", "serde",
"strum", "strum",
"thiserror 2.0.20", "thiserror 2.0.21",
"unicode-segmentation", "unicode-segmentation",
"unicode-truncate", "unicode-truncate",
"unicode-width", "unicode-width",
@@ -1432,7 +1439,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"hashbrown 0.17.1", "hashbrown 0.17.1",
"indoc", "indoc",
"instability", "instability",
@@ -1452,7 +1459,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
] ]
[[package]] [[package]]
@@ -1495,11 +1502,11 @@ dependencies = [
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -1569,7 +1576,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.4", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -1616,9 +1623,9 @@ dependencies = [
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]] [[package]]
name = "slab" name = "slab"
@@ -1628,9 +1635,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.2" version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -1705,9 +1712,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.4" version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1733,7 +1740,7 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.2",
"parking_lot", "parking_lot",
"rustix", "rustix",
"signal-hook", "signal-hook",
@@ -1769,7 +1776,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"bitflags 2.13.1", "bitflags 2.13.2",
"fancy-regex", "fancy-regex",
"filedescriptor", "filedescriptor",
"finl_unicode", "finl_unicode",
@@ -1814,11 +1821,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [ dependencies = [
"thiserror-impl 2.0.20", "thiserror-impl 2.0.21",
] ]
[[package]] [[package]]
@@ -1834,13 +1841,13 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.20" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.4", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -1886,7 +1893,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.4", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -1909,9 +1916,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
@@ -1944,9 +1951,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.26.0" version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [ dependencies = [
"atomic", "atomic",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -1995,9 +2002,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.127" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -2008,9 +2015,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.127" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -2018,22 +2025,22 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.127" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.6",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.127" version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -2155,18 +2162,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.56" version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.56" version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+1
View File
@@ -171,6 +171,7 @@ when to use it instead of `irtt-client`/`irtt-server` directly.
```sh ```sh
cargo fmt --all -- --check cargo fmt --all -- --check
cargo test --workspace --locked cargo test --workspace --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
``` ```
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-rs" name = "irtt-rs"
version = "0.7.0" version = "0.7.1"
description = "IRTT-compatible client, TUI, and server applications" description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -47,7 +47,7 @@ server = ["dep:irtt-server", "dep:tokio"]
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"] tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
[dependencies] [dependencies]
clap = { version = "4.6.6", features = ["derive", "env"] } clap = { version = "4.6.7", features = ["derive", "env"] }
crossterm = { version = "0.29.0", optional = true } crossterm = { version = "0.29.0", optional = true }
ctrlc = { version = "3.5.2", features = ["termination"] } ctrlc = { version = "3.5.2", features = ["termination"] }
irtt-client = { workspace = true, optional = true } irtt-client = { workspace = true, optional = true }
+2 -1
View File
@@ -22,7 +22,7 @@ pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) { match applet_basename(argv0) {
"irtt-client" => Some(RequestedApplet::Client), "irtt-client" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui), "irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server), "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None, "irtt-rs" => None,
_ => None, _ => None,
} }
@@ -199,6 +199,7 @@ mod tests {
detect_applet_from_argv0("irtt-server"), detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server) Some(RequestedApplet::Server)
); );
assert_eq!(detect_applet_from_argv0("irttd"), None);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None); assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None); assert_eq!(detect_applet_from_argv0("custom-name"), None);
} }
+2 -2
View File
@@ -207,7 +207,7 @@ enum OutputRow {
client_send_wall: SystemTime, client_send_wall: SystemTime,
bytes: usize, bytes: usize,
send_call: Duration, send_call: Duration,
timer_error: Duration, timer_error: Option<Duration>,
}, },
EchoReply(ReplyRow), EchoReply(ReplyRow),
Loss { Loss {
@@ -950,7 +950,7 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
}, },
Column::TimerErrorUs => match row { Column::TimerErrorUs => match row {
OutputRow::EchoSent { timer_error, .. } => { OutputRow::EchoSent { timer_error, .. } => {
Some(CellValue::Unsigned(duration_us(*timer_error))) timer_error.map(|error| CellValue::Unsigned(duration_us(error)))
} }
_ => None, _ => None,
}, },
+4 -4
View File
@@ -170,11 +170,11 @@ mod tests {
collector.process(&ClientEvent::EchoSent { collector.process(&ClientEvent::EchoSent {
seq: 1, seq: 1,
remote: test_remote(), remote: test_remote(),
scheduled_at: sent_at.mono, scheduled_at: Some(sent_at.mono),
sent_at, sent_at,
bytes: 64, bytes: 64,
send_call: Duration::from_micros(10), send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2), timer_error: Some(Duration::from_micros(2)),
}); });
collector.process(&ClientEvent::EchoReply { collector.process(&ClientEvent::EchoReply {
seq: 1, seq: 1,
@@ -251,11 +251,11 @@ mod tests {
collector.process(&ClientEvent::EchoSent { collector.process(&ClientEvent::EchoSent {
seq, seq,
remote: test_remote(), remote: test_remote(),
scheduled_at: sent_at.mono, scheduled_at: Some(sent_at.mono),
sent_at, sent_at,
bytes: 64, bytes: 64,
send_call: Duration::from_micros(send_call_us), send_call: Duration::from_micros(send_call_us),
timer_error: Duration::from_micros(timer_error_us), timer_error: Some(Duration::from_micros(timer_error_us)),
}); });
collector.process(&ClientEvent::EchoReply { collector.process(&ClientEvent::EchoReply {
seq, seq,
+50 -1
View File
@@ -106,10 +106,22 @@ pub struct ServerArgs {
pub timestamp_allowance: Option<TimestampAllowanceArg>, pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking. /// Refuse to provide requested DSCP marking.
//
// `FalseyValueParser` rather than clap's default `bool` parser for a flag,
// which is what makes IRTT_SERVER_NO_DSCP usable from the environment at
// all. A flag's default parser accepts only the literals `true` and
// `false`, so the ordinary container/orchestrator spelling
// `IRTT_SERVER_NO_DSCP=1` did not enable the flag — it failed argument
// parsing and the server refused to start. Falsey semantics are clap's own
// documented recipe for a boolean environment flag: `0`, `false`, `no`,
// `n`, `off` and empty are off, anything else is on, and an absent
// variable is off as before. The command-line flag is unaffected: it still
// takes no value and still means "on".
#[arg( #[arg(
long, long,
env = "IRTT_SERVER_NO_DSCP", env = "IRTT_SERVER_NO_DSCP",
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, 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] #[test]
fn an_explicit_flag_overrides_the_environment() { fn an_explicit_flag_overrides_the_environment() {
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || { with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
+24 -23
View File
@@ -26,30 +26,30 @@ configuration (`try_enable_tx_timestamping` restores it explicitly) and
timing falls back to userspace `sent_at` as it always has. TX capability timing falls back to userspace `sent_at` as it always has. TX capability
failure is never a public error and is never logged from the library. failure is never a public error and is never logged from the library.
### Correlation invariant: kernel ID == wire sequence ### Best-effort kernel-ID correlation
The client uses the kernel's automatic `OPT_ID` directly as the probe's wire The client uses the kernel's automatic `OPT_ID` as the probe's wire-sequence
sequence number — no separate correlation map. This is safe because: correlation ID — no separate correlation map. On the normal successful-send
path, both counters start at zero after Open and advance together. This is
not a permanent identity: a kernel ID can theoretically be consumed while a
send is being prepared and then survive a subsequent send failure, leaving
kernel IDs one or more steps ahead of `next_wire_seq`. Do not restore an
assumption that failed or nonblocking sends cannot cause that desynchrony.
- **A failed/nonblocking send never consumes an ID.** Linux increments the The effect is confined to the optional timing enhancement. A kernel timestamp
per-socket ID counter (`sk->sk_tskey`) speculatively while building a whose ID cannot be matched to a pending or retained timed-out probe is
datagram and rolls the increment back (`atomic_dec`) on every error path discarded, as is any implausible timestamp; `sent_at` remains the ordinary
out of `__ip_append_data`/`__ip6_append_data` userspace fallback. **After every failed probe submission, disable kernel-ID
(`net/ipv4/ip_output.c`, `net/ipv6/ip6_output.c`), guarded by a local correlation for the rest of that session.** A gap means an otherwise plausible
`hold_tskey` flag. A `WouldBlock` `try_send` therefore can never leave a later ID can name the wrong probe, so subsequent error-queue timestamps must
gap in, or otherwise advance, the ID the kernel will assign to the next be discarded rather than attached by ID. This is safe because:
*successful* submission. (`include/net/sock.h`'s `_sock_tx_timestamp`
performs the same increment for the non-corked path.) Client-side, the - **Normal successful sends track together.** `wire_seq` starts at 0 on every
existing send transaction only calls `commit_probe_sent` — which is the successful Open (`SessionMachine::commit_open` builds a fresh
only thing that advances `next_wire_seq` — after a successful socket send; `ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via
a `WouldBlock` result never reaches it. `commit_probe_sent`, after a confirmed successful socket send. TX
- **The two counters start together and advance together.** `wire_seq` timestamping is enabled after that same successful Open and before any probe
starts at 0 on every successful Open (`SessionMachine::commit_open` builds is sent, so the normal zero points line up.
a fresh `ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1
only via `commit_probe_sent`, which only runs after a confirmed successful
send — the same condition under which the kernel's counter advances by 1.
TX timestamping is enabled only after that same successful Open and before
any probe is sent, so both counters' zero points line up exactly.
- **One socket, one session, no reopen.** `SessionMachine`'s state machine - **One socket, one session, no reopen.** `SessionMachine`'s state machine
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close` only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
@@ -127,7 +127,8 @@ remains anchored to before the send, on purpose, so the existing pre-send
timeout/overflow/no-send contract is unchanged. Only the *measurement* timeout/overflow/no-send contract is unchanged. Only the *measurement*
`sent_at` moved to after the send. A `WouldBlock` `try_send` (Tokio) never `sent_at` moved to after the send. A `WouldBlock` `try_send` (Tokio) never
samples a `sent_at`, never calls `finalize_probe_commit`'s matching samples a `sent_at`, never calls `finalize_probe_commit`'s matching
`commit_probe_sent`, and never advances the machine or schedule; each retry `commit_probe_sent`, and never advances the machine; the managed runner advances cadence only
after an accepted send; each retry
gets its own fresh pre-send anchor, and only a successful attempt's anchor gets its own fresh pre-send anchor, and only a successful attempt's anchor
and `sent_at` are ever retained. and `sent_at` are ever retained.
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-client" name = "irtt-client"
version = "0.5.1" version = "0.5.3"
description = "IRTT-compatible Rust client library" description = "IRTT-compatible Rust client library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -14,7 +14,7 @@ categories = ["network-programming"]
[dependencies] [dependencies]
irtt-proto.workspace = true irtt-proto.workspace = true
socket2 = { version = "0.6.5", features = ["all"] } socket2 = { version = "0.6.5", features = ["all"] }
thiserror = "2.0.20" thiserror = "2.0.21"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true} tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
+7
View File
@@ -22,6 +22,13 @@ binaries are built on.
runtime, and `BlockingManagedClient` for synchronous callers, which owns its runtime, and `BlockingManagedClient` for synchronous callers, which owns its
own dedicated current-thread runtime. own dedicated current-thread runtime.
`Client` and `AsyncClient` send one probe whenever called. They expose the
negotiated parameters, pending-probe status, and timeout deadlines; callers
own cadence and run duration. The managed driver provides fixed cadence,
missed-slot handling, finite runs, and staggered or burst pacing. Low-level
`EchoSent` events leave `scheduled_at` and `timer_error` absent; the managed
driver supplies those schedule measurements.
Tokio stays optional: the default build has no runtime and no Tokio Tokio stays optional: the default build has no runtime and no Tokio
dependency at all. dependency at all.
+34 -21
View File
@@ -18,7 +18,7 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use irtt_client::{Client, ClientConfig, ClientEvent}; use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome};
const RUN_DURATION: Duration = Duration::from_secs(2); const RUN_DURATION: Duration = Duration::from_secs(2);
@@ -45,32 +45,42 @@ fn main() {
} }
}; };
match client.open() { let negotiated = match client.open() {
Ok(outcome) => println!("session opened: {outcome:?}"), Ok(OpenOutcome::Started {
event, negotiated, ..
}) => {
println!("session opened: {event:?}");
negotiated
}
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
println!("{event:?}");
return;
}
Err(err) => { Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}"); eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return; return;
} }
} };
// The run itself lasts `duration`, but a lost probe isn't classified as // This loop owns cadence and duration. The transport sends whenever called.
// `EchoLoss` until `probe_timeout()` after it was sent; wait at least let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
// that much past the run's own end so a loss near the end can still be let start = Instant::now();
// observed and printed before this example gives up and closes. let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
let deadline = Instant::now() + RUN_DURATION + client.probe_timeout(); let deadline = end + client.probe_timeout();
while Instant::now() < deadline && !client.is_run_complete() { let mut next_send = start;
// send_probe is caller-paced: only call it once its own schedule while Instant::now() < deadline && !client.is_peer_closed() {
// says a probe is due, rather than as fast as the loop spins. let now = Instant::now();
if client if now >= end && !client.has_pending_probes() {
.next_send_deadline() break;
.is_some_and(|send_at| Instant::now() >= send_at) }
{ if now < end && now >= next_send {
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry silently until the
// safety deadline instead of reporting it.
match client.send_probe() { match client.send_probe() {
Ok(events) => events.iter().for_each(print_event), Ok(events) => {
events.iter().for_each(print_event);
while next_send <= Instant::now() {
next_send += interval;
}
}
Err(err) => { Err(err) => {
eprintln!("send failed: {err}"); eprintln!("send failed: {err}");
break; break;
@@ -93,6 +103,9 @@ fn main() {
} }
} }
if client.is_peer_closed() {
return;
}
match client.close() { match client.close() {
Ok(events) => events.iter().for_each(print_event), Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"), Err(err) => eprintln!("close failed: {err}"),
+37 -17
View File
@@ -18,7 +18,7 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use irtt_client::{AsyncClient, ClientConfig, ClientEvent}; use irtt_client::{AsyncClient, ClientConfig, ClientEvent, OpenOutcome};
/// Upper bound on how long the receive wait can go without also calling /// Upper bound on how long the receive wait can go without also calling
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending /// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
@@ -58,24 +58,43 @@ async fn run() {
} }
}; };
match client.open().await { let negotiated = match client.open().await {
Ok(outcome) => println!("session opened: {outcome:?}"), Ok(OpenOutcome::Started {
event, negotiated, ..
}) => {
println!("session opened: {event:?}");
negotiated
}
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
println!("{event:?}");
return;
}
Err(err) => { Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}"); eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return; return;
} }
} };
while !client.is_run_complete() { // This loop owns cadence and duration. The transport sends whenever called.
let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
let start = Instant::now();
let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
let deadline = end + client.probe_timeout();
let mut next_send = start;
while Instant::now() < deadline && !client.is_peer_closed() {
if Instant::now() >= end && !client.has_pending_probes() {
break;
}
// Bound the receive wait by the next send/timeout deadline, rather // Bound the receive wait by the next send/timeout deadline, rather
// than awaiting recv() unconditionally: a lost or rate-limited reply // than awaiting recv() unconditionally: a lost or rate-limited reply
// must not stall pacing or timeout classification indefinitely. // must not stall pacing or timeout classification indefinitely.
let wake_at = client let wake_at = (next_send < end)
.next_send_deadline() .then_some(next_send)
.into_iter() .into_iter()
.chain(client.next_probe_timeout_deadline())
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL)) .chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
.min() .min()
.expect("the fixed poll-interval deadline is always present"); .unwrap();
let recv_result = tokio::select! { let recv_result = tokio::select! {
events = client.recv() => Some(events), events = client.recv() => Some(events),
@@ -91,16 +110,14 @@ async fn run() {
} }
} }
if client if Instant::now() < end && Instant::now() >= next_send {
.next_send_deadline()
.is_some_and(|send_at| Instant::now() >= send_at)
{
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry forever instead of
// reporting it.
match client.send_probe().await { match client.send_probe().await {
Ok(events) => events.iter().for_each(print_event), Ok(events) => {
events.iter().for_each(print_event);
while next_send <= Instant::now() {
next_send += interval;
}
}
Err(err) => { Err(err) => {
eprintln!("send failed: {err}"); eprintln!("send failed: {err}");
break; break;
@@ -116,6 +133,9 @@ async fn run() {
} }
} }
if client.is_peer_closed() {
return;
}
match client.close().await { match client.close().await {
Ok(events) => events.iter().for_each(print_event), Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"), Err(err) => eprintln!("close failed: {err}"),
+65 -342
View File
@@ -7,20 +7,10 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
#[cfg(test)]
use std::{
cell::{Cell, RefCell},
collections::VecDeque,
};
use tokio::time; use tokio::time;
use crate::{ use crate::{
client::{ client::{echo_sent_event, validate_datagram_length},
echo_sent_event,
schedule::{instant_abs_diff, ProbeSchedule},
validate_datagram_length,
},
config::ClientConfig, config::ClientConfig,
error::ClientError, error::ClientError,
event::{ClientEvent, OpenOutcome}, event::{ClientEvent, OpenOutcome},
@@ -54,13 +44,9 @@ const OPEN_POLL_WORK_BUDGET: usize = 64;
/// this crate already bounds open-attempt work keeps that impossible. /// this crate already bounds open-attempt work keeps that impossible.
const RECV_POLL_WORK_BUDGET: usize = 64; const RECV_POLL_WORK_BUDGET: usize = 64;
#[cfg(test)]
use crate::client::ProbeSendTimestamps;
#[derive(Debug)] #[derive(Debug)]
struct PreparedAsyncOpen { struct PreparedAsyncOpen {
machine: PreparedOpenAcceptance, machine: PreparedOpenAcceptance,
schedule: Option<ProbeSchedule>,
recv_buffer_len: Option<usize>, recv_buffer_len: Option<usize>,
negotiated_traffic_class: Option<u8>, negotiated_traffic_class: Option<u8>,
} }
@@ -156,11 +142,6 @@ impl AsyncOpenState {
self.stopped_after_current_attempt self.stopped_after_current_attempt
} }
#[cfg(test)]
pub(crate) fn has_retained_cleanup(&self) -> bool {
self.cleanup.is_some()
}
fn deadline(&self) -> Instant { fn deadline(&self) -> Instant {
self.deadline self.deadline
.expect("an open attempt has an active deadline") .expect("an open attempt has an active deadline")
@@ -173,80 +154,40 @@ impl AsyncOpenState {
} }
} }
#[cfg(test)]
#[derive(Debug)]
enum InjectedSend {
WouldBlock,
Error,
ReportedLength(usize),
}
#[cfg(test)]
#[derive(Debug, Default)]
struct AsyncClientTestHooks {
sends: RefCell<VecDeque<InjectedSend>>,
send_attempts: Cell<usize>,
receive_would_block: Cell<usize>,
/// Number of subsequent receive attempts (`poll_open` or `poll_recv`)
/// that should report `io::ErrorKind::Interrupted` instead of performing
/// a real nonblocking receive, deterministically exercising interrupted-
/// syscall retry behavior.
inject_recv_interrupted: Cell<usize>,
fail_open_dscp: Cell<bool>,
fail_cleanup_send: Cell<bool>,
fail_peer_close_dscp: Cell<bool>,
fail_dscp_restore: Cell<bool>,
pause_open_cleanup_before_send: Cell<bool>,
pause_open_before_writable: Cell<bool>,
pause_open_before_readable: Cell<bool>,
pause_probe_before_writable: Cell<bool>,
pause_close_before_writable: Cell<bool>,
pause_close_after_would_block: Cell<bool>,
probe_timestamps: RefCell<VecDeque<ProbeSendTimestamps>>,
close_sent_at: Cell<Option<ClientTimestamp>>,
}
#[cfg(test)]
impl AsyncClientTestHooks {
fn try_send(&self, socket: &tokio::net::UdpSocket, packet: &[u8]) -> io::Result<usize> {
self.send_attempts.set(self.send_attempts.get() + 1);
if let Some(injected) = self.sends.borrow_mut().pop_front() {
return match injected {
InjectedSend::WouldBlock => Err(io::Error::from(io::ErrorKind::WouldBlock)),
InjectedSend::Error => Err(io::Error::other("injected send failure")),
InjectedSend::ReportedLength(bytes) => Ok(bytes),
};
}
socket.try_send(packet)
}
fn take_probe_timestamps(&self) -> Option<ProbeSendTimestamps> {
self.probe_timestamps.borrow_mut().pop_front()
}
/// Consumes one pending injected interruption, if any, reporting whether
/// the caller should report `io::ErrorKind::Interrupted` instead of
/// attempting a real receive.
fn take_injected_interrupted(&self) -> bool {
let remaining = self.inject_recv_interrupted.get();
if remaining == 0 {
return false;
}
self.inject_recv_interrupted.set(remaining - 1);
true
}
}
/// Low-level Tokio IRTT client for one connected UDP target. /// Low-level Tokio IRTT client for one connected UDP target.
/// ///
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async /// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
/// methods are polled by the caller and require a current Tokio runtime with I/O /// methods are polled by the caller and require a current Tokio runtime with I/O
/// and time enabled. /// and time enabled.
///
/// # Example
///
/// Use this low-level adapter when the application already owns Tokio and wants
/// to drive the session lifecycle itself. This example type-checks without
/// contacting a server.
///
/// ```no_run
/// use irtt_client::{AsyncClient, ClientConfig};
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = AsyncClient::connect(ClientConfig::default()).await?;
/// let outcome = client.open().await?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe().await?;
/// println!("sent: {sent:?}");
/// let received = client.recv().await?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close().await?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct AsyncClient { pub struct AsyncClient {
socket: tokio::net::UdpSocket, socket: tokio::net::UdpSocket,
machine: SessionMachine, machine: SessionMachine,
schedule: Option<ProbeSchedule>,
remote: SocketAddr, remote: SocketAddr,
recv_buffer: Vec<u8>, recv_buffer: Vec<u8>,
applied_traffic_class: Option<u8>, applied_traffic_class: Option<u8>,
@@ -255,8 +196,6 @@ pub struct AsyncClient {
/// See `Client::tx_timestamping_enabled` in `client.rs` for the /// See `Client::tx_timestamping_enabled` in `client.rs` for the
/// invariant this tracks; it is the same adapter-owned capability flag. /// invariant this tracks; it is the same adapter-owned capability flag.
tx_timestamping_enabled: bool, tx_timestamping_enabled: bool,
#[cfg(test)]
test_hooks: AsyncClientTestHooks,
} }
impl AsyncClient { impl AsyncClient {
@@ -277,15 +216,12 @@ impl AsyncClient {
Ok(Self { Ok(Self {
socket, socket,
machine, machine,
schedule: None,
remote, remote,
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?], recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
applied_traffic_class: None, applied_traffic_class: None,
prepared_open: Some(prepared_open), prepared_open: Some(prepared_open),
prepared_probe: None, prepared_probe: None,
tx_timestamping_enabled: false, tx_timestamping_enabled: false,
#[cfg(test)]
test_hooks: AsyncClientTestHooks::default(),
}) })
} }
@@ -297,16 +233,16 @@ impl AsyncClient {
/// ignored without resending the request. /// ignored without resending the request.
/// ///
/// Dropping this future before trusted acceptance leaves the local machine /// Dropping this future before trusted acceptance leaves the local machine
/// connected with no committed schedule or negotiated adapter state. If it /// connected with no negotiated adapter state. If it is dropped during
/// is dropped during best-effort post-token cleanup, cleanup may remain /// best-effort post-token cleanup, cleanup may remain incomplete because
/// incomplete because this low-level client never detaches cleanup work. /// this low-level client never detaches cleanup work.
pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> { pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
let mut state = AsyncOpenState::new(); let mut state = AsyncOpenState::new();
poll_fn(|cx| self.poll_open(&mut state, cx)).await poll_fn(|cx| self.poll_open(&mut state, cx)).await
} }
/// Send one caller-paced probe with transactional protocol and schedule /// Send one echo probe now, committing protocol state only after acceptance.
/// commits. /// The caller owns pacing and run duration.
/// ///
/// A prepared packet is retained across readiness false positives, /// A prepared packet is retained across readiness false positives,
/// cancellation, and socket errors so a later call retries the same logical /// cancellation, and socket errors so a later call retries the same logical
@@ -387,10 +323,6 @@ impl AsyncClient {
} }
if !state.request_submitted { if !state.request_submitted {
#[cfg(test)]
if self.test_hooks.pause_open_before_writable.replace(false) {
return Poll::Pending;
}
match poll_writable_until(&self.socket, state.deadline_timer(), cx) { match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
Poll::Pending => return Poll::Pending, Poll::Pending => return Poll::Pending,
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
@@ -405,9 +337,6 @@ impl AsyncClient {
.prepared_open .prepared_open
.as_ref() .as_ref()
.expect("connected clients retain their prepared open request"); .expect("connected clients retain their prepared open request");
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, &request.bytes);
#[cfg(not(test))]
let send_result = self.socket.try_send(&request.bytes); let send_result = self.socket.try_send(&request.bytes);
match send_result { match send_result {
Ok(bytes) => { Ok(bytes) => {
@@ -421,10 +350,6 @@ impl AsyncClient {
} }
} }
#[cfg(test)]
if self.test_hooks.pause_open_before_readable.replace(false) {
return Poll::Pending;
}
match poll_readable_until(&self.socket, state.deadline_timer(), cx) { match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
Poll::Pending => return Poll::Pending, Poll::Pending => return Poll::Pending,
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
@@ -438,10 +363,6 @@ impl AsyncClient {
Poll::Ready(Ok(true)) => {} Poll::Ready(Ok(true)) => {}
} }
#[cfg(test)]
if self.test_hooks.take_injected_interrupted() {
continue;
}
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) { let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
Ok(datagram) => datagram, Ok(datagram) => datagram,
// WouldBlock is a readiness false positive; Interrupted // WouldBlock is a readiness false positive; Interrupted
@@ -489,7 +410,7 @@ impl AsyncClient {
continue; continue;
} }
}; };
let prepared = match self.prepare_async_open(machine, datagram.received_at) { let prepared = match self.prepare_async_open(machine) {
Ok(prepared) => prepared, Ok(prepared) => prepared,
Err(failure) => { Err(failure) => {
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure)); state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
@@ -514,19 +435,11 @@ impl AsyncClient {
} }
if self.prepared_probe.is_none() { if self.prepared_probe.is_none() {
match self.machine.prepare_probe() { match self.machine.prepare_probe() {
Ok(prepared) => self.prepared_probe = prepared, Ok(prepared) => self.prepared_probe = Some(prepared),
Err(error) => return Poll::Ready(Err(error)), Err(error) => return Poll::Ready(Err(error)),
} }
} }
if self.prepared_probe.is_none() {
return Poll::Ready(Ok(Vec::new()));
}
loop { loop {
#[cfg(test)]
if self.test_hooks.pause_probe_before_writable.replace(false) {
return Poll::Pending;
}
match self.socket.poll_send_ready(cx) { match self.socket.poll_send_ready(cx) {
Poll::Pending => return Poll::Pending, Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {} Poll::Ready(Ok(())) => {}
@@ -535,23 +448,6 @@ impl AsyncClient {
} }
} }
#[cfg(test)]
let test_timestamps = self.test_hooks.take_probe_timestamps();
#[cfg(not(test))]
let permission_at = Instant::now();
#[cfg(test)]
let permission_at = test_timestamps
.map(|timestamps| timestamps.permission_at)
.unwrap_or_else(Instant::now);
let schedule = self
.schedule
.as_mut()
.expect("open sessions always have a probe schedule");
if !schedule.permit_probe_at(permission_at) {
self.prepared_probe = None;
return Poll::Ready(Ok(Vec::new()));
}
let prepared = self let prepared = self
.prepared_probe .prepared_probe
.as_ref() .as_ref()
@@ -560,11 +456,6 @@ impl AsyncClient {
Ok(preflight) => preflight, Ok(preflight) => preflight,
Err(error) => return Poll::Ready(Err(error)), Err(error) => return Poll::Ready(Err(error)),
}; };
let schedule_commit =
match schedule.preflight_caller_commit(machine_preflight.next_packets_sent) {
Ok(commit) => commit,
Err(error) => return Poll::Ready(Err(error)),
};
let mut events = Vec::new(); let mut events = Vec::new();
if let Err(source) = events.try_reserve(1) { if let Err(source) = events.try_reserve(1) {
return Poll::Ready(Err(ClientError::AllocationFailed { return Poll::Ready(Err(ClientError::AllocationFailed {
@@ -572,20 +463,14 @@ impl AsyncClient {
source, source,
})); }));
} }
let expected_bytes = prepared.bytes.len(); let expected_bytes = prepared.bytes.len();
let scheduled_at = schedule_commit.scheduled_at;
// Private pre-send anchor: finalizes all fallible commit work // Private pre-send anchor: finalizes all fallible commit work
// (timeout deadline arithmetic, kernel TX lower bound) before // (timeout deadline arithmetic, kernel TX lower bound) before
// the send attempt. A WouldBlock retry samples a fresh one on // the send attempt. A WouldBlock retry samples a fresh one on
// the next loop iteration; only the successful attempt's anchor // the next loop iteration; only the successful attempt's anchor
// is retained (via `machine_commit`/`commit_probe_sent` below). // is retained (via `machine_commit`/`commit_probe_sent` below).
#[cfg(not(test))]
let send_anchor = ClientTimestamp::now(); let send_anchor = ClientTimestamp::now();
#[cfg(test)]
let send_anchor = test_timestamps
.map(|timestamps| timestamps.send_anchor)
.unwrap_or_else(ClientTimestamp::now);
let machine_commit = match self let machine_commit = match self
.machine .machine
.finalize_probe_commit(machine_preflight, send_anchor) .finalize_probe_commit(machine_preflight, send_anchor)
@@ -593,61 +478,40 @@ impl AsyncClient {
Ok(commit) => commit, Ok(commit) => commit,
Err(error) => return Poll::Ready(Err(error)), Err(error) => return Poll::Ready(Err(error)),
}; };
#[cfg(not(test))]
let send_call_start = Instant::now(); let send_call_start = Instant::now();
#[cfg(test)]
let send_call_start = test_timestamps
.map(|timestamps| timestamps.send_call_start)
.unwrap_or_else(Instant::now);
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, &prepared.bytes);
#[cfg(not(test))]
let send_result = self.socket.try_send(&prepared.bytes); let send_result = self.socket.try_send(&prepared.bytes);
let bytes = match send_result { let bytes = match send_result {
Ok(bytes) => bytes, Ok(bytes) => bytes,
// No sent_at measurement, no machine commit, no schedule // No measurement or machine commit: retry the transaction
// commit: the transaction is fully discarded and retried
// with a fresh send_anchor on the next loop iteration. // with a fresh send_anchor on the next loop iteration.
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue, Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))), self.machine.invalidate_kernel_tx_correlation();
continue;
}
Err(error) => {
self.machine.invalidate_kernel_tx_correlation();
return Poll::Ready(Err(ClientError::Socket(error)));
}
}; };
#[cfg(not(test))]
let send_finished_at = Instant::now(); let send_finished_at = Instant::now();
#[cfg(test)]
let send_finished_at = test_timestamps
.map(|timestamps| timestamps.send_finished_at)
.unwrap_or_else(Instant::now);
// Public post-send measurement, captured only after the // Public post-send measurement, captured only after the
// successful send above. The subsequent machine/schedule commit // successful send above. The subsequent machine commit
// is infallible. // is infallible.
#[cfg(not(test))]
let sent_at = ClientTimestamp::now(); let sent_at = ClientTimestamp::now();
#[cfg(test)]
let sent_at = test_timestamps
.map(|timestamps| timestamps.sent_at)
.unwrap_or_else(ClientTimestamp::now);
let sent = self let sent = self
.machine .machine
.commit_probe_sent(machine_commit, sent_at, bytes); .commit_probe_sent(machine_commit, sent_at, bytes);
schedule.commit(schedule_commit);
self.prepared_probe = None; self.prepared_probe = None;
let send_call = send_finished_at.saturating_duration_since(send_call_start); let send_call = send_finished_at.saturating_duration_since(send_call_start);
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
if let Err(error) = validate_datagram_length(expected_bytes, bytes) { if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
return Poll::Ready(Err(error)); return Poll::Ready(Err(error));
} }
if let Err(error) = self.drain_tx_timestamps() { if let Err(error) = self.drain_tx_timestamps() {
return Poll::Ready(Err(error)); return Poll::Ready(Err(error));
} }
events.push(echo_sent_event( events.push(echo_sent_event(self.remote, sent, send_call));
self.remote,
sent,
send_call,
scheduled_at,
timer_error,
));
return Poll::Ready(Ok(events)); return Poll::Ready(Ok(events));
} }
} }
@@ -668,17 +532,7 @@ impl AsyncClient {
return Poll::Ready(Err(ClientError::Socket(error))); return Poll::Ready(Err(ClientError::Socket(error)));
} }
} }
#[cfg(test)]
if self.test_hooks.receive_would_block.get() > 0 {
self.test_hooks
.receive_would_block
.set(self.test_hooks.receive_would_block.get() - 1);
continue;
}
#[cfg(test)]
if self.test_hooks.take_injected_interrupted() {
continue;
}
let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) { let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
Ok(datagram) => datagram, Ok(datagram) => datagram,
// See `RECV_POLL_WORK_BUDGET`: both a readiness false // See `RECV_POLL_WORK_BUDGET`: both a readiness false
@@ -712,7 +566,6 @@ impl AsyncClient {
Err(error) => return Poll::Ready(Err(error)), Err(error) => return Poll::Ready(Err(error)),
}; };
if self.machine.is_peer_closed() { if self.machine.is_peer_closed() {
self.schedule = None;
self.prepared_probe = None; self.prepared_probe = None;
if self.clear_peer_close_dscp().is_ok() { if self.clear_peer_close_dscp().is_ok() {
self.applied_traffic_class = None; self.applied_traffic_class = None;
@@ -747,10 +600,6 @@ impl AsyncClient {
let previous_traffic_class = self.applied_traffic_class; let previous_traffic_class = self.applied_traffic_class;
let expected_bytes = prepared.bytes.len(); let expected_bytes = prepared.bytes.len();
#[cfg(test)]
if self.test_hooks.pause_close_before_writable.replace(false) {
return Poll::Pending;
}
match self.socket.poll_send_ready(cx) { match self.socket.poll_send_ready(cx) {
Poll::Pending => return Poll::Pending, Poll::Pending => return Poll::Pending,
Poll::Ready(Ok(())) => {} Poll::Ready(Ok(())) => {}
@@ -761,16 +610,8 @@ impl AsyncClient {
if let Err(error) = self.clear_close_dscp() { if let Err(error) = self.clear_close_dscp() {
return Poll::Ready(Err(error)); return Poll::Ready(Err(error));
} }
let mut rollback = DscpRollback::armed( let mut rollback =
&self.socket, DscpRollback::armed(&self.socket, self.remote, previous_traffic_class);
self.remote,
previous_traffic_class,
#[cfg(test)]
&self.test_hooks.fail_dscp_restore,
);
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, prepared.bytes);
#[cfg(not(test))]
let send_result = self.socket.try_send(prepared.bytes); let send_result = self.socket.try_send(prepared.bytes);
let bytes = match send_result { let bytes = match send_result {
Ok(bytes) => bytes, Ok(bytes) => bytes,
@@ -778,28 +619,18 @@ impl AsyncClient {
if let Err(error) = rollback.restore() { if let Err(error) = rollback.restore() {
return Poll::Ready(Err(error)); return Poll::Ready(Err(error));
} }
#[cfg(test)]
if self.test_hooks.pause_close_after_would_block.replace(false) {
return Poll::Pending;
}
continue; continue;
} }
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))), Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
}; };
#[cfg(not(test))]
let close_sent_at = ClientTimestamp::now(); let close_sent_at = ClientTimestamp::now();
#[cfg(test)]
let close_sent_at = self
.test_hooks
.close_sent_at
.take()
.unwrap_or_else(ClientTimestamp::now);
let event = self let event = self
.machine .machine
.commit_local_close(prepared.commit, close_sent_at); .commit_local_close(prepared.commit, close_sent_at);
rollback.disarm(); rollback.disarm();
self.schedule = None;
self.prepared_probe = None; self.prepared_probe = None;
self.applied_traffic_class = None; self.applied_traffic_class = None;
@@ -811,33 +642,19 @@ impl AsyncClient {
} }
} }
/// Return the next scheduled probe deadline.
pub fn next_send_deadline(&self) -> Option<Instant> {
if !self.machine.is_open() {
return None;
}
self.schedule.as_ref()?.next_send_deadline()
}
pub(crate) fn probe_interval(&self) -> Option<Duration> {
if !self.machine.is_open() {
return None;
}
self.schedule.as_ref().map(ProbeSchedule::interval)
}
/// Return the configured local probe timeout. /// Return the configured local probe timeout.
pub fn probe_timeout(&self) -> Duration { pub fn probe_timeout(&self) -> Duration {
self.machine.probe_timeout() self.machine.probe_timeout()
} }
/// Return whether the current run has completed. /// Negotiated parameters of the currently open session.
pub fn is_run_complete(&self) -> bool { pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
self.machine.is_terminal() self.machine.negotiated_params()
|| self }
.schedule
.as_ref() /// Whether any sent probes still await a reply or timeout.
.is_some_and(|schedule| schedule.is_finished() && self.machine.pending_is_empty()) pub fn has_pending_probes(&self) -> bool {
!self.machine.pending_is_empty()
} }
/// Return whether an authenticated peer close ended the session. /// Return whether an authenticated peer close ended the session.
@@ -853,7 +670,8 @@ impl AsyncClient {
self.machine.packets_sent() self.machine.packets_sent()
} }
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> { /// Earliest timeout deadline among probes still awaiting a reply.
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
self.machine.next_probe_timeout_deadline() self.machine.next_probe_timeout_deadline()
} }
@@ -865,64 +683,17 @@ impl AsyncClient {
self.prepared_probe = None; self.prepared_probe = None;
} }
#[cfg(test)]
pub(crate) fn retain_open_cleanup_once(&self) {
self.test_hooks.fail_open_dscp.set(true);
self.test_hooks.pause_open_cleanup_before_send.set(true);
}
#[cfg(test)]
pub(crate) fn poll_recv_ready_for_test(&self, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.socket.poll_recv_ready(cx)
}
#[cfg(test)]
pub(crate) fn remove_pending_for_test(
&mut self,
wire_seq: u32,
) -> Option<crate::probe::PendingProbe> {
self.machine.remove_pending_for_test(wire_seq)
}
#[cfg(test)]
pub(crate) fn replace_pending_for_test(&mut self, probe: crate::probe::PendingProbe) {
self.machine.replace_pending_for_test(probe);
}
pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
let Some(schedule) = self.schedule.as_mut() else {
return Ok(());
};
let Some(deadline) = schedule.next_send_deadline() else {
return Ok(());
};
if deadline > now {
return Ok(());
}
let commit = schedule.preflight_managed_commit(deadline, now)?;
schedule.commit(commit);
Ok(())
}
fn prepare_async_open( fn prepare_async_open(
&mut self, &mut self,
machine: PreparedOpenAcceptance, machine: PreparedOpenAcceptance,
opened_at: ClientTimestamp,
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> { ) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
let Some(negotiated) = machine.normal_negotiated() else { let Some(negotiated) = machine.normal_negotiated() else {
return Ok(PreparedAsyncOpen { return Ok(PreparedAsyncOpen {
machine, machine,
schedule: None,
recv_buffer_len: None, recv_buffer_len: None,
negotiated_traffic_class: None, negotiated_traffic_class: None,
}); });
}; };
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
Ok(schedule) => schedule,
Err(primary) => {
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
}
};
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) { let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
Ok(size) => size, Ok(size) => size,
Err(primary) => { Err(primary) => {
@@ -950,17 +721,7 @@ impl AsyncClient {
machine, machine,
})); }));
} }
#[cfg(test)]
let dscp_result = if self.test_hooks.fail_open_dscp.replace(false) {
Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected negotiated DSCP failure"),
})
} else {
apply_traffic_class_to_tokio_socket(&self.socket, self.remote, negotiated_traffic_class)
};
#[cfg(not(test))]
let dscp_result = apply_traffic_class_to_tokio_socket( let dscp_result = apply_traffic_class_to_tokio_socket(
&self.socket, &self.socket,
self.remote, self.remote,
@@ -973,7 +734,6 @@ impl AsyncClient {
Ok(PreparedAsyncOpen { Ok(PreparedAsyncOpen {
machine, machine,
schedule: Some(schedule),
recv_buffer_len: Some(recv_buffer_len), recv_buffer_len: Some(recv_buffer_len),
negotiated_traffic_class: Some(negotiated_traffic_class), negotiated_traffic_class: Some(negotiated_traffic_class),
}) })
@@ -984,7 +744,7 @@ impl AsyncClient {
self.recv_buffer.resize(recv_buffer_len, 0); self.recv_buffer.resize(recv_buffer_len, 0);
} }
let outcome = self.machine.commit_open(prepared.machine); let outcome = self.machine.commit_open(prepared.machine);
self.schedule = prepared.schedule;
self.applied_traffic_class = prepared.negotiated_traffic_class; self.applied_traffic_class = prepared.negotiated_traffic_class;
self.prepared_open = None; self.prepared_open = None;
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket); self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
@@ -1017,23 +777,6 @@ impl AsyncClient {
else { else {
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())); return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
}; };
#[cfg(test)]
if self
.test_hooks
.pause_open_cleanup_before_send
.replace(false)
{
return Poll::Pending;
}
#[cfg(test)]
if self.test_hooks.fail_cleanup_send.replace(false) {
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
}
#[cfg(test)]
let send_result = self.test_hooks.try_send(&self.socket, packet);
#[cfg(not(test))]
let send_result = self.socket.try_send(packet); let send_result = self.socket.try_send(packet);
match send_result { match send_result {
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())), Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
@@ -1056,14 +799,6 @@ impl AsyncClient {
} }
fn clear_peer_close_dscp(&self) -> Result<(), ClientError> { fn clear_peer_close_dscp(&self) -> Result<(), ClientError> {
#[cfg(test)]
if self.test_hooks.fail_peer_close_dscp.replace(false) {
return Err(ClientError::SocketOption {
operation: "clear DSCP before close",
remote: self.remote,
source: io::Error::other("injected peer-close DSCP cleanup failure"),
});
}
clear_dscp_on_tokio_socket(&self.socket, self.remote) clear_dscp_on_tokio_socket(&self.socket, self.remote)
} }
@@ -1086,8 +821,6 @@ struct DscpRollback<'a> {
remote: SocketAddr, remote: SocketAddr,
previous_traffic_class: Option<u8>, previous_traffic_class: Option<u8>,
armed: bool, armed: bool,
#[cfg(test)]
fail_restore: &'a Cell<bool>,
} }
impl<'a> DscpRollback<'a> { impl<'a> DscpRollback<'a> {
@@ -1095,15 +828,12 @@ impl<'a> DscpRollback<'a> {
socket: &'a tokio::net::UdpSocket, socket: &'a tokio::net::UdpSocket,
remote: SocketAddr, remote: SocketAddr,
previous_traffic_class: Option<u8>, previous_traffic_class: Option<u8>,
#[cfg(test)] fail_restore: &'a Cell<bool>,
) -> Self { ) -> Self {
Self { Self {
socket, socket,
remote, remote,
previous_traffic_class, previous_traffic_class,
armed: true, armed: true,
#[cfg(test)]
fail_restore,
} }
} }
@@ -1111,14 +841,7 @@ impl<'a> DscpRollback<'a> {
if !self.armed { if !self.armed {
return Ok(()); return Ok(());
} }
#[cfg(test)]
if self.fail_restore.replace(false) {
return Err(ClientError::SocketOption {
operation: "restore negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected DSCP restore failure"),
});
}
match self.previous_traffic_class { match self.previous_traffic_class {
Some(traffic_class) => { Some(traffic_class) => {
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)? apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
+67 -749
View File
@@ -5,7 +5,6 @@ use std::{
sync::mpsc, sync::mpsc,
task::{Context, Poll, Waker}, task::{Context, Poll, Waker},
thread::{self, JoinHandle}, thread::{self, JoinHandle},
time::SystemTime,
}; };
use irtt_proto::{ use irtt_proto::{
@@ -18,10 +17,7 @@ use tokio::runtime::{Builder, Runtime};
mod in_tree_server; mod in_tree_server;
use super::*; use super::*;
use crate::{ use crate::{socket_options::tokio_socket_traffic_class, Client, RunMode, SocketConfig};
probe::PendingProbe, socket_options::tokio_socket_traffic_class, Client, ClientTimestamp,
RunMode, SocketConfig,
};
use in_tree_server::InTreeServer; use in_tree_server::InTreeServer;
use irtt_server::ServerConfig; use irtt_server::ServerConfig;
@@ -72,10 +68,6 @@ async fn opened_client(addr: SocketAddr, dscp: u8) -> AsyncClient {
client client
} }
fn inject_send(client: &AsyncClient, result: InjectedSend) {
client.test_hooks.sends.borrow_mut().push_back(result);
}
fn socket_dscp(client: &AsyncClient) -> u32 { fn socket_dscp(client: &AsyncClient) -> u32 {
tokio_socket_traffic_class(&client.socket, client.remote).unwrap() >> 2 tokio_socket_traffic_class(&client.socket, client.remote).unwrap() >> 2
} }
@@ -208,15 +200,6 @@ fn echo_reply(
.unwrap() .unwrap()
} }
fn start_open_server(key: Option<Vec<u8>>, packets_after_open: usize) -> TestServer {
start_server(move |socket, tx| {
open_session(&socket, &tx, key.as_deref());
for _ in 0..packets_after_open {
let _ = recv_packet(&socket, &tx);
}
})
}
fn start_no_test_server() -> TestServer { fn start_no_test_server() -> TestServer {
start_server(move |socket, tx| { start_server(move |socket, tx| {
let (open_packet, peer) = recv_packet(&socket, &tx); let (open_packet, peer) = recv_packet(&socket, &tx);
@@ -285,124 +268,6 @@ fn recv_before_open_fails_on_first_poll_without_socket_readiness() {
}); });
} }
#[test]
fn open_ignores_malformed_and_bad_hmac_without_resending() {
let key = b"open-filter-key".to_vec();
let server_key = key.clone();
let server = start_server(move |socket, tx| {
let (open_packet, peer) = recv_packet(&socket, &tx);
let (params, _) = open_request(&open_packet, Some(&server_key));
socket.send_to(&[0_u8], peer).unwrap();
let mut bad_hmac = encode_open_reply(
&OpenReply {
flags: flags::FLAG_OPEN | flags::FLAG_REPLY,
token: TOKEN,
params: params.clone(),
},
Some(&server_key),
)
.unwrap();
bad_hmac[4] ^= 0xff;
socket.send_to(&bad_hmac, peer).unwrap();
send_open_reply(
&socket,
peer,
params,
Some(&server_key),
flags::FLAG_OPEN | flags::FLAG_REPLY,
TOKEN,
);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, Some(key), 0))
.await
.unwrap();
inject_send(&client, InjectedSend::WouldBlock);
let attempts = client.test_hooks.send_attempts.get();
assert!(matches!(
client.open().await.unwrap(),
OpenOutcome::Started { .. }
));
assert_eq!(client.test_hooks.send_attempts.get(), attempts + 2);
assert!(client.prepared_open.is_none());
assert!(client.schedule.is_some());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
let server = start_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_packet(&socket, &tx);
for _ in 0..3 {
socket.send_to(&[0_u8], peer).unwrap();
}
}
});
let mut client_config = config(server.addr, None, 0);
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
runtime().block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
assert!(matches!(client.open().await, Err(ClientError::OpenTimeout)));
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn opening_poll_budget_yields_then_public_open_completes() {
let (queued, queue_ready) = mpsc::channel();
let server = start_server(move |socket, tx| {
let (open_packet, peer) = recv_packet(&socket, &tx);
let (params, _) = open_request(&open_packet, None);
for _ in 0..=OPEN_POLL_WORK_BUDGET {
socket.send_to(&[0_u8], peer).unwrap();
}
send_open_reply(
&socket,
peer,
params,
None,
flags::FLAG_OPEN | flags::FLAG_REPLY,
TOKEN,
);
queued.send(()).unwrap();
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.socket.writable().await.unwrap();
client.test_hooks.pause_open_before_readable.set(true);
let mut opening = Box::pin(client.open());
// Submit the one opening request, then let the server queue all noise
// before performing the explicit bounded poll below.
assert!(matches!(
poll_once(Pin::as_mut(&mut opening)),
Poll::Pending
));
queue_ready.recv_timeout(Duration::from_secs(2)).unwrap();
// The valid reply is behind more ignored datagrams than one opening
// poll may consume, so this cannot complete without the budget yield.
assert!(matches!(
poll_once(Pin::as_mut(&mut opening)),
Poll::Pending
));
assert!(matches!(
opening.await.unwrap(),
OpenOutcome::Started { .. }
));
});
assert_eq!(server.finish().len(), 1);
}
#[test] #[test]
fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() { fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() {
let server = start_server(move |socket, tx| { let server = start_server(move |socket, tx| {
@@ -485,478 +350,6 @@ fn authenticated_rejection_is_terminal_without_retry() {
assert_eq!(server.finish().len(), 1); assert_eq!(server.finish().len(), 1);
} }
#[test]
fn opening_cancellation_before_submission_is_transactional() {
let server = start_server(move |socket, tx| {
socket
.set_read_timeout(Some(Duration::from_millis(50)))
.unwrap();
let mut buffer = [0_u8; 512];
if let Ok((len, _)) = socket.recv_from(&mut buffer) {
tx.send(buffer[..len].to_vec()).unwrap();
}
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.test_hooks.pause_open_before_writable.set(true);
let mut open = Box::pin(client.open());
assert!(matches!(poll_once(Pin::as_mut(&mut open)), Poll::Pending));
drop(open);
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.prepared_open.is_some());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
});
assert!(server.finish().is_empty());
}
#[test]
fn opening_cancellation_after_submission_can_retry() {
let server = start_server(move |socket, tx| {
let (first, _) = recv_packet(&socket, &tx);
let (first_params, _) = open_request(&first, None);
let (_, peer) = recv_packet(&socket, &tx);
send_open_reply(
&socket,
peer,
first_params,
None,
flags::FLAG_OPEN | flags::FLAG_REPLY,
TOKEN,
);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.socket.writable().await.unwrap();
client.test_hooks.pause_open_before_readable.set(true);
let mut open = Box::pin(client.open());
assert!(matches!(poll_once(Pin::as_mut(&mut open)), Poll::Pending));
drop(open);
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.prepared_open.is_some());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert!(matches!(
client.open().await.unwrap(),
OpenOutcome::Started { .. }
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn adapter_failure_sends_preencoded_cleanup_without_committing_open() {
let server = start_server(move |socket, tx| {
open_session(&socket, &tx, None);
let (close, _) = recv_packet(&socket, &tx);
assert_eq!(close_request_token(&close, None), TOKEN);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 46))
.await
.unwrap();
client.test_hooks.fail_open_dscp.set(true);
assert!(matches!(
client.open().await,
Err(ClientError::SocketOption { .. })
));
assert!(client.machine.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn cleanup_failure_preserves_primary_open_error() {
let server = start_server(move |socket, tx| {
open_session(&socket, &tx, None);
});
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 46))
.await
.unwrap();
client.test_hooks.fail_open_dscp.set(true);
client.test_hooks.fail_cleanup_send.set(true);
assert!(matches!(
client.open().await,
Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
..
})
));
assert!(client.machine.prepare_open_request().is_ok());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn no_test_open_has_no_schedule_or_dscp_state() {
let server = start_no_test_server();
runtime().block_on(async {
let mut client_config = config(server.addr, None, 46);
client_config.run_mode = RunMode::NoTest;
let mut client = AsyncClient::connect(client_config).await.unwrap();
assert!(matches!(
client.open().await.unwrap(),
OpenOutcome::NoTestCompleted { .. }
));
assert!(client.prepared_open.is_none());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert!(client.is_run_complete());
assert!(matches!(
client.close().await,
Err(ClientError::AlreadyCompleted)
));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn probe_cancellation_would_block_and_error_retain_one_preparation() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let deadline = client.next_send_deadline();
let attempts = client.test_hooks.send_attempts.get();
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
let retained = client.prepared_probe.as_ref().unwrap().bytes.clone();
let retained_ptr = client.prepared_probe.as_ref().unwrap().bytes.as_ptr();
assert_eq!(client.machine.packets_sent(), 0);
assert!(client.machine.pending_is_empty());
assert_eq!(client.next_send_deadline(), deadline);
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
client
.test_hooks
.sends
.borrow_mut()
.extend([InjectedSend::WouldBlock, InjectedSend::Error]);
assert!(matches!(
client.send_probe().await,
Err(ClientError::Socket(_))
));
assert_eq!(client.prepared_probe.as_ref().unwrap().bytes, retained);
assert_eq!(
client.prepared_probe.as_ref().unwrap().bytes.as_ptr(),
retained_ptr
);
assert_eq!(client.machine.packets_sent(), 0);
assert!(client.machine.pending_is_empty());
assert_eq!(client.next_send_deadline(), deadline);
assert!(matches!(
client.send_probe().await.unwrap().as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
assert_eq!(client.machine.packets_sent(), 1);
assert!(client.prepared_probe.is_none());
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn probe_false_readiness_resamples_all_timestamps() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let scheduled_at = client.next_send_deadline().unwrap();
let first_sent = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1),
mono: scheduled_at + Duration::from_millis(3),
};
let second_sent = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(2),
mono: scheduled_at + Duration::from_millis(11),
};
client.test_hooks.probe_timestamps.borrow_mut().extend([
ProbeSendTimestamps {
permission_at: scheduled_at + Duration::from_millis(1),
send_anchor: first_sent,
sent_at: first_sent,
send_call_start: scheduled_at + Duration::from_millis(4),
send_finished_at: scheduled_at + Duration::from_millis(6),
},
ProbeSendTimestamps {
permission_at: scheduled_at + Duration::from_millis(8),
send_anchor: second_sent,
sent_at: second_sent,
send_call_start: scheduled_at + Duration::from_millis(12),
send_finished_at: scheduled_at + Duration::from_millis(17),
},
]);
inject_send(&client, InjectedSend::WouldBlock);
let events = client.send_probe().await.unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoSent {
scheduled_at: event_scheduled,
sent_at,
send_call,
timer_error,
..
}] if *event_scheduled == scheduled_at
&& *sent_at == second_sent
&& *send_call == Duration::from_millis(5)
&& *timer_error == Duration::from_millis(11)
));
assert!(matches!(
client
.poll_timeouts_at(second_sent.mono + client.probe_timeout())
.unwrap()
.as_slice(),
[ClientEvent::EchoLoss { sent_at, .. }] if *sent_at == second_sent
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn public_timeout_polling_remains_exhaustive() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let now = Instant::now();
for seq in 0..3 {
client.replace_pending_for_test(PendingProbe {
wire_seq: seq,
sent_at: ClientTimestamp {
mono: now - Duration::from_secs(2),
wall: SystemTime::UNIX_EPOCH,
},
timeout_at: now - Duration::from_secs(1),
tx_not_before_wall: SystemTime::UNIX_EPOCH,
kernel_tx_timestamp: None,
});
}
assert!(matches!(
client.poll_timeouts_at(now).unwrap().as_slice(),
[
ClientEvent::EchoLoss { seq: 0, .. },
ClientEvent::EchoLoss { seq: 1, .. },
ClientEvent::EchoLoss { seq: 2, .. },
]
));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn successful_short_probe_commits_before_length_error() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
let probe_len = client.prepared_probe.as_ref().unwrap().bytes.len();
let deadline = client.next_send_deadline().unwrap();
inject_send(&client, InjectedSend::ReportedLength(probe_len - 1));
assert!(matches!(
client.send_probe().await,
Err(ClientError::DatagramLengthMismatch { .. })
));
assert_eq!(client.machine.packets_sent(), 1);
assert!(!client.machine.pending_is_empty());
assert!(client.prepared_probe.is_none());
assert_eq!(
client.next_send_deadline(),
Some(deadline + client.machine.config().interval)
);
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn stale_retained_probe_is_rejected_before_send() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
let advanced = client.machine.prepare_probe().unwrap().unwrap();
let preflight = client.machine.preflight_probe_commit(&advanced).unwrap();
let commit = client
.machine
.finalize_probe_commit(preflight, ClientTimestamp::now())
.unwrap();
client
.machine
.commit_probe_sent(commit, ClientTimestamp::now(), advanced.bytes.len());
let attempts = client.test_hooks.send_attempts.get();
assert!(matches!(
client.send_probe().await,
Err(ClientError::StalePreparedProbe { .. })
));
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
assert!(client.prepared_probe.is_some());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn finite_duration_boundary_clears_preparation_without_send() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client_config = config(server.addr, None, 0);
client_config.duration = Some(Duration::from_millis(20));
let mut client = AsyncClient::connect(client_config).await.unwrap();
client.open().await.unwrap();
let start = client.next_send_deadline().unwrap();
let attempts = client.test_hooks.send_attempts.get();
client
.test_hooks
.probe_timestamps
.borrow_mut()
.push_back(ProbeSendTimestamps {
permission_at: start + Duration::from_millis(20),
send_anchor: ClientTimestamp {
wall: SystemTime::UNIX_EPOCH,
mono: start + Duration::from_millis(21),
},
sent_at: ClientTimestamp {
wall: SystemTime::UNIX_EPOCH,
mono: start + Duration::from_millis(21),
},
send_call_start: start + Duration::from_millis(22),
send_finished_at: start + Duration::from_millis(23),
});
assert!(client.send_probe().await.unwrap().is_empty());
assert!(client.prepared_probe.is_none());
assert_eq!(client.machine.packets_sent(), 0);
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
assert!(client.is_run_complete());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn receive_retries_false_readiness_and_shares_packet_classification() {
let server = start_server(move |socket, tx| {
let (params, peer) = open_session(&socket, &tx, None);
let (probe_packet, _) = recv_packet(&socket, &tx);
let sequence = echo_request_sequence(&probe_packet, None);
socket
.send_to(
&echo_reply(&params, sequence, TOKEN, flags::FLAG_REPLY, None),
peer,
)
.unwrap();
});
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.send_probe().await.unwrap();
client.test_hooks.receive_would_block.set(2);
assert!(matches!(
client.recv().await.unwrap().as_slice(),
[ClientEvent::EchoReply { seq: 0, .. }]
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn receive_retries_interrupted_syscall_and_shares_packet_classification() {
let server = start_server(move |socket, tx| {
let (params, peer) = open_session(&socket, &tx, None);
let (probe_packet, _) = recv_packet(&socket, &tx);
let sequence = echo_request_sequence(&probe_packet, None);
socket
.send_to(
&echo_reply(&params, sequence, TOKEN, flags::FLAG_REPLY, None),
peer,
)
.unwrap();
});
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.send_probe().await.unwrap();
client.test_hooks.inject_recv_interrupted.set(2);
assert!(matches!(
client.recv().await.unwrap().as_slice(),
[ClientEvent::EchoReply { seq: 0, .. }]
));
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn open_retries_interrupted_syscall_within_the_same_attempt() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = AsyncClient::connect(config(server.addr, None, 0))
.await
.unwrap();
client.test_hooks.inject_recv_interrupted.set(3);
assert!(matches!(
client.open().await,
Ok(OpenOutcome::Started { .. })
));
// Only one open request reached the server: interruptions were
// absorbed inside the single open attempt, with no retransmit.
assert_eq!(client.test_hooks.send_attempts.get(), 1);
});
server.finish();
}
#[test]
fn peer_close_dscp_failure_preserves_events_and_logical_ownership() {
let server = start_peer_close_server();
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
client.send_probe().await.unwrap();
client.prepared_probe = client.machine.prepare_probe().unwrap();
client.test_hooks.fail_peer_close_dscp.set(true);
assert!(matches!(
client.recv().await.unwrap().as_slice(),
[
ClientEvent::EchoReply { .. },
ClientEvent::SessionClosed { .. }
]
));
assert!(client.is_peer_closed());
assert!(client.is_run_complete());
assert!(client.schedule.is_none());
assert!(client.prepared_probe.is_none());
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(client.next_send_deadline(), None);
assert_eq!(socket_dscp(&client), 46);
});
assert_eq!(server.finish().len(), 2);
}
#[test] #[test]
fn recv_after_local_close_and_no_test_fails_on_first_poll() { fn recv_after_local_close_and_no_test_fails_on_first_poll() {
let close_server = start_server(move |socket, tx| { let close_server = start_server(move |socket, tx| {
@@ -988,141 +381,6 @@ fn recv_after_local_close_and_no_test_fails_on_first_poll() {
assert_eq!(no_test_server.finish().len(), 1); assert_eq!(no_test_server.finish().len(), 1);
} }
#[test]
fn close_cancellation_and_would_block_restore_dscp_before_suspend() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
let deadline = client.next_send_deadline();
let attempts = client.test_hooks.send_attempts.get();
assert_eq!(socket_dscp(&client), 46);
client.test_hooks.pause_close_before_writable.set(true);
let mut close = Box::pin(client.close());
assert!(matches!(poll_once(Pin::as_mut(&mut close)), Poll::Pending));
drop(close);
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.next_send_deadline(), deadline);
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(client.test_hooks.send_attempts.get(), attempts);
assert_eq!(socket_dscp(&client), 46);
inject_send(&client, InjectedSend::WouldBlock);
client.test_hooks.pause_close_after_would_block.set(true);
let mut close = Box::pin(client.close());
assert!(matches!(poll_once(Pin::as_mut(&mut close)), Poll::Pending));
drop(close);
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(socket_dscp(&client), 46);
assert!(matches!(
client.close().await.unwrap().as_slice(),
[ClientEvent::SessionClosed { .. }]
));
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
});
assert_eq!(server.finish().len(), 2);
}
#[test]
fn close_errors_preserve_open_state_and_primary_send_error() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
let deadline = client.next_send_deadline();
inject_send(&client, InjectedSend::Error);
assert!(matches!(client.close().await, Err(ClientError::Socket(_))));
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.next_send_deadline(), deadline);
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(socket_dscp(&client), 46);
client.test_hooks.fail_dscp_restore.set(true);
inject_send(&client, InjectedSend::Error);
assert!(matches!(client.close().await, Err(ClientError::Socket(_))));
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn close_would_block_restore_failure_stops_without_committing() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 46).await;
client.test_hooks.fail_dscp_restore.set(true);
inject_send(&client, InjectedSend::WouldBlock);
assert!(matches!(
client.close().await,
Err(ClientError::SocketOption {
operation: "restore negotiated DSCP",
..
})
));
assert!(client.machine.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(184));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn successful_short_close_commits_before_length_error() {
let server = start_open_server(None, 0);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
client.test_hooks.pause_probe_before_writable.set(true);
let mut send = Box::pin(client.send_probe());
assert!(matches!(poll_once(Pin::as_mut(&mut send)), Poll::Pending));
drop(send);
assert!(client.prepared_probe.is_some());
let close_len = client.machine.prepare_close().unwrap().bytes.len();
inject_send(&client, InjectedSend::ReportedLength(close_len - 1));
assert!(matches!(
client.close().await,
Err(ClientError::DatagramLengthMismatch { .. })
));
assert!(!client.machine.is_open());
assert!(client.schedule.is_none());
assert!(client.prepared_probe.is_none());
assert_eq!(client.applied_traffic_class, None);
assert!(matches!(
client.machine.prepare_close(),
Err(ClientError::AlreadyClosed)
));
});
assert_eq!(server.finish().len(), 1);
}
#[test]
fn successful_close_uses_post_acceptance_timestamp() {
let server = start_open_server(None, 1);
runtime().block_on(async {
let mut client = opened_client(server.addr, 0).await;
let close_sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(44),
mono: Instant::now() + Duration::from_secs(1),
};
client.test_hooks.close_sent_at.set(Some(close_sent_at));
assert!(matches!(
client.close().await.unwrap().as_slice(),
[ClientEvent::SessionClosed { at, .. }] if *at == close_sent_at
));
});
assert_eq!(server.finish().len(), 2);
}
#[test] #[test]
fn blocking_and_async_hmac_dscp_lifecycle_are_semantically_equivalent() { fn blocking_and_async_hmac_dscp_lifecycle_are_semantically_equivalent() {
let key = b"async-equivalence-key".to_vec(); let key = b"async-equivalence-key".to_vec();
@@ -1178,7 +436,7 @@ fn blocking_and_async_no_test_are_semantically_equivalent() {
blocking_no_test_config.run_mode = RunMode::NoTest; blocking_no_test_config.run_mode = RunMode::NoTest;
let mut blocking_no_test = Client::connect(blocking_no_test_config).unwrap(); let mut blocking_no_test = Client::connect(blocking_no_test_config).unwrap();
let blocking_no_test_open = blocking_no_test.open().unwrap(); let blocking_no_test_open = blocking_no_test.open().unwrap();
assert!(blocking_no_test.is_run_complete()); assert!(blocking_no_test.negotiated_params().is_none());
drop(blocking_no_test_server); drop(blocking_no_test_server);
let async_no_test_server = InTreeServer::start(ServerConfig::default()); let async_no_test_server = InTreeServer::start(ServerConfig::default());
@@ -1187,7 +445,7 @@ fn blocking_and_async_no_test_are_semantically_equivalent() {
let async_no_test_open = runtime().block_on(async { let async_no_test_open = runtime().block_on(async {
let mut client = AsyncClient::connect(async_no_test_config).await.unwrap(); let mut client = AsyncClient::connect(async_no_test_config).await.unwrap();
let opened = client.open().await.unwrap(); let opened = client.open().await.unwrap();
assert!(client.is_run_complete()); assert!(client.negotiated_params().is_none());
opened opened
}); });
drop(async_no_test_server); drop(async_no_test_server);
@@ -1332,8 +590,11 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
// must be able to open the same connected client. // must be able to open the same connected client.
let blocking_server = start_silent_then_open_server(1); let blocking_server = start_silent_then_open_server(1);
let mut blocking = Client::connect(config(blocking_server.addr, None, 0)).unwrap(); let mut blocking = Client::connect(config(blocking_server.addr, None, 0)).unwrap();
assert!(!blocking.has_pending_probes());
let blocking_first = blocking.open().unwrap_err(); let blocking_first = blocking.open().unwrap_err();
assert!(!blocking.has_pending_probes());
let blocking_second = blocking.open().unwrap(); let blocking_second = blocking.open().unwrap();
assert!(!blocking.has_pending_probes());
let blocking_sent = blocking.send_probe().unwrap(); let blocking_sent = blocking.send_probe().unwrap();
let blocking_reply = blocking.recv_once().unwrap(); let blocking_reply = blocking.recv_once().unwrap();
let blocking_close = blocking.close().unwrap(); let blocking_close = blocking.close().unwrap();
@@ -1345,8 +606,11 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
let mut client = AsyncClient::connect(config(async_server.addr, None, 0)) let mut client = AsyncClient::connect(config(async_server.addr, None, 0))
.await .await
.unwrap(); .unwrap();
assert!(!client.has_pending_probes());
let first = client.open().await.unwrap_err(); let first = client.open().await.unwrap_err();
assert!(!client.has_pending_probes());
let second = client.open().await.unwrap(); let second = client.open().await.unwrap();
assert!(!client.has_pending_probes());
let sent = client.send_probe().await.unwrap(); let sent = client.send_probe().await.unwrap();
let reply = client.recv().await.unwrap(); let reply = client.recv().await.unwrap();
let closed = client.close().await.unwrap(); let closed = client.close().await.unwrap();
@@ -1380,30 +644,53 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
#[test] #[test]
fn blocking_and_async_complete_every_caller_paced_probe() { fn blocking_and_async_complete_every_caller_paced_probe() {
const PROBES: u32 = 3; const PROBES: u32 = 3;
fn caller_config(addr: SocketAddr) -> ClientConfig {
ClientConfig {
duration: Some(Duration::from_millis(1)),
interval: Duration::from_secs(1),
..config(addr, None, 0)
}
}
let blocking_server = InTreeServer::start(ServerConfig::default()); let blocking_server = InTreeServer::start(ServerConfig::default());
let mut blocking = Client::connect(config(blocking_server.addr, None, 0)).unwrap(); let mut blocking = Client::connect(caller_config(blocking_server.addr)).unwrap();
blocking.open().unwrap(); let outcome = blocking.open().unwrap();
assert_eq!(
blocking.negotiated_params(),
Some(open_negotiated(&outcome))
);
assert!(!blocking.has_pending_probes());
// Oversleeping cannot invalidate this: low-level sends remain caller-owned
// after the negotiated duration. The compliant server has no duration cap.
thread::sleep(Duration::from_millis(2));
let mut blocking_sent = Vec::new(); let mut blocking_sent = Vec::new();
let mut blocking_replies = Vec::new(); let mut blocking_replies = Vec::new();
for _ in 0..PROBES { for _ in 0..PROBES {
blocking_sent.extend(blocking.send_probe().unwrap()); blocking_sent.extend(blocking.send_probe().unwrap());
assert!(blocking.has_pending_probes());
assert!(blocking.next_probe_timeout_deadline().is_some());
blocking_replies.extend(blocking.recv_once().unwrap()); blocking_replies.extend(blocking.recv_once().unwrap());
assert!(!blocking.has_pending_probes());
} }
blocking.close().unwrap(); blocking.close().unwrap();
drop(blocking_server); drop(blocking_server);
let async_server = InTreeServer::start(ServerConfig::default()); let async_server = InTreeServer::start(ServerConfig::default());
let (async_sent, async_replies) = runtime().block_on(async { let (async_sent, async_replies) = runtime().block_on(async {
let mut client = AsyncClient::connect(config(async_server.addr, None, 0)) let mut client = AsyncClient::connect(caller_config(async_server.addr))
.await .await
.unwrap(); .unwrap();
client.open().await.unwrap(); let outcome = client.open().await.unwrap();
assert_eq!(client.negotiated_params(), Some(open_negotiated(&outcome)));
tokio::time::sleep(Duration::from_millis(2)).await;
let mut sent = Vec::new(); let mut sent = Vec::new();
let mut replies = Vec::new(); let mut replies = Vec::new();
for _ in 0..PROBES { for _ in 0..PROBES {
sent.extend(client.send_probe().await.unwrap()); sent.extend(client.send_probe().await.unwrap());
assert!(client.has_pending_probes());
assert!(client.next_probe_timeout_deadline().is_some());
replies.extend(client.recv().await.unwrap()); replies.extend(client.recv().await.unwrap());
assert!(!client.has_pending_probes());
} }
client.close().await.unwrap(); client.close().await.unwrap();
(sent, replies) (sent, replies)
@@ -1419,6 +706,16 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
// the sequence helpers keep: an extra event on either side is a difference. // the sequence helpers keep: an extra event on either side is a difference.
assert_eq!(blocking_sent.len(), async_sent.len()); assert_eq!(blocking_sent.len(), async_sent.len());
assert_eq!(blocking_replies.len(), async_replies.len()); assert_eq!(blocking_replies.len(), async_replies.len());
for event in blocking_sent.iter().chain(&async_sent) {
assert!(matches!(
event,
ClientEvent::EchoSent {
scheduled_at: None,
timer_error: None,
..
}
));
}
for (blocking, asynchronous) in blocking_sent.iter().zip(&async_sent) { for (blocking, asynchronous) in blocking_sent.iter().zip(&async_sent) {
assert_matching_event_shape(blocking, asynchronous); assert_matching_event_shape(blocking, asynchronous);
} }
@@ -1470,3 +767,24 @@ fn blocking_and_async_reject_probes_before_open_and_after_close() {
error_name(&async_after_close) error_name(&async_after_close)
); );
} }
#[test]
fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
let server = start_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_packet(&socket, &tx);
for _ in 0..3 {
socket.send_to(&[0_u8], peer).unwrap();
}
}
});
let mut client_config = config(server.addr, None, 0);
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
runtime().block_on(async {
let mut client = AsyncClient::connect(client_config).await.unwrap();
assert!(matches!(client.open().await, Err(ClientError::OpenTimeout)));
assert!(client.machine.prepare_open_request().is_ok());
});
assert_eq!(server.finish().len(), 2);
}
+112 -418
View File
@@ -4,12 +4,6 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
#[cfg(test)]
use std::cell::Cell;
#[cfg(test)]
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
use crate::{ use crate::{
config::{ClientConfig, RecvBudget}, config::{ClientConfig, RecvBudget},
error::ClientError, error::ClientError,
@@ -24,23 +18,9 @@ use crate::{
timing::ClientTimestamp, timing::ClientTimestamp,
}; };
pub(crate) mod schedule;
use schedule::{instant_abs_diff, ProbeSchedule};
#[derive(Debug, Clone, Copy)]
enum ProbeScheduleMode {
CallerPaced,
#[cfg(test)]
Managed {
scheduled_at: Instant,
},
}
#[derive(Debug)] #[derive(Debug)]
struct PreparedClientOpen { struct PreparedClientOpen {
machine: PreparedOpenAcceptance, machine: PreparedOpenAcceptance,
schedule: Option<ProbeSchedule>,
recv_buffer_len: Option<usize>, recv_buffer_len: Option<usize>,
negotiated_traffic_class: Option<u8>, negotiated_traffic_class: Option<u8>,
previous_traffic_class: Option<u8>, previous_traffic_class: Option<u8>,
@@ -53,60 +33,40 @@ struct PreparedClientOpenFailure {
machine: PreparedOpenAcceptance, machine: PreparedOpenAcceptance,
} }
#[cfg(test)]
#[derive(Debug, Default)]
struct ClientTestHooks {
fail_open_dscp: Cell<bool>,
fail_open_timeout_restore: Cell<bool>,
fail_close_event_reserve: Cell<bool>,
fail_close_dscp_clear: Cell<bool>,
fail_close_send: Cell<bool>,
close_reported_len: Cell<Option<usize>>,
close_send_attempts: Cell<usize>,
close_sent_at: Cell<Option<ClientTimestamp>>,
fail_cleanup_send: Cell<bool>,
recv_buffer_len_override: Cell<Option<usize>>,
fail_dscp_restore: Cell<bool>,
last_restored_read_timeout: Cell<Option<Duration>>,
prepared_active_session_before_dscp: Cell<bool>,
/// Number of subsequent receive attempts that should report
/// `io::ErrorKind::Interrupted` before a real receive is attempted.
/// Decremented on every injected receive; `0` means inject nothing.
inject_recv_interrupted: Cell<usize>,
}
#[cfg(test)]
#[derive(Debug, Clone, Copy)]
pub(crate) struct ProbeSendTimestamps {
pub(crate) permission_at: Instant,
/// Private pre-send anchor: timeout deadline / kernel TX lower bound.
pub(crate) send_anchor: ClientTimestamp,
pub(crate) send_call_start: Instant,
pub(crate) send_finished_at: Instant,
/// Public post-send measurement: RTT / OWD fallback / timer_error.
pub(crate) sent_at: ClientTimestamp,
}
#[cfg(test)]
use crate::{
session::machine::{
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
unix_epoch_ns_i64, update_highest_received,
},
session::negotiate_params,
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
};
/// Low-level synchronous IRTT client. /// Low-level synchronous IRTT client.
/// ///
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a /// `Client` exposes the protocol steps directly: connect a UDP socket, open a
/// session, send probes, receive replies, poll timeouts, and close. Callers /// session, send probes, receive replies, poll timeouts, and close. Callers
/// that do not need to own this loop can use the unified managed API in /// that do not need to own this loop can use the unified managed API in
/// [`crate::managed`] when the `tokio` feature is enabled. /// [`crate::managed`] when the `tokio` feature is enabled.
///
/// # Example
///
/// `Client` is the runtime-free adapter. The caller drives receives and timeout
/// polling between sends; see the standalone example for a complete loop.
/// This example type-checks without contacting a server:
///
/// ```no_run
/// use irtt_client::{Client, ClientConfig};
///
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = Client::connect(ClientConfig::default())?;
/// let outcome = client.open()?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe()?;
/// println!("sent: {sent:?}");
/// let received = client.recv_once()?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close()?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct Client { pub struct Client {
runtime: SessionMachine, runtime: SessionMachine,
schedule: Option<ProbeSchedule>,
socket: UdpSocket, socket: UdpSocket,
remote: SocketAddr, remote: SocketAddr,
recv_buffer: Vec<u8>, recv_buffer: Vec<u8>,
@@ -118,14 +78,6 @@ pub struct Client {
/// unnecessary error-queue drains are skipped once it is known there is /// unnecessary error-queue drains are skipped once it is known there is
/// nothing to drain. /// nothing to drain.
tx_timestamping_enabled: bool, tx_timestamping_enabled: bool,
#[cfg(test)]
test_hooks: ClientTestHooks,
#[cfg(test)]
probe_reported_len: Option<usize>,
#[cfg(test)]
probe_send_error: bool,
#[cfg(test)]
probe_send_timestamps: Option<ProbeSendTimestamps>,
} }
impl Client { impl Client {
@@ -142,20 +94,11 @@ impl Client {
Ok(Self { Ok(Self {
runtime, runtime,
schedule: None,
socket, socket,
remote, remote,
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?], recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
applied_traffic_class: None, applied_traffic_class: None,
tx_timestamping_enabled: false, tx_timestamping_enabled: false,
#[cfg(test)]
test_hooks: ClientTestHooks::default(),
#[cfg(test)]
probe_reported_len: None,
#[cfg(test)]
probe_send_error: false,
#[cfg(test)]
probe_send_timestamps: None,
}) })
} }
@@ -163,12 +106,13 @@ impl Client {
/// ///
/// On success, returns the negotiated open outcome and transitions the /// On success, returns the negotiated open outcome and transitions the
/// client into either an open probe session or completed no-test state. /// client into either an open probe session or completed no-test state.
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed, unrelated, /// Open attempts use [`ClientConfig::open_timeouts`]. Malformed or
/// or unauthenticated datagrams are ignored until the current attempt's /// unrelated datagrams are ignored until the current attempt's absolute
/// absolute deadline, so one attempt may consume several datagrams without /// deadline, so one attempt may consume several datagrams without
/// retransmitting. Silence or ignored traffic eventually produces /// retransmitting. Silence or ignored traffic eventually produces
/// [`ClientError::OpenTimeout`], while authenticated incompatibility remains /// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or
/// terminal. /// incompatible Open reply from the connected peer is terminal; when HMAC
/// is configured, that recognition additionally requires authentication.
/// ///
/// When a trusted reply allocates a token but later negotiation or socket /// When a trusted reply allocates a token but later negotiation or socket
/// preparation fails, the client sends a best-effort cleanup close and /// preparation fails, the client sends a best-effort cleanup close and
@@ -177,8 +121,9 @@ impl Client {
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> { pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
let result = self.open_transaction(); let result = self.open_transaction();
if result.is_err() { if result.is_err() {
let _ = let _ = self
self.restore_open_read_timeout(self.runtime.config().socket_config.recv_timeout); .socket
.set_read_timeout(self.runtime.config().socket_config.recv_timeout);
} }
result result
} }
@@ -188,19 +133,11 @@ impl Client {
/// The close event means the client has sent its close packet and stopped /// The close event means the client has sent its close packet and stopped
/// tracking the session locally; it is not a server acknowledgement. If the /// tracking the session locally; it is not a server acknowledgement. If the
/// send fails, the negotiated DSCP is restored best-effort and the session /// send fails, the negotiated DSCP is restored best-effort and the session
/// and probe schedule remain open. /// remains open.
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> { pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
let prepared = self.runtime.prepare_close()?; let prepared = self.runtime.prepare_close()?;
let mut events = Vec::new(); let mut events = Vec::new();
#[cfg(test)]
if self.test_hooks.fail_close_event_reserve.replace(false) {
events
.try_reserve(usize::MAX)
.map_err(|source| ClientError::AllocationFailed {
operation: "close event result",
source,
})?;
}
events events
.try_reserve(1) .try_reserve(1)
.map_err(|source| ClientError::AllocationFailed { .map_err(|source| ClientError::AllocationFailed {
@@ -211,51 +148,74 @@ impl Client {
let expected_bytes = prepared.bytes.len(); let expected_bytes = prepared.bytes.len();
self.clear_close_dscp()?; self.clear_close_dscp()?;
let bytes = match self.send_close_datagram(prepared.bytes) { let bytes = match self.socket.send(prepared.bytes) {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => { Err(err) => {
self.restore_dscp_best_effort(previous_traffic_class); self.restore_dscp_best_effort(previous_traffic_class);
return Err(ClientError::Socket(err)); return Err(ClientError::Socket(err));
} }
}; };
#[cfg(not(test))]
let sent_at = ClientTimestamp::now(); let sent_at = ClientTimestamp::now();
#[cfg(test)]
let sent_at = self
.test_hooks
.close_sent_at
.take()
.unwrap_or_else(ClientTimestamp::now);
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
self.schedule = None;
self.applied_traffic_class = None;
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
self.applied_traffic_class = None;
validate_datagram_length(expected_bytes, bytes)?; validate_datagram_length(expected_bytes, bytes)?;
events.push(event); events.push(event);
Ok(events) Ok(events)
} }
/// Return the monotonic deadline for the next probe send, if another probe
/// is scheduled.
pub fn next_send_deadline(&self) -> Option<Instant> {
if !self.runtime.is_open() {
return None;
}
self.schedule.as_ref()?.next_send_deadline()
}
/// Return the local timeout used to classify pending probes as lost. /// Return the local timeout used to classify pending probes as lost.
pub fn probe_timeout(&self) -> Duration { pub fn probe_timeout(&self) -> Duration {
self.runtime.probe_timeout() self.runtime.probe_timeout()
} }
/// Send one echo probe if the negotiated run is still active. /// Send one echo probe now. The caller owns pacing and run duration.
/// ///
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event /// The negotiated interval and duration do not gate low-level sends.
/// list when the run duration has elapsed and no further probe should be
/// sent.
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> { pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_transaction(ProbeScheduleMode::CallerPaced) let remote = self.remote;
let (runtime, socket) = (&mut self.runtime, &self.socket);
let prepared = runtime.prepare_probe()?;
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
let mut events = Vec::new();
events
.try_reserve(1)
.map_err(|source| ClientError::AllocationFailed {
operation: "probe event result",
source,
})?;
let expected_bytes = prepared.bytes.len();
// All fallible probe-commit preflight (timeout deadline arithmetic,
// capacity/collision checks already done above) is finalized from
// this PRIVATE pre-send anchor, before the socket send. It is not
// the public measurement `sent_at` captured further below.
let send_anchor = ClientTimestamp::now();
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
let send_call_start = Instant::now();
let bytes = match socket.send(&prepared.bytes) {
Ok(bytes) => bytes,
Err(error) => {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(error));
}
};
let send_finished_at = Instant::now();
// The public measurement timestamp: paired wall/monotonic sample
// captured immediately after the successful socket send completed.
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
// endpoint. The infallible machine commit below never fails.
let sent_at = ClientTimestamp::now();
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
let send_call = send_finished_at.saturating_duration_since(send_call_start);
validate_datagram_length(expected_bytes, bytes)?;
self.drain_tx_timestamps()?;
events.push(echo_sent_event(remote, sent, send_call));
Ok(events)
} }
/// Receive and classify at most one datagram from the socket. /// Receive and classify at most one datagram from the socket.
@@ -268,37 +228,6 @@ impl Client {
self.recv_once_inner() self.recv_once_inner()
} }
/// Receive one datagram into `self.recv_buffer`. In test builds, a
/// pending `inject_recv_interrupted` count reports
/// `io::ErrorKind::Interrupted` instead of performing a real receive,
/// deterministically exercising interrupted-syscall retry behavior.
fn recv_datagram_once(&mut self) -> Result<ReceivedDatagram, io::Error> {
#[cfg(test)]
{
let remaining = self.test_hooks.inject_recv_interrupted.get();
if remaining > 0 {
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
}
recv_datagram(&self.socket, &mut self.recv_buffer)
}
/// Same as [`Self::recv_datagram_once`], receiving into a caller-supplied
/// buffer instead of `self.recv_buffer` (used before the negotiated
/// receive buffer size is known, i.e. during Open).
fn recv_datagram_once_into(&self, buf: &mut [u8]) -> Result<ReceivedDatagram, io::Error> {
#[cfg(test)]
{
let remaining = self.test_hooks.inject_recv_interrupted.get();
if remaining > 0 {
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
}
recv_datagram(&self.socket, buf)
}
fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> { fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
let Some(datagram) = self.recv_datagram_retrying_interrupted()? else { let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
return Ok(vec![]); return Ok(vec![]);
@@ -314,11 +243,8 @@ impl Client {
datagram.received_at, datagram.received_at,
datagram.meta, datagram.meta,
)?; )?;
if self.runtime.is_peer_closed() { if self.runtime.is_peer_closed() && self.clear_close_dscp().is_ok() {
self.schedule = None; self.applied_traffic_class = None;
if self.clear_close_dscp().is_ok() {
self.applied_traffic_class = None;
}
} }
Ok(events) Ok(events)
} }
@@ -351,7 +277,7 @@ impl Client {
let mut timeout_narrowed = false; let mut timeout_narrowed = false;
let result = loop { let result = loop {
match self.recv_datagram_once() { match recv_datagram(&self.socket, &mut self.recv_buffer) {
Ok(datagram) => break Ok(Some(datagram)), Ok(datagram) => break Ok(Some(datagram)),
Err(err) Err(err)
if matches!( if matches!(
@@ -424,17 +350,19 @@ impl Client {
self.runtime.poll_timeouts_at(now) self.runtime.poll_timeouts_at(now)
} }
/// Return whether the current run has completed. /// Negotiated parameters of the currently open session.
/// pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
/// A normal run is complete once no more probes will be sent and all self.runtime.negotiated_params()
/// pending probes have either replied or timed out. No-test and closed }
/// sessions are also considered complete.
pub fn is_run_complete(&self) -> bool { /// Earliest timeout deadline among probes still awaiting a reply.
self.runtime.is_terminal() pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|| self self.runtime.next_probe_timeout_deadline()
.schedule }
.as_ref()
.is_some_and(|schedule| schedule.is_finished() && self.runtime.pending_is_empty()) /// Whether any sent probes still await a reply or timeout.
pub fn has_pending_probes(&self) -> bool {
!self.runtime.pending_is_empty()
} }
/// Return whether the session was closed by a peer close-flagged reply. /// Return whether the session was closed by a peer close-flagged reply.
@@ -447,123 +375,6 @@ impl Client {
self.runtime.is_peer_closed() self.runtime.is_peer_closed()
} }
#[cfg(test)]
pub(crate) fn send_managed_probe(
&mut self,
scheduled_at: Instant,
) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_transaction(ProbeScheduleMode::Managed { scheduled_at })
}
fn send_probe_transaction(
&mut self,
mode: ProbeScheduleMode,
) -> Result<Vec<ClientEvent>, ClientError> {
self.runtime.ensure_open()?;
#[cfg(test)]
let test_timestamps = self.probe_send_timestamps.take();
#[cfg(test)]
let fail_send = std::mem::take(&mut self.probe_send_error);
let remote = self.remote;
let (runtime, schedule, socket) = (&mut self.runtime, &mut self.schedule, &self.socket);
let schedule = schedule
.as_mut()
.expect("open sessions always have a probe schedule");
#[cfg(not(test))]
let permission_at = Instant::now();
#[cfg(test)]
let permission_at = test_timestamps
.map(|timestamps| timestamps.permission_at)
.unwrap_or_else(Instant::now);
if !schedule.permit_probe_at(permission_at) {
return Ok(Vec::new());
}
let Some(prepared) = runtime.prepare_probe()? else {
return Ok(Vec::new());
};
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
let schedule_commit = match mode {
ProbeScheduleMode::CallerPaced => {
schedule.preflight_caller_commit(machine_preflight.next_packets_sent)?
}
#[cfg(test)]
ProbeScheduleMode::Managed { scheduled_at } => {
schedule.preflight_managed_commit(scheduled_at, permission_at)?
}
};
let mut events = Vec::new();
events
.try_reserve(1)
.map_err(|source| ClientError::AllocationFailed {
operation: "probe event result",
source,
})?;
let expected_bytes = prepared.bytes.len();
let scheduled_at = schedule_commit.scheduled_at;
#[cfg(test)]
let reported_bytes = self.probe_reported_len.take();
// All fallible probe-commit preflight (timeout deadline arithmetic,
// capacity/collision checks already done above) is finalized from
// this PRIVATE pre-send anchor, before the socket send. It is not
// the public measurement `sent_at` captured further below.
#[cfg(not(test))]
let send_anchor = ClientTimestamp::now();
#[cfg(test)]
let send_anchor = test_timestamps
.map(|timestamps| timestamps.send_anchor)
.unwrap_or_else(ClientTimestamp::now);
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
#[cfg(not(test))]
let send_call_start = Instant::now();
#[cfg(test)]
let send_call_start = test_timestamps
.map(|timestamps| timestamps.send_call_start)
.unwrap_or_else(Instant::now);
#[cfg(test)]
if fail_send {
return Err(ClientError::Socket(io::Error::other(
"injected probe send failure",
)));
}
let bytes = socket.send(&prepared.bytes)?;
#[cfg(not(test))]
let send_finished_at = Instant::now();
#[cfg(test)]
let send_finished_at = test_timestamps
.map(|timestamps| timestamps.send_finished_at)
.unwrap_or_else(Instant::now);
// The public measurement timestamp: paired wall/monotonic sample
// captured immediately after the successful socket send completed.
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
// endpoint. The infallible machine/schedule commit below never fails.
#[cfg(not(test))]
let sent_at = ClientTimestamp::now();
#[cfg(test)]
let sent_at = test_timestamps
.map(|timestamps| timestamps.sent_at)
.unwrap_or_else(ClientTimestamp::now);
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
schedule.commit(schedule_commit);
let send_call = send_finished_at.saturating_duration_since(send_call_start);
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
#[cfg(test)]
let bytes = reported_bytes.unwrap_or(bytes);
validate_datagram_length(expected_bytes, bytes)?;
self.drain_tx_timestamps()?;
events.push(echo_sent_event(
remote,
sent,
send_call,
scheduled_at,
timer_error,
));
Ok(events)
}
fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> { fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
let request = self.runtime.prepare_open_request()?; let request = self.runtime.prepare_open_request()?;
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE]; let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
@@ -587,7 +398,7 @@ impl Client {
} }
self.socket.set_read_timeout(Some(remaining))?; self.socket.set_read_timeout(Some(remaining))?;
let datagram = match self.recv_datagram_once_into(&mut buf) { let datagram = match recv_datagram(&self.socket, &mut buf) {
Ok(datagram) => datagram, Ok(datagram) => datagram,
// WouldBlock/TimedOut mean the attempt's own deadline // WouldBlock/TimedOut mean the attempt's own deadline
// elapsed; Interrupted (EINTR) means the syscall was // elapsed; Interrupted (EINTR) means the syscall was
@@ -630,7 +441,7 @@ impl Client {
return Err(failure.primary); return Err(failure.primary);
} }
}; };
let prepared = match self.prepare_client_open(machine, datagram.received_at) { let prepared = match self.prepare_client_open(machine) {
Ok(prepared) => prepared, Ok(prepared) => prepared,
Err(failure) => { Err(failure) => {
self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet()); self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
@@ -647,32 +458,21 @@ impl Client {
fn prepare_client_open( fn prepare_client_open(
&self, &self,
machine: PreparedOpenAcceptance, machine: PreparedOpenAcceptance,
opened_at: ClientTimestamp,
) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> { ) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
let Some(negotiated) = machine.normal_negotiated() else { let Some(negotiated) = machine.normal_negotiated() else {
return Ok(PreparedClientOpen { return Ok(PreparedClientOpen {
machine, machine,
schedule: None,
recv_buffer_len: None, recv_buffer_len: None,
negotiated_traffic_class: None, negotiated_traffic_class: None,
previous_traffic_class: self.applied_traffic_class, previous_traffic_class: self.applied_traffic_class,
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout, post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
}); });
}; };
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
Ok(schedule) => schedule,
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
};
let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) { let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
Ok(size) => size, Ok(size) => size,
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })), Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
}; };
#[cfg(test)]
let recv_buffer_len = self
.test_hooks
.recv_buffer_len_override
.get()
.unwrap_or(recv_buffer_len);
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) { let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
Ok(traffic_class) => traffic_class, Ok(traffic_class) => traffic_class,
Err(_) => { Err(_) => {
@@ -687,7 +487,6 @@ impl Client {
Ok(PreparedClientOpen { Ok(PreparedClientOpen {
machine, machine,
schedule: Some(schedule),
recv_buffer_len: Some(recv_buffer_len), recv_buffer_len: Some(recv_buffer_len),
negotiated_traffic_class: Some(negotiated_traffic_class), negotiated_traffic_class: Some(negotiated_traffic_class),
previous_traffic_class: self.applied_traffic_class, previous_traffic_class: self.applied_traffic_class,
@@ -701,7 +500,6 @@ impl Client {
) -> Result<OpenOutcome, ClientError> { ) -> Result<OpenOutcome, ClientError> {
let PreparedClientOpen { let PreparedClientOpen {
machine, machine,
schedule,
recv_buffer_len, recv_buffer_len,
negotiated_traffic_class, negotiated_traffic_class,
previous_traffic_class, previous_traffic_class,
@@ -722,17 +520,15 @@ impl Client {
} }
self.recv_buffer.resize(recv_buffer_len, 0); self.recv_buffer.resize(recv_buffer_len, 0);
#[cfg(test)] if let Err(primary) =
self.test_hooks apply_traffic_class_to_socket(&self.socket, self.remote, negotiated_traffic_class)
.prepared_active_session_before_dscp {
.set(machine.has_prepared_active_session());
if let Err(primary) = self.apply_open_dscp(negotiated_traffic_class) {
self.recv_buffer.truncate(previous_len); self.recv_buffer.truncate(previous_len);
self.restore_dscp_best_effort(previous_traffic_class); self.restore_dscp_best_effort(previous_traffic_class);
self.send_cleanup_close_best_effort(machine.cleanup_close_packet()); self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
return Err(primary); return Err(primary);
} }
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) { if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
let primary = ClientError::ReadTimeoutRestore { source }; let primary = ClientError::ReadTimeoutRestore { source };
self.recv_buffer.truncate(previous_len); self.recv_buffer.truncate(previous_len);
self.restore_dscp_best_effort(previous_traffic_class); self.restore_dscp_best_effort(previous_traffic_class);
@@ -741,17 +537,16 @@ impl Client {
} }
let outcome = self.runtime.commit_open(machine); let outcome = self.runtime.commit_open(machine);
self.schedule = schedule;
self.applied_traffic_class = Some(negotiated_traffic_class); self.applied_traffic_class = Some(negotiated_traffic_class);
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket); self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
Ok(outcome) Ok(outcome)
} else { } else {
debug_assert!(schedule.is_none()); if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
return Err(ClientError::ReadTimeoutRestore { source }); return Err(ClientError::ReadTimeoutRestore { source });
} }
let outcome = self.runtime.commit_open(machine); let outcome = self.runtime.commit_open(machine);
self.schedule = None;
self.applied_traffic_class = None; self.applied_traffic_class = None;
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket); self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
Ok(outcome) Ok(outcome)
@@ -776,10 +571,6 @@ impl Client {
} }
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) { fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
#[cfg(test)]
if self.test_hooks.fail_dscp_restore.replace(false) {
return;
}
let _ = match previous_traffic_class { let _ = match previous_traffic_class {
Some(traffic_class) => { Some(traffic_class) => {
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class) apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
@@ -789,85 +580,29 @@ impl Client {
} }
fn clear_close_dscp(&self) -> Result<(), ClientError> { fn clear_close_dscp(&self) -> Result<(), ClientError> {
#[cfg(test)]
if self.test_hooks.fail_close_dscp_clear.replace(false) {
return Err(ClientError::SocketOption {
operation: "clear negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected negotiated DSCP clear failure"),
});
}
clear_dscp_on_socket(&self.socket, self.remote) clear_dscp_on_socket(&self.socket, self.remote)
} }
fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) { fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
if let Some(packet) = packet { if let Some(packet) = packet {
#[cfg(test)]
if self.test_hooks.fail_cleanup_send.replace(false) {
return;
}
let _ = self.socket.send(packet); let _ = self.socket.send(packet);
} }
} }
fn apply_open_dscp(&self, traffic_class: u8) -> Result<(), ClientError> {
#[cfg(test)]
if self.test_hooks.fail_open_dscp.replace(false) {
return Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
remote: self.remote,
source: io::Error::other("injected negotiated DSCP failure"),
});
}
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
}
fn restore_open_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
#[cfg(test)]
self.test_hooks.last_restored_read_timeout.set(timeout);
#[cfg(test)]
if self.test_hooks.fail_open_timeout_restore.replace(false) {
return Err(io::Error::other(
"injected configured read timeout restoration failure",
));
}
self.socket.set_read_timeout(timeout)
}
fn send_close_datagram(&self, packet: &[u8]) -> io::Result<usize> {
#[cfg(test)]
{
self.test_hooks
.close_send_attempts
.set(self.test_hooks.close_send_attempts.get() + 1);
if self.test_hooks.fail_close_send.replace(false) {
return Err(io::Error::other("injected close send failure"));
}
}
let bytes = self.socket.send(packet)?;
#[cfg(test)]
if let Some(reported) = self.test_hooks.close_reported_len.take() {
return Ok(reported);
}
Ok(bytes)
}
} }
pub(crate) fn echo_sent_event( pub(crate) fn echo_sent_event(
remote: SocketAddr, remote: SocketAddr,
sent: ProbeSent, sent: ProbeSent,
send_call: Duration, send_call: Duration,
scheduled_at: Instant,
timer_error: Duration,
) -> ClientEvent { ) -> ClientEvent {
ClientEvent::EchoSent { ClientEvent::EchoSent {
seq: sent.seq, seq: sent.seq,
remote, remote,
scheduled_at, scheduled_at: None,
sent_at: sent.sent_at, sent_at: sent.sent_at,
bytes: sent.bytes, bytes: sent.bytes,
send_call, send_call,
timer_error, timer_error: None,
} }
} }
@@ -879,46 +614,5 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result
} }
} }
#[cfg(test)]
impl Client {
fn send_probe_at(
&mut self,
timestamps: ProbeSendTimestamps,
) -> Result<Vec<ClientEvent>, ClientError> {
self.probe_send_timestamps = Some(timestamps);
self.send_probe()
}
fn send_managed_probe_at(
&mut self,
scheduled_at: Instant,
timestamps: ProbeSendTimestamps,
) -> Result<Vec<ClientEvent>, ClientError> {
self.probe_send_timestamps = Some(timestamps);
self.send_managed_probe(scheduled_at)
}
}
#[cfg(test)]
fn bounded_receive_timeout(
deadline: Option<Instant>,
configured_timeout: Option<Duration>,
max_wait: Duration,
now: Instant,
) -> Option<Duration> {
if max_wait.is_zero() {
return None;
}
let mut timeout = configured_timeout.unwrap_or(max_wait).min(max_wait);
if let Some(deadline) = deadline {
let remaining = deadline.checked_duration_since(now)?;
if remaining.is_zero() {
return None;
}
timeout = timeout.min(remaining);
}
(!timeout.is_zero()).then_some(timeout)
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+63 -315
View File
@@ -17,191 +17,6 @@ fn open_fails_after_close() {
server.join(); server.join();
} }
#[test]
fn close_sends_one_close_packet_with_negotiated_token() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1_234),
mono: Instant::now() + Duration::from_secs(5),
};
client.test_hooks.close_sent_at.set(Some(sent_at));
let events = client.close().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::SessionClosed {
token: TOKEN,
at,
..
}] if *at == sent_at
));
assert!(!client.runtime.is_open());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
assert_eq!(close[3], flags::FLAG_CLOSE);
assert_eq!(u64::from_le_bytes(close[4..12].try_into().unwrap()), TOKEN);
server.join();
}
#[test]
fn close_event_reservation_failure_precedes_dscp_clear_and_send() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_event_reserve.set(true);
client.test_hooks.fail_close_dscp_clear.set(true);
assert!(matches!(
client.close(),
Err(ClientError::AllocationFailed {
operation: "close event result",
..
})
));
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
assert!(client.test_hooks.fail_close_dscp_clear.get());
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
client.test_hooks.fail_close_dscp_clear.set(false);
client.close().unwrap();
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
server.join();
}
#[test]
fn close_dscp_clear_failure_precedes_send_and_preserves_open_state() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_dscp_clear.set(true);
assert!(matches!(
client.close(),
Err(ClientError::SocketOption { .. })
));
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
client.close().unwrap();
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
server.join();
}
#[test]
fn close_send_failure_leaves_machine_and_schedule_open_for_retry() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(2_345),
mono: Instant::now() + Duration::from_secs(6),
};
client.test_hooks.close_sent_at.set(Some(sent_at));
client.test_hooks.fail_close_send.set(true);
assert!(matches!(client.close(), Err(ClientError::Socket(_))));
assert_eq!(client.test_hooks.close_sent_at.get(), Some(sent_at));
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
assert!(matches!(
client.close().unwrap().as_slice(),
[ClientEvent::SessionClosed {
token: TOKEN,
at,
..
}] if *at == sent_at
));
assert_eq!(client.test_hooks.close_sent_at.get(), None);
assert_eq!(client.test_hooks.close_send_attempts.get(), 2);
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
server.join();
}
#[test]
fn short_successful_close_commits_before_length_mismatch() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let expected = client.runtime.prepare_close().unwrap().bytes.len();
let sent_at = ClientTimestamp {
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(3_456),
mono: Instant::now() + Duration::from_secs(7),
};
client.test_hooks.close_sent_at.set(Some(sent_at));
client.test_hooks.close_reported_len.set(Some(expected - 1));
assert!(matches!(
client.close(),
Err(ClientError::DatagramLengthMismatch {
expected: error_expected,
actual,
}) if error_expected == expected && actual + 1 == expected
));
assert!(!client.runtime.is_open());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert_eq!(client.test_hooks.close_sent_at.get(), None);
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
server.join();
}
#[test] #[test]
fn send_probe_fails_after_close() { fn send_probe_fails_after_close() {
let params = default_params(); let params = default_params();
@@ -232,135 +47,6 @@ fn send_probe_fails_after_close() {
server.join(); server.join();
} }
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&TimestampFields::default(),
None,
FLAG_REPLY | flags::FLAG_CLOSE,
);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.first(),
Some(ClientEvent::EchoReply { .. })
));
assert!(matches!(
events.get(1),
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
));
assert_eq!(events.len(), 2);
assert!(client.next_send_deadline().is_none());
assert!(client.schedule.is_none());
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
));
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
server.join();
}
#[test]
fn peer_close_dscp_cleanup_failure_preserves_events_and_closed_state() {
let params = default_params();
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
socket
.send_to(
&echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&TimestampFields::default(),
None,
FLAG_REPLY | flags::FLAG_CLOSE,
),
peer,
)
.unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let mut client = Client::connect(ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
})
.unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.test_hooks.fail_close_dscp_clear.set(true);
assert!(matches!(
client.recv_once().unwrap().as_slice(),
[
ClientEvent::EchoReply { .. },
ClientEvent::SessionClosed { token: TOKEN, .. }
]
));
assert!(client.is_peer_closed());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, Some(0));
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
let open = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
let echo = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
assert_ne!(open[3] & FLAG_OPEN, 0);
assert_eq!(echo[3] & flags::FLAG_CLOSE, 0);
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
server.join();
}
#[test] #[test]
fn recv_available_stops_after_peer_close() { fn recv_available_stops_after_peer_close() {
let params = default_params(); let params = default_params();
@@ -415,7 +101,6 @@ fn normal_echo_reply_does_not_close_session() {
let events = client.recv_once().unwrap(); let events = client.recv_once().unwrap();
assert!(matches!(events.as_slice(), [ClientEvent::EchoReply { .. }])); assert!(matches!(events.as_slice(), [ClientEvent::EchoReply { .. }]));
assert!(client.next_send_deadline().is_some());
assert!(client.send_probe().is_ok()); assert!(client.send_probe().is_ok());
client.close().unwrap(); client.close().unwrap();
@@ -752,3 +437,66 @@ fn wrong_token_close_flag_does_not_close_session() {
client.close().unwrap(); client.close().unwrap();
server.join(); server.join();
} }
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet_with_flags(
TOKEN,
seq,
&params,
&TimestampFields::default(),
None,
FLAG_REPLY | flags::FLAG_CLOSE,
);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
while recv_request_timeout(&socket, &tx).is_some() {}
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.first(),
Some(ClientEvent::EchoReply { .. })
));
assert!(matches!(
events.get(1),
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
));
assert_eq!(events.len(), 2);
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
));
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
server.join();
}
+3 -74
View File
@@ -82,80 +82,9 @@ fn normal_open_uses_negotiated_traffic_class_not_requested_dscp() {
target_os = "redox", target_os = "redox",
target_os = "solaris", target_os = "solaris",
target_os = "illumos", target_os = "illumos",
target_os = "haiku", target_os = "haiku"
)))] )))]
fn failed_close_send_restores_negotiated_traffic_class_and_keeps_session_open() { fn authenticated_peer_close_clears_negotiated_traffic_class() {
let mut params = default_params();
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
params.dscp = 184;
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut config = default_test_config(server.addr);
config.dscp = 46;
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_send.set(true);
let error = client.close().unwrap_err();
assert!(matches!(error, ClientError::Socket(_)));
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(184));
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
184
);
client.close().unwrap();
server.join();
}
#[test]
fn failed_close_keeps_send_error_primary_when_dscp_restoration_also_fails() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
client.test_hooks.fail_close_send.set(true);
client.test_hooks.fail_dscp_restore.set(true);
let error = client.close().unwrap_err();
assert!(matches!(error, ClientError::Socket(_)));
assert!(error.to_string().contains("injected close send failure"));
assert!(client.runtime.is_open());
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
client.close().unwrap();
server.join();
}
#[test]
#[cfg(not(any(
target_os = "fuchsia",
target_os = "redox",
target_os = "solaris",
target_os = "illumos",
target_os = "haiku",
)))]
fn authenticated_peer_close_clears_schedule_and_negotiated_traffic_class() {
let mut params = default_params(); let mut params = default_params();
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF). // Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
params.dscp = 184; params.dscp = 184;
@@ -198,7 +127,7 @@ fn authenticated_peer_close_clears_schedule_and_negotiated_traffic_class() {
ClientEvent::SessionClosed { .. } ClientEvent::SessionClosed { .. }
] ]
)); ));
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None); assert_eq!(client.applied_traffic_class, None);
assert_eq!( assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(), socket_traffic_class(&client.socket, client.remote).unwrap(),
@@ -1,5 +1,14 @@
use super::*; use super::*;
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS}; use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
use crate::{
session::machine::{
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
unix_epoch_ns_i64, update_highest_received,
},
session::negotiate_params,
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
};
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
use irtt_proto::{ use irtt_proto::{
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock, flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock,
ReceivedStats, StampAt, HMAC_SIZE, MAGIC, ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
+16 -43
View File
@@ -1,22 +1,5 @@
use super::*; use super::*;
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(client.next_send_deadline().is_none());
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert!(client.next_send_deadline().is_none());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
server.join();
}
#[test] #[test]
fn no_test_rejects_non_close_open_reply() { fn no_test_rejects_non_close_open_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -48,32 +31,6 @@ fn no_test_rejects_non_close_open_reply() {
server.join(); server.join();
} }
#[test]
fn no_test_cleanup_send_failure_preserves_unexpected_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None),
peer,
)
.unwrap();
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
client.test_hooks.fail_cleanup_send.set(true);
assert!(matches!(
client.open(),
Err(ClientError::UnexpectedNoTestReply)
));
assert!(client.runtime.prepare_open_request().is_ok());
server.join();
}
#[test] #[test]
fn no_test_rejects_non_zero_token_with_close_reply() { fn no_test_rejects_non_zero_token_with_close_reply() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -156,3 +113,19 @@ fn open_fails_after_no_test_completed() {
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted))); assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
server.join(); server.join();
} }
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(client.applied_traffic_class, None);
server.join();
}
+104 -347
View File
@@ -1,37 +1,5 @@
use super::*; use super::*;
#[test]
fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert!(client.schedule.is_some());
assert_eq!(client.applied_traffic_class, Some(0));
server.join();
}
#[test]
fn successful_open_restores_configured_receive_timeout() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
let params = params_from_config(&config).unwrap();
let server = open_success_server(params);
let mut config = default_test_config(server.addr);
config.socket_config.recv_timeout = Some(Duration::from_millis(75));
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
assert_eq!(
client.test_hooks.last_restored_read_timeout.get(),
Some(Duration::from_millis(75))
);
server.join();
}
#[test] #[test]
fn open_fails_when_already_open() { fn open_fails_when_already_open() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -64,84 +32,6 @@ fn open_retries_after_first_timeout() {
server.join(); server.join();
} }
#[test]
fn open_survives_interrupted_receive_within_same_attempt() {
let server = start_fake_server(|socket, tx| {
let (first, peer) = recv_request(&socket, &tx);
let params = Params::decode(&first[4..]).unwrap();
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(500)],
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
// Several interrupted receive attempts before the real reply arrives
// must not be treated as a timeout, must not retransmit the open
// request, and must not advance to a next configured attempt.
client.test_hooks.inject_recv_interrupted.set(5);
let outcome = client.open().unwrap();
assert_open_started(outcome);
// Exactly one request reached the server: interruptions were absorbed
// inside the single configured attempt.
assert_eq!(server.rx.iter().take(1).count(), 1);
server.join();
}
#[test]
fn open_interrupted_receive_does_not_extend_the_attempt_deadline() {
// A fake server that never replies: with a short attempt timeout, the
// real question is whether interruptions can push the effective wait
// past the configured deadline. They must not: the deadline stays
// absolute across any number of interruptions.
let server = start_fake_server(|socket, tx| {
let _ = recv_request(&socket, &tx);
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200)],
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
// Each interruption carries no timing information of its own, so the
// loop must keep re-checking the real deadline rather than treating an
// interruption as "more time available" or a reason to fail outright.
client.test_hooks.inject_recv_interrupted.set(20);
let started = Instant::now();
let result = client.open();
let elapsed = started.elapsed();
assert!(matches!(result, Err(ClientError::OpenTimeout)));
assert!(
elapsed < Duration::from_millis(800),
"interrupted receives must not extend the attempt deadline, took {elapsed:?}"
);
server.join();
}
#[test]
fn open_timeout_after_all_timeouts() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
assert_eq!(
client.test_hooks.last_restored_read_timeout.get(),
Some(Duration::from_millis(50))
);
server.join();
}
#[test] #[test]
fn protocol_version_mismatch_fails() { fn protocol_version_mismatch_fails() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1))); let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -175,41 +65,6 @@ fn server_rejection_fails_in_normal_mode() {
server.join(); server.join();
} }
#[test]
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
let mut wrong_type = valid.clone();
wrong_type[3] = FLAG_REPLY;
let mut reserved_flags = valid.clone();
reserved_flags[3] |= 0x10;
let mut bad_magic = valid.clone();
bad_magic[0] ^= 0xff;
let unexpected_hmac =
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(b"unexpected"));
for packet in [
vec![0_u8],
bad_magic,
wrong_type,
reserved_flags,
unexpected_hmac,
valid,
] {
socket.send_to(&packet, peer).unwrap();
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(server.rx.try_recv().is_err());
assert!(client.schedule.is_some());
server.join();
}
#[test] #[test]
fn ignored_datagrams_do_not_restart_the_attempt_deadline() { fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
let server = start_fake_server(move |socket, tx| { let server = start_fake_server(move |socket, tx| {
@@ -237,60 +92,6 @@ fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
server.join(); server.join();
} }
#[test]
fn only_ignored_open_datagrams_eventually_time_out() {
let server = start_fake_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_request(&socket, &tx);
socket.send_to(&[0_u8], peer).unwrap();
socket.send_to(&MAGIC, peer).unwrap();
}
thread::sleep(Duration::from_millis(300));
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
assert!(server.rx.try_recv().is_err());
assert_eq!(
client.test_hooks.last_restored_read_timeout.get(),
Some(Duration::from_millis(50))
);
server.join();
}
#[test]
fn trusted_zero_token_normal_reply_is_terminal() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
// deliberately non-compliant reply is built by encoding a normal
// reply with a placeholder token and then zeroing the token field.
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
socket.send_to(&reply, peer).unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
));
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
server.join();
}
#[test] #[test]
fn opening_deadline_overflow_occurs_before_send() { fn opening_deadline_overflow_occurs_before_send() {
let server = timeout_server(Duration::from_millis(250)); let server = timeout_server(Duration::from_millis(250));
@@ -305,47 +106,6 @@ fn opening_deadline_overflow_occurs_before_send() {
server.join(); server.join();
} }
#[test]
fn schedule_duration_overflow_cannot_partially_open() {
let server = start_fake_server(|_socket, _tx| {});
let client = Client::connect(default_test_config(server.addr)).unwrap();
let reply = irtt_proto::OpenReply {
flags: FLAG_OPEN | FLAG_REPLY,
token: TOKEN,
params: params_from_config(client.runtime.config()).unwrap(),
};
let mut latest = Instant::now();
let mut step = Duration::MAX;
while !step.is_zero() {
if let Some(candidate) = latest.checked_add(step) {
latest = candidate;
}
step /= 2;
}
let opened_at = ClientTimestamp {
mono: latest,
wall: SystemTime::now(),
};
let machine = client
.runtime
.prepare_open_acceptance(reply, opened_at)
.unwrap();
let previous_recv_len = client.recv_buffer.len();
let failure = client.prepare_client_open(machine, opened_at).unwrap_err();
assert!(matches!(
failure.primary,
ClientError::NegotiationRejected { .. }
));
assert!(failure.machine.cleanup_close_packet().is_some());
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
assert_eq!(client.recv_buffer.len(), previous_recv_len);
server.join();
}
#[test] #[test]
fn post_token_negotiation_failure_sends_cleanup_close() { fn post_token_negotiation_failure_sends_cleanup_close() {
let mut returned = default_params(); let mut returned = default_params();
@@ -377,146 +137,143 @@ fn post_token_negotiation_failure_sends_cleanup_close() {
} }
#[test] #[test]
fn cleanup_send_failure_does_not_replace_primary_open_error() { fn successful_open_handshake() {
let mut returned = default_params(); let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
returned.interval_ns += 1; let params = params_from_config(&config).unwrap();
let server = start_fake_server(move |socket, tx| { let server = open_success_server(params.clone());
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
peer,
)
.unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap(); let mut client = Client::connect(default_test_config(server.addr)).unwrap();
client.test_hooks.fail_cleanup_send.set(true);
assert!(matches!( let negotiated = assert_open_started(client.open().unwrap());
client.open(), assert_eq!(negotiated.params, params);
Err(ClientError::NegotiationRejected { .. })
)); assert_eq!(client.applied_traffic_class, Some(0));
assert!(client.runtime.prepare_open_request().is_ok());
server.join(); server.join();
} }
#[test] #[test]
fn receive_buffer_reservation_failure_precedes_dscp_application() { fn successful_open_restores_configured_receive_timeout() {
let mut returned = default_params(); let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF), matching let params = params_from_config(&config).unwrap();
// what the client below requests so negotiation succeeds unchanged. let server = open_success_server(params);
returned.dscp = 184;
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
socket
.send_to(
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
peer,
)
.unwrap();
let _ = recv_request(&socket, &tx);
});
let mut config = default_test_config(server.addr); let mut config = default_test_config(server.addr);
config.dscp = 46; config.socket_config.recv_timeout = Some(Duration::from_millis(75));
let mut client = Client::connect(config).unwrap(); let mut client = Client::connect(config).unwrap();
client
.test_hooks
.recv_buffer_len_override
.set(Some(usize::MAX));
client.test_hooks.fail_open_dscp.set(true);
assert!(matches!( assert_open_started(client.open().unwrap());
client.open(),
Err(ClientError::AllocationFailed { assert_eq!(
operation: "negotiated receive buffer", client.socket.read_timeout().unwrap(),
.. Some(Duration::from_millis(75))
}) );
));
assert!(client.test_hooks.fail_open_dscp.get());
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
server.join(); server.join();
} }
#[test] #[test]
fn dscp_application_failure_leaves_machine_connected() { fn open_timeout_after_all_timeouts() {
let params = default_params(); let server = timeout_server(Duration::from_millis(700));
let server = start_fake_server(move |socket, tx| { let config = ClientConfig {
let (_, peer) = recv_request(&socket, &tx); open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket socket_config: crate::SocketConfig {
.send_to( recv_timeout: Some(Duration::from_millis(50)),
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None), ..Default::default()
peer, },
) ..default_test_config(server.addr)
.unwrap(); };
let _ = recv_request(&socket, &tx); let mut client = Client::connect(config).unwrap();
}); assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let mut client = Client::connect(default_test_config(server.addr)).unwrap(); assert_eq!(server.rx.iter().take(2).count(), 2);
client.test_hooks.fail_open_dscp.set(true); assert_eq!(
client.socket.read_timeout().unwrap(),
assert!(matches!( Some(Duration::from_millis(50))
client.open(), );
Err(ClientError::SocketOption {
operation: "set negotiated DSCP",
..
})
));
assert!(client.test_hooks.prepared_active_session_before_dscp.get());
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
server.join(); server.join();
} }
#[test] #[test]
fn read_timeout_restoration_failure_leaves_machine_connected() { fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
let params = default_params(); let params = default_params();
let server = start_fake_server(move |socket, tx| { let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx); let (_, peer) = recv_request(&socket, &tx);
socket let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
.send_to( let mut wrong_type = valid.clone();
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None), wrong_type[3] = FLAG_REPLY;
peer, let mut reserved_flags = valid.clone();
) reserved_flags[3] |= 0x10;
.unwrap(); let mut bad_magic = valid.clone();
let _ = recv_request(&socket, &tx); bad_magic[0] ^= 0xff;
let unexpected_hmac =
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, Some(b"unexpected"));
for packet in [
vec![0_u8],
bad_magic,
wrong_type,
reserved_flags,
unexpected_hmac,
valid,
] {
socket.send_to(&packet, peer).unwrap();
}
}); });
let mut client = Client::connect(default_test_config(server.addr)).unwrap(); let mut client = Client::connect(default_test_config(server.addr)).unwrap();
client.test_hooks.fail_open_timeout_restore.set(true);
assert!(matches!( assert_open_started(client.open().unwrap());
client.open(), assert_eq!(server.rx.iter().take(1).count(), 1);
Err(ClientError::ReadTimeoutRestore { .. }) assert!(server.rx.try_recv().is_err());
));
assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
server.join(); server.join();
} }
#[test] #[test]
fn dscp_rollback_failure_keeps_timeout_restoration_primary() { fn only_ignored_open_datagrams_eventually_time_out() {
let server = start_fake_server(move |socket, tx| {
for _ in 0..2 {
let (_, peer) = recv_request(&socket, &tx);
socket.send_to(&[0_u8], peer).unwrap();
socket.send_to(&MAGIC, peer).unwrap();
}
thread::sleep(Duration::from_millis(300));
});
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
assert!(server.rx.try_recv().is_err());
assert_eq!(
client.socket.read_timeout().unwrap(),
Some(Duration::from_millis(50))
);
server.join();
}
#[test]
fn trusted_zero_token_normal_reply_is_terminal() {
let params = default_params(); let params = default_params();
let server = start_fake_server(move |socket, tx| { let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx); let (_, peer) = recv_request(&socket, &tx);
socket // encode_open_reply rejects a zero token without FLAG_CLOSE, so this
.send_to( // deliberately non-compliant reply is built by encoding a normal
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None), // reply with a placeholder token and then zeroing the token field.
peer, let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
) reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
.unwrap(); socket.send_to(&reply, peer).unwrap();
let _ = recv_request(&socket, &tx);
}); });
let mut client = Client::connect(default_test_config(server.addr)).unwrap(); let mut client = Client::connect(default_test_config(server.addr)).unwrap();
client.test_hooks.fail_open_timeout_restore.set(true);
client.test_hooks.fail_dscp_restore.set(true);
let error = client.open().unwrap_err(); assert!(matches!(
client.open(),
assert!(matches!(error, ClientError::ReadTimeoutRestore { .. })); Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
));
assert_eq!(server.rx.iter().take(1).count(), 1);
assert!(client.runtime.prepare_open_request().is_ok()); assert!(client.runtime.prepare_open_request().is_ok());
assert!(client.schedule.is_none());
assert_eq!(client.applied_traffic_class, None);
server.join(); server.join();
} }
@@ -1,80 +0,0 @@
use super::*;
/// An interrupted receive (`io::ErrorKind::Interrupted`, e.g. `EINTR`) must be
/// invisible to the caller: retried transparently, with the eventual reply
/// classified exactly as if no interruption had occurred.
#[test]
fn interrupted_receive_is_retried_and_the_reply_is_still_classified() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let _ = size;
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_secs(2)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
client.test_hooks.inject_recv_interrupted.set(10);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], ClientEvent::EchoReply { seq: 0, .. }),
"expected a normal EchoReply despite interrupted receive attempts, got {:?}",
events[0]
);
client.close().unwrap();
server.join();
}
/// One logical receive keeps one timeout budget: repeated interruptions must
/// not extend a `recv_once` call past its configured receive timeout.
#[test]
fn interrupted_receive_does_not_extend_the_configured_timeout() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(100)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
// No reply is ever coming; only interrupted attempts occur before the
// real socket takes over and legitimately times out.
client.test_hooks.inject_recv_interrupted.set(20);
let started = Instant::now();
let events = client.recv_once().unwrap();
let elapsed = started.elapsed();
assert!(events.is_empty());
assert!(
elapsed < Duration::from_millis(500),
"interrupted receives must not extend the configured receive timeout, took {elapsed:?}"
);
client.close().unwrap();
server.join();
}
@@ -20,9 +20,7 @@ fn kernel_rx_timestamp_unavailable_skip(test_name: &str) {
} }
mod burst_recv; mod burst_recv;
mod interrupted_receive;
mod packet_length; mod packet_length;
mod probe_schedule;
mod reply_classification; mod reply_classification;
mod reply_decode; mod reply_decode;
mod reply_metadata; mod reply_metadata;
@@ -1,487 +0,0 @@
use super::*;
fn probe_timestamps(permission_at: Instant, sent_at: ClientTimestamp) -> ProbeSendTimestamps {
ProbeSendTimestamps {
permission_at,
send_anchor: sent_at,
sent_at,
send_call_start: sent_at.mono,
send_finished_at: sent_at.mono,
}
}
#[test]
fn send_probe_fails_before_open() {
let server = start_fake_server(|_socket, _tx| {});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
server.join();
}
#[test]
fn send_probe_sends_valid_echo_request() {
let params = default_params();
let server = silent_open_server(params.clone());
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(client.next_send_deadline().is_none());
assert_open_started(client.open().unwrap());
let events = client.send_probe().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoSent {
seq, remote, bytes, ..
} => {
assert_eq!(*seq, 0);
assert_eq!(*remote, server.addr);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
thread::sleep(Duration::from_millis(30));
let packets: Vec<_> = server.rx.try_iter().collect();
let echo_reqs: Vec<_> = packets
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
.collect();
let echo_req = echo_reqs.first().unwrap();
assert_eq!(&echo_req[..3], &MAGIC);
assert_eq!(echo_req[3], 0x00);
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
assert_eq!(req_token, TOKEN);
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
assert_eq!(seq, 0);
client.close().unwrap();
server.join();
}
#[test]
fn blocking_short_probe_commits_before_presentation_length_error() {
let params = default_params();
let server = silent_open_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let expected = echo_packet_len(false, &params);
let initial_deadline = client.next_send_deadline().unwrap();
client.probe_reported_len = Some(expected - 1);
assert!(matches!(
client.send_probe(),
Err(ClientError::DatagramLengthMismatch {
expected: error_expected,
actual,
}) if error_expected == expected && actual + 1 == expected
));
assert_eq!(client.runtime.packets_sent(), 1);
assert_eq!(
client.next_send_deadline(),
Some(initial_deadline + client.runtime.config().interval)
);
client.close().unwrap();
server.join();
}
#[test]
fn blocking_failed_probe_send_preserves_machine_and_schedule() {
let params = default_params();
let server = silent_open_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let initial_deadline = client.next_send_deadline().unwrap();
client.probe_send_error = true;
assert!(matches!(client.send_probe(), Err(ClientError::Socket(_))));
assert_eq!(client.runtime.packets_sent(), 0);
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
assert!(matches!(
client.send_probe().unwrap().as_slice(),
[ClientEvent::EchoSent { seq: 0, .. }]
));
client.close().unwrap();
server.join();
}
#[test]
fn blocking_timeout_finalization_overflow_does_not_send_or_commit() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::MAX,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let initial_deadline = client.next_send_deadline().unwrap();
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::now(),
};
assert!(matches!(
client.send_probe_at(probe_timestamps(initial_deadline, sent_at)),
Err(ClientError::DurationOverflow)
));
assert_eq!(client.runtime.packets_sent(), 0);
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
thread::sleep(Duration::from_millis(30));
assert!(server
.rx
.try_iter()
.all(|packet| packet.len() >= 4 && packet[3] & FLAG_OPEN != 0));
client.close().unwrap();
server.join();
}
#[test]
fn blocking_probe_separates_permission_committed_send_and_send_call_timing() {
let duration = Duration::from_secs(1);
let interval = Duration::from_millis(100);
let params = Params {
duration_ns: 1_000_000_000,
interval_ns: 100_000_000,
..default_params()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(duration),
interval,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let opened_mono = Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("test host monotonic clock has at least two seconds of history");
client.schedule = Some(
ProbeSchedule::new(
opened_mono,
&NegotiatedParams {
params,
restrictions: Vec::new(),
},
)
.unwrap(),
);
let permission_at = opened_mono + Duration::from_millis(500);
// Distinct pre-send anchor and post-send `sent_at`: the anchor drives
// the operational timeout deadline, while `sent_at` (captured after the
// send call) is the public measurement/timer_error endpoint. They are
// deliberately decoupled by this PR.
let send_anchor = ClientTimestamp {
wall: SystemTime::now(),
mono: opened_mono + Duration::from_millis(525),
};
let sent_at = ClientTimestamp {
wall: SystemTime::now(),
mono: opened_mono + Duration::from_millis(534),
};
let timestamps = ProbeSendTimestamps {
permission_at,
send_anchor,
sent_at,
send_call_start: opened_mono + Duration::from_millis(526),
send_finished_at: opened_mono + Duration::from_millis(533),
};
let events = client.send_probe_at(timestamps).unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoSent {
scheduled_at,
sent_at: event_sent_at,
send_call,
timer_error,
..
}] if *scheduled_at == opened_mono
&& *event_sent_at == sent_at
&& *send_call == Duration::from_millis(7)
&& *timer_error == Duration::from_millis(534)
));
assert_eq!(client.next_send_deadline(), Some(opened_mono + interval));
assert_eq!(client.runtime.packets_sent(), 1);
// The timeout deadline is anchored to the pre-send `send_anchor`, not
// to the post-send `sent_at` captured 9ms later.
let timeout_at = send_anchor.mono + client.probe_timeout();
assert_ne!(timeout_at, sent_at.mono + client.probe_timeout());
assert!(client
.poll_timeouts_at(timeout_at - Duration::from_nanos(1))
.unwrap()
.is_empty());
assert!(matches!(
client.poll_timeouts_at(timeout_at).unwrap().as_slice(),
[ClientEvent::EchoLoss {
sent_at: loss_sent_at,
..
}] if *loss_sent_at == sent_at
));
client.close().unwrap();
server.join();
}
#[test]
fn blocking_send_call_excludes_wrapped_history_cleanup() {
let params = default_params();
let server = silent_open_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open().unwrap());
let scheduled_at = client.next_send_deadline().unwrap();
let sent_at = ClientTimestamp {
wall: SystemTime::now(),
mono: scheduled_at + Duration::from_millis(5),
};
client.runtime.seed_wrapped_probe_history_for_test(sent_at);
let timestamps = ProbeSendTimestamps {
permission_at: scheduled_at,
send_anchor: sent_at,
sent_at,
send_call_start: scheduled_at + Duration::from_millis(6),
send_finished_at: scheduled_at + Duration::from_millis(13),
};
let events = client.send_probe_at(timestamps).unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoSent {
seq: 0,
send_call,
..
}] if *send_call == Duration::from_millis(7)
));
assert!(!client.runtime.has_timed_out_metadata());
client.close().unwrap();
server.join();
}
#[test]
fn send_probe_respects_finite_duration_exclusive_end() {
let params = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 500_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(500),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let start = client.next_send_deadline().unwrap();
let interval = Duration::from_millis(500);
let now0 = ClientTimestamp {
mono: start,
wall: SystemTime::now(),
};
assert!(client
.send_probe_at(probe_timestamps(now0.mono, now0))
.is_ok());
let now1 = ClientTimestamp {
mono: start + interval,
wall: SystemTime::now(),
};
assert!(client
.send_probe_at(probe_timestamps(now1.mono, now1))
.is_ok());
let now2 = ClientTimestamp {
mono: start + Duration::from_secs(1),
wall: SystemTime::now(),
};
let events = client
.send_probe_at(probe_timestamps(now2.mono, now2))
.unwrap();
assert!(events.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn managed_probe_skips_missed_schedule_slots() {
let interval = Duration::from_millis(10);
let params = Params {
protocol_version: 1,
duration_ns: 0,
interval_ns: 10_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = echo_server(params);
let config = ClientConfig {
duration: None,
interval,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let first_deadline = client.next_send_deadline().unwrap();
let permission_at = first_deadline + Duration::from_millis(45);
let delayed_send = ClientTimestamp {
mono: first_deadline + Duration::from_millis(48),
wall: SystemTime::now(),
};
let timestamps = ProbeSendTimestamps {
permission_at,
send_anchor: delayed_send,
sent_at: delayed_send,
send_call_start: first_deadline + Duration::from_millis(49),
send_finished_at: first_deadline + Duration::from_millis(55),
};
let events = client
.send_managed_probe_at(first_deadline, timestamps)
.unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
ClientEvent::EchoSent {
seq: 0,
scheduled_at,
sent_at,
send_call,
timer_error,
..
} if scheduled_at == first_deadline + Duration::from_millis(40)
&& sent_at == delayed_send
&& send_call == Duration::from_millis(6)
&& timer_error == Duration::from_millis(8)
));
assert_eq!(
client.next_send_deadline(),
Some(first_deadline + Duration::from_millis(50))
);
client.close().unwrap();
server.join();
}
#[test]
fn managed_probe_skip_preserves_finite_run_end() {
let interval = Duration::from_millis(10);
let duration = Duration::from_millis(50);
let params = Params {
protocol_version: 1,
duration_ns: 50_000_000,
interval_ns: 10_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = echo_server(params);
let config = ClientConfig {
duration: Some(duration),
interval,
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let first_deadline = client.next_send_deadline().unwrap();
let delayed_send = ClientTimestamp {
mono: first_deadline + Duration::from_millis(45),
wall: SystemTime::now(),
};
let first = client
.send_managed_probe_at(
first_deadline,
probe_timestamps(delayed_send.mono, delayed_send),
)
.unwrap();
let second_sent_at = ClientTimestamp {
mono: first_deadline + Duration::from_millis(46),
wall: SystemTime::now(),
};
let second = client
.send_managed_probe_at(
first_deadline + duration,
probe_timestamps(second_sent_at.mono, second_sent_at),
)
.unwrap();
assert_eq!(first.len(), 1);
assert!(second.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn receive_wait_is_bounded_by_next_ten_millisecond_deadline() {
let now = Instant::now();
let deadline = now + Duration::from_millis(10);
assert_eq!(
bounded_receive_timeout(
Some(deadline),
Some(Duration::from_millis(20)),
Duration::from_millis(20),
now,
),
Some(Duration::from_millis(10))
);
assert_eq!(
bounded_receive_timeout(
Some(now),
Some(Duration::from_millis(20)),
Duration::from_millis(20),
now,
),
None
);
}
#[test]
fn connect_rejects_invalid_probe_limits() {
for config in [
ClientConfig {
max_pending_probes: 0,
..ClientConfig::default()
},
ClientConfig {
probe_timeout: Duration::ZERO,
..ClientConfig::default()
},
] {
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
+65 -1
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 /// request and the local client behavior used to drive the UDP socket. Values
/// that are negotiated by the server are available after opening the session /// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams). /// through [`NegotiatedParams`](crate::NegotiatedParams).
///
/// # Example
///
/// Configure a finite run with larger probes and accept documented server
/// restrictions. Use `duration: None` for continuous mode, or
/// [`RunMode::NoTest`] for negotiation without probes.
///
/// ```
/// use std::time::Duration;
///
/// use irtt_client::{ClientConfig, NegotiationPolicy};
///
/// let config = ClientConfig {
/// server_addr: "example.net:2112".into(),
/// duration: Some(Duration::from_secs(10)),
/// interval: Duration::from_millis(250),
/// length: 1200,
/// negotiation_policy: NegotiationPolicy::Loose,
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.duration, Some(Duration::from_secs(10)));
/// assert_eq!(config.interval, Duration::from_millis(250));
/// assert_eq!(config.length, 1200);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig { pub struct ClientConfig {
/// Remote server name or address. /// Remote server name or address.
@@ -60,12 +85,18 @@ pub struct ClientConfig {
/// `None` requests continuous mode and is encoded on the wire as a zero /// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only /// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes. /// without sending probes.
///
/// The managed driver stops sending after the negotiated duration.
/// Low-level adapters leave run duration to the caller.
pub duration: Option<Duration>, pub duration: Option<Duration>,
/// Requested spacing between probe sends. /// Requested spacing between probe sends.
/// ///
/// The interval is encoded as nanoseconds in the open request and must be /// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending /// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions. /// on the negotiated policy and server restrictions.
///
/// The managed driver uses the negotiated interval for cadence.
/// Low-level adapters send whenever called.
pub interval: Duration, pub interval: Duration,
/// Requested echo packet payload length, in bytes. /// Requested echo packet payload length, in bytes.
/// ///
@@ -176,6 +207,29 @@ impl Default for ClientConfig {
/// These settings affect how the client binds, resolves, and receives from the /// These settings affect how the client binds, resolves, and receives from the
/// socket. They do not change the IRTT protocol parameters negotiated with the /// socket. They do not change the IRTT protocol parameters negotiated with the
/// server. /// server.
///
/// # Example
///
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_client::{ClientConfig, SocketConfig};
///
/// let config = ClientConfig {
/// socket_config: SocketConfig {
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
/// ttl: Some(32),
/// ipv4_only: true,
/// ..SocketConfig::default()
/// },
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.socket_config.ttl, Some(32));
/// assert!(config.socket_config.ipv4_only);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig { pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket. /// Local address to bind before connecting the UDP socket.
@@ -183,6 +237,15 @@ pub struct SocketConfig {
/// `None` binds to an unspecified address with an ephemeral port matching /// `None` binds to an unspecified address with an ephemeral port matching
/// the selected remote address family. /// the selected remote address family.
pub bind_addr: Option<SocketAddr>, pub bind_addr: Option<SocketAddr>,
/// Network interface to bind this socket to (`SO_BINDTODEVICE`).
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
pub bind_to_device: Option<String>,
/// Firewall mark applied to outgoing packets (`SO_MARK`).
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
pub mark: Option<u32>,
/// FreeBSD FIB used for routing this socket (`SO_SETFIB`).
#[cfg(target_os = "freebsd")]
pub fib: Option<u32>,
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets. /// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
/// ///
/// `None` leaves the platform default unchanged. Values must fit in the /// `None` leaves the platform default unchanged. Values must fit in the
@@ -216,7 +279,8 @@ pub enum NegotiationPolicy {
/// Mode requested during the IRTT open exchange. /// Mode requested during the IRTT open exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode { pub enum RunMode {
/// Open a session and send echo probes for the negotiated run duration. /// Open a probe session. The managed driver runs for the negotiated duration;
/// low-level adapters leave sending and stopping to the caller.
Normal, Normal,
/// Complete negotiation without running the echo probe test. /// Complete negotiation without running the echo probe test.
NoTest, NoTest,
+18 -4
View File
@@ -92,8 +92,9 @@ pub enum ClientEvent {
seq: u32, seq: u32,
/// Resolved remote socket address. /// Resolved remote socket address.
remote: SocketAddr, remote: SocketAddr,
/// Monotonic deadline at which the probe was scheduled to be sent. /// Intended send deadline, supplied by the managed runner.
scheduled_at: Instant, /// Low-level adapters leave this absent.
scheduled_at: Option<Instant>,
/// Paired client wall/monotonic timestamp captured immediately after /// Paired client wall/monotonic timestamp captured immediately after
/// the successful UDP send completed. This is the RTT and userspace /// the successful UDP send completed. This is the RTT and userspace
/// upstream one-way-delay fallback endpoint. The operational probe /// upstream one-way-delay fallback endpoint. The operational probe
@@ -106,8 +107,8 @@ pub enum ClientEvent {
/// scheduler commit work. /// scheduler commit work.
send_call: Duration, send_call: Duration,
/// Absolute difference between the scheduled send time and the /// Absolute difference between the scheduled send time and the
/// post-send `sent_at` timestamp above. /// post-send `sent_at` timestamp above; absent for low-level sends.
timer_error: Duration, timer_error: Option<Duration>,
}, },
/// First in-window reply for a pending echo request. /// First in-window reply for a pending echo request.
@@ -260,6 +261,19 @@ pub struct RttSample {
/// processing time or comparing wall-clock timestamps from different hosts. It /// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time, /// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`]. /// which is non-negative and represented by [`Duration`].
///
/// # Example
///
/// Signed timing values retain a negative result instead of clamping clock
/// skew or server processing adjustments to zero:
///
/// ```
/// use irtt_client::SignedDuration;
///
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
/// assert!(adjusted_rtt.is_negative());
/// assert_eq!(adjusted_rtt.as_micros(), -250);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration { pub struct SignedDuration {
ns: i128, ns: i128,
+4 -1
View File
@@ -18,7 +18,10 @@
//! wants full control over its own event loop, should start from. With the //! wants full control over its own event loop, should start from. With the
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio //! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
//! adapter for a caller that owns a runtime and drives socket readiness //! adapter for a caller that owns a runtime and drives socket readiness
//! itself. [`managed`] sits above both: `ManagedClientTask` / //! itself. Both adapters send one probe whenever called; callers own cadence
//! and run duration, using `negotiated_params()` after Open. [`managed`] owns
//! negotiated-interval scheduling, finite runs, and staggered/burst pacing:
//! `ManagedClientTask` /
//! `ManagedClientHandle` (`tokio` feature) run and control one or more //! `ManagedClientHandle` (`tokio` feature) run and control one or more
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio` //! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
//! feature) wraps that in a synchronous owner backed by its own dedicated //! feature) wraps that in a synchronous owner backed by its own dedicated
+3
View File
@@ -18,3 +18,6 @@ pub use task::*;
#[cfg(all(feature = "tokio", test))] #[cfg(all(feature = "tokio", test))]
mod blocking_tests; mod blocking_tests;
#[cfg(feature = "tokio")]
mod schedule;
@@ -4,7 +4,6 @@ use crate::{error::ClientError, session::NegotiatedParams};
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct ProbeSchedule { pub(crate) struct ProbeSchedule {
start_at: Instant,
end_at: Option<Instant>, end_at: Option<Instant>,
interval: Duration, interval: Duration,
next_send_at: Option<Instant>, next_send_at: Option<Instant>,
@@ -39,7 +38,6 @@ impl ProbeSchedule {
}; };
Ok(Self { Ok(Self {
start_at,
end_at, end_at,
interval, interval,
next_send_at: Some(start_at), next_send_at: Some(start_at),
@@ -50,7 +48,6 @@ impl ProbeSchedule {
self.next_send_at self.next_send_at
} }
#[cfg(feature = "tokio")]
pub(crate) fn interval(&self) -> Duration { pub(crate) fn interval(&self) -> Duration {
self.interval self.interval
} }
@@ -66,26 +63,12 @@ impl ProbeSchedule {
true true
} }
pub(crate) fn preflight_caller_commit(
&self,
next_packets_sent: u64,
) -> Result<ScheduleCommit, ClientError> {
let scheduled_at = self
.next_send_at
.expect("a caller schedule commit requires a permitted probe");
let interval_ns =
u64::try_from(self.interval.as_nanos()).map_err(|_| ClientError::DurationOverflow)?;
let next_send_at = next_probe_deadline(self.start_at, interval_ns, next_packets_sent)?;
Ok(self.finish_commit(scheduled_at, next_send_at))
}
#[cfg(any(feature = "tokio", test))]
pub(crate) fn preflight_managed_commit( pub(crate) fn preflight_managed_commit(
&self, &self,
scheduled_at: Instant, scheduled_at: Instant,
permission_at: Instant, permission_at: Instant,
) -> Result<ScheduleCommit, ClientError> { ) -> Result<ScheduleCommit, ClientError> {
let (scheduled_at, next_send_at, _) = let (scheduled_at, next_send_at) =
advance_cadence(scheduled_at, self.interval, permission_at)?; advance_cadence(scheduled_at, self.interval, permission_at)?;
Ok(self.finish_commit(scheduled_at, next_send_at)) Ok(self.finish_commit(scheduled_at, next_send_at))
} }
@@ -94,6 +77,16 @@ impl ProbeSchedule {
self.next_send_at = commit.next_send_at; self.next_send_at = commit.next_send_at;
} }
/// Reclaim slots crossed while a successful socket submission was in flight.
/// This follows the send commit; it never advances a failed submission.
pub(crate) fn skip_missed_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
if let Some(deadline) = self.next_send_at.filter(|deadline| *deadline <= now) {
let commit = self.preflight_managed_commit(deadline, now)?;
self.commit(commit);
}
Ok(())
}
pub(crate) fn is_finished(&self) -> bool { pub(crate) fn is_finished(&self) -> bool {
self.next_send_at.is_none() self.next_send_at.is_none()
} }
@@ -110,25 +103,11 @@ impl ProbeSchedule {
} }
} }
fn next_probe_deadline(
start: Instant,
interval_ns: u64,
packets_sent: u64,
) -> Result<Instant, ClientError> {
let offset_ns = interval_ns
.checked_mul(packets_sent)
.ok_or(ClientError::DurationOverflow)?;
start
.checked_add(Duration::from_nanos(offset_ns))
.ok_or(ClientError::DurationOverflow)
}
#[cfg(any(feature = "tokio", test))]
pub(crate) fn advance_cadence( pub(crate) fn advance_cadence(
deadline: Instant, deadline: Instant,
interval: Duration, interval: Duration,
now: Instant, now: Instant,
) -> Result<(Instant, Instant, u128), ClientError> { ) -> Result<(Instant, Instant), ClientError> {
if interval.is_zero() { if interval.is_zero() {
return Err(ClientError::InvalidConfig { return Err(ClientError::InvalidConfig {
reason: "probe interval must be greater than zero".to_owned(), reason: "probe interval must be greater than zero".to_owned(),
@@ -160,10 +139,9 @@ pub(crate) fn advance_cadence(
let next_at = deadline let next_at = deadline
.checked_add(next_offset) .checked_add(next_offset)
.ok_or(ClientError::DurationOverflow)?; .ok_or(ClientError::DurationOverflow)?;
Ok((scheduled_at, next_at, elapsed_slots)) Ok((scheduled_at, next_at))
} }
#[cfg(any(feature = "tokio", test))]
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> { fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
const NANOS_PER_SECOND: u128 = 1_000_000_000; const NANOS_PER_SECOND: u128 = 1_000_000_000;
let seconds = let seconds =
@@ -178,16 +156,15 @@ pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
.or_else(|| right.checked_duration_since(left)) .or_else(|| right.checked_duration_since(left))
.unwrap_or(Duration::ZERO) .unwrap_or(Duration::ZERO)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use irtt_proto::Params; use irtt_proto::Params;
fn negotiated(interval_ns: i64, duration_ns: i64) -> NegotiatedParams { fn negotiated(duration_ns: i64) -> NegotiatedParams {
NegotiatedParams { NegotiatedParams {
params: Params { params: Params {
interval_ns, interval_ns: 10_000_000,
duration_ns, duration_ns,
..Params::default() ..Params::default()
}, },
@@ -196,123 +173,56 @@ mod tests {
} }
#[test] #[test]
fn initial_deadline_is_the_opening_time() { fn missed_slots_skip_on_the_absolute_grid_only_after_commit() {
let opened_at = Instant::now();
let schedule = ProbeSchedule::new(opened_at, &negotiated(10_000_000, 0)).unwrap();
assert_eq!(schedule.next_send_deadline(), Some(opened_at));
assert!(!schedule.is_finished());
}
#[test]
fn caller_preflight_keeps_absolute_cadence_without_mutation() {
let start = Instant::now(); let start = Instant::now();
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap(); let mut schedule = ProbeSchedule::new(start, &negotiated(0)).unwrap();
for (now_ms, intended_ms, next_ms) in [(0, 0, 10), (45, 40, 50), (50, 50, 60)] {
let commit = schedule.preflight_caller_commit(3).unwrap(); let deadline = schedule.next_send_deadline().unwrap();
let commit = schedule
assert_eq!(commit.scheduled_at, start); .preflight_managed_commit(deadline, start + Duration::from_millis(now_ms))
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(30))); .unwrap();
assert_eq!(schedule.next_send_deadline(), Some(start)); assert_eq!(
} commit.scheduled_at,
start + Duration::from_millis(intended_ms)
#[test] );
fn caller_commit_advances_exactly_one_cadence_slot() { // A failed or pending submission discards this preflight.
let start = Instant::now(); assert_eq!(schedule.next_send_deadline(), Some(deadline));
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap(); schedule.commit(commit);
assert_eq!(
let commit = schedule.preflight_caller_commit(1).unwrap(); schedule.next_send_deadline(),
assert_eq!(schedule.next_send_deadline(), Some(start)); Some(start + Duration::from_millis(next_ms))
);
schedule.commit(commit); }
schedule
.skip_missed_slots_at(start + Duration::from_millis(85))
.unwrap();
assert_eq!( assert_eq!(
schedule.next_send_deadline(), schedule.next_send_deadline(),
Some(start + Duration::from_millis(10)) Some(start + Duration::from_millis(90))
); );
} }
#[test] #[test]
fn finite_duration_uses_an_exclusive_end_boundary() { fn finite_duration_excludes_its_end_even_when_a_slot_is_missed() {
let start = Instant::now(); let start = Instant::now();
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap(); let mut schedule = ProbeSchedule::new(start, &negotiated(20_000_000)).unwrap();
assert!(schedule.permit_probe_at(start + Duration::from_millis(19)));
let first = schedule.preflight_caller_commit(1).unwrap();
schedule.commit(first);
assert_eq!(
schedule.next_send_deadline(),
Some(start + Duration::from_millis(10))
);
let second = schedule.preflight_caller_commit(2).unwrap();
schedule.commit(second);
assert!(schedule.is_finished());
assert_eq!(schedule.next_send_deadline(), None);
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
}
#[test]
fn permit_at_exclusive_end_finishes_schedule_without_a_send() {
let start = Instant::now();
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
assert!(schedule.is_finished());
assert_eq!(schedule.next_send_deadline(), None);
}
#[test]
fn managed_preflight_skips_missed_slots_without_mutation() {
let start = Instant::now();
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
let sent_at = start + Duration::from_millis(45);
let commit = schedule.preflight_managed_commit(start, sent_at).unwrap();
assert_eq!(commit.scheduled_at, start + Duration::from_millis(40));
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(50)));
assert_eq!(schedule.next_send_deadline(), Some(start));
}
#[test]
fn delayed_managed_commit_does_not_replay_catch_up_slots() {
let start = Instant::now();
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
let delayed_send = start + Duration::from_millis(45);
let commit = schedule let commit = schedule
.preflight_managed_commit(start, delayed_send) .preflight_managed_commit(start, start + Duration::from_millis(19))
.unwrap(); .unwrap();
schedule.commit(commit); schedule.commit(commit);
assert!(schedule.is_finished());
assert_eq!( let mut missed = ProbeSchedule::new(start, &negotiated(20_000_000)).unwrap();
schedule.next_send_deadline(), assert!(!missed.permit_probe_at(start + Duration::from_millis(20)));
Some(start + Duration::from_millis(50)) assert!(missed.is_finished());
);
assert!(schedule.next_send_deadline().unwrap() > delayed_send);
} }
#[test] #[test]
fn deadline_arithmetic_overflow_is_detected_before_commit() { fn cadence_rejects_overflow_before_commit() {
let start = Instant::now();
let schedule = ProbeSchedule::new(start, &negotiated(2, 0)).unwrap();
assert!(matches!( assert!(matches!(
schedule.preflight_caller_commit(u64::MAX), advance_cadence(Instant::now(), Duration::MAX, Instant::now()),
Err(ClientError::DurationOverflow) Err(ClientError::DurationOverflow)
)); ));
assert_eq!(schedule.next_send_deadline(), Some(start));
}
#[test]
fn timer_error_is_adapter_owned() {
let scheduled_at = Instant::now();
let sent_at = scheduled_at + Duration::from_millis(7);
assert_eq!(
instant_abs_diff(sent_at, scheduled_at),
Duration::from_millis(7)
);
} }
} }
+175 -15
View File
@@ -25,6 +25,8 @@ use crate::{
ClientConfig, ClientError, ClientEvent, OpenOutcome, ClientConfig, ClientError, ClientEvent, OpenOutcome,
}; };
use super::schedule::{instant_abs_diff, ProbeSchedule};
use super::{ use super::{
classify_client_error, ManagedClientConfig, ManagedCommandAcknowledgement, classify_client_error, ManagedClientConfig, ManagedCommandAcknowledgement,
ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError, ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError,
@@ -44,6 +46,42 @@ const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1; const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
/// Entry point for constructing a unified Tokio managed task. /// Entry point for constructing a unified Tokio managed task.
///
/// # Example
///
/// Construct the task and its separate control handle, subscribe before
/// starting the task, then run it under the caller's Tokio runtime. A reachable
/// server is required at runtime, so this example only type-checks.
///
/// ```no_run
/// use irtt_client::{ClientConfig, ClientEvent};
/// use irtt_client::managed::{
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
/// };
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let config = ManagedClientConfig {
/// client: ClientConfig {
/// duration: Some(std::time::Duration::from_secs(10)),
/// ..ClientConfig::default()
/// },
/// ..ManagedClientConfig::default()
/// };
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
/// let (task, handle) = ManagedClient::task(config, targets)?;
/// let mut events = handle.subscribe()?;
/// let driver = tokio::spawn(task);
///
/// while let Ok(event) = events.recv().await {
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
/// println!("received a probe reply");
/// }
/// }
/// let outcome = driver.await?;
/// println!("run ended: {:?}", outcome.end_reason);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct ManagedClient; pub struct ManagedClient;
@@ -468,6 +506,7 @@ impl TargetCounters {
} }
struct TargetRuntime { struct TargetRuntime {
schedule: Option<ProbeSchedule>,
instance: TargetInstance, instance: TargetInstance,
config: ManagedTargetConfig, config: ManagedTargetConfig,
desired: bool, desired: bool,
@@ -955,6 +994,7 @@ impl ManagedClientTask {
}; };
created_instances.push(instance.clone()); created_instances.push(instance.clone());
self.targets.push(TargetRuntime { self.targets.push(TargetRuntime {
schedule: None,
instance, instance,
server_addr: Arc::from(planned.target.server_addr.clone()), server_addr: Arc::from(planned.target.server_addr.clone()),
config: planned.target, config: planned.target,
@@ -1207,7 +1247,25 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Opening { client, open }; self.targets[index].state = TargetState::Opening { client, open };
false false
} }
Poll::Ready(Ok(OpenOutcome::Started { event, .. })) => { Poll::Ready(Ok(OpenOutcome::Started {
event, negotiated, ..
})) => {
let ClientEvent::SessionStarted { at: opened_at, .. } = &event else {
unreachable!("Started carries SessionStarted");
};
match ProbeSchedule::new(opened_at.mono, &negotiated) {
Ok(schedule) => self.targets[index].schedule = Some(schedule),
Err(error) => {
return self.begin_open_session_failure(
index,
client,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Close,
)
}
}
self.publish_client_events(index, vec![event]); self.publish_client_events(index, vec![event]);
if self.state == DriverState::Stopping if self.state == DriverState::Stopping
|| self.effective_retirement(index).is_some() || self.effective_retirement(index).is_some()
@@ -1304,7 +1362,15 @@ impl ManagedClientTask {
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None); self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
return false; return false;
} }
if client.is_run_complete() { if self.targets[index]
.schedule
.as_mut()
.is_some_and(|schedule| {
schedule.permit_probe_at(now);
schedule.is_finished()
})
&& !client.has_pending_probes()
{
self.targets[index].sync_packets_sent(&client); self.targets[index].sync_packets_sent(&client);
return self.begin_drain( return self.begin_drain(
index, index,
@@ -1584,13 +1650,15 @@ impl ManagedClientTask {
if !target.desired || target.retirement.is_some() { if !target.desired || target.retirement.is_some() {
continue; continue;
} }
let TargetState::Active { client } = &target.state else { let TargetState::Active { .. } = &target.state else {
continue; continue;
}; };
active += 1; active += 1;
let interval = client let interval = target
.probe_interval() .schedule
.expect("active managed targets have a committed probe schedule"); .as_ref()
.expect("active managed targets have a probe schedule")
.interval();
minimum = minimum.into_iter().chain(Some(interval)).min(); minimum = minimum.into_iter().chain(Some(interval)).min();
} }
minimum.map(|interval| (active, stagger_spacing(interval, active))) minimum.map(|interval| (active, stagger_spacing(interval, active)))
@@ -1613,6 +1681,30 @@ impl ManagedClientTask {
self.send_gate = self.send_gate.filter(|gate| *gate > now); self.send_gate = self.send_gate.filter(|gate| *gate > now);
} }
/// The next stagger gate after a send was accepted at `accepted_at`.
///
/// The gate advances on its **own** cadence rather than being re-anchored
/// on every observed accept time. Anchoring on `accepted_at` folds each
/// send's wakeup latency into the next slot and then into every slot after
/// it, so the gate — and with it the whole group's send cadence — drifts
/// later without bound while the per-target schedules stay on their
/// absolute grid. The drift is not hypothetical: it shows up as a
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
/// interval, as probe slots the schedule then skips.
///
/// `accepted_at` is still the anchor in the two cases where continuing the
/// old cadence would be wrong: there is no previous gate, or the pacer has
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
/// stranded in the past from releasing a burst of catch-up sends once the
/// group starts moving again.
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
let base = match self.send_gate {
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
_ => accepted_at,
};
base.checked_add(spacing)
}
fn record_stagger_acceptance( fn record_stagger_acceptance(
&mut self, &mut self,
result: SendResult, result: SendResult,
@@ -1626,7 +1718,7 @@ impl ManagedClientTask {
return; return;
}; };
self.last_stagger_send = Some(accepted_at); self.last_stagger_send = Some(accepted_at);
self.send_gate = accepted_at.checked_add(spacing); self.send_gate = self.next_stagger_gate(spacing, accepted_at);
#[cfg(test)] #[cfg(test)]
if let Some(observations) = &self.stagger_observations { if let Some(observations) = &self.stagger_observations {
observations.lock().unwrap().push((_active, spacing)); observations.lock().unwrap().push((_active, spacing));
@@ -1649,6 +1741,9 @@ impl ManagedClientTask {
self.targets[index].state = state; self.targets[index].state = state;
return SendResult::NotAttempted; return SendResult::NotAttempted;
}; };
// A burst pass may span several slots; validate this target against
// fresh time rather than the pass's earlier scheduling snapshot.
let now = now.max(Instant::now());
if client if client
.next_probe_timeout_deadline() .next_probe_timeout_deadline()
.is_some_and(|deadline| deadline <= now) .is_some_and(|deadline| deadline <= now)
@@ -1657,7 +1752,21 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Active { client }; self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted; return SendResult::NotAttempted;
} }
if client let schedule = self.targets[index]
.schedule
.as_mut()
.expect("active managed targets have a probe schedule");
if !schedule.permit_probe_at(now) {
self.targets[index].send_waiting = false;
if client.has_pending_probes() {
self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted;
}
self.targets[index].sync_packets_sent(&client);
self.begin_drain(index, client, ManagedTargetEndReason::TestComplete, now);
return SendResult::Ready { accepted: false };
}
if schedule
.next_send_deadline() .next_send_deadline()
.is_none_or(|deadline| deadline > now) .is_none_or(|deadline| deadline > now)
{ {
@@ -1665,8 +1774,25 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Active { client }; self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted; return SendResult::NotAttempted;
} }
let schedule = self.targets[index].schedule.as_ref().unwrap();
let commit =
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
Ok(commit) => commit,
Err(error) => {
self.begin_open_session_failure(
index,
client,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Drain,
);
return SendResult::Failed { accepted: false };
}
};
let scheduled_at = commit.scheduled_at;
let before = client.packets_sent(); let before = client.packets_sent();
let result = client.poll_send_probe(cx); let mut result = client.poll_send_probe(cx);
let after = client.packets_sent(); let after = client.packets_sent();
let accepted = after > before; let accepted = after > before;
let send_result = match &result { let send_result = match &result {
@@ -1675,10 +1801,37 @@ impl ManagedClientTask {
Poll::Ready(Err(_)) => SendResult::Failed { accepted }, Poll::Ready(Err(_)) => SendResult::Failed { accepted },
}; };
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now()); self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
let schedule_error = accepted if accepted {
.then(|| client.skip_missed_probe_slots_at(Instant::now())) self.targets[index]
.transpose() .schedule
.err(); .as_mut()
.unwrap()
.commit(commit);
if let Poll::Ready(Ok(events)) = &mut result {
for event in events {
if let ClientEvent::EchoSent {
scheduled_at: intended,
sent_at,
timer_error,
..
} = event
{
*intended = Some(scheduled_at);
*timer_error = Some(instant_abs_diff(sent_at.mono, scheduled_at));
}
}
}
}
let schedule_error = if accepted {
self.targets[index]
.schedule
.as_mut()
.unwrap()
.skip_missed_slots_at(Instant::now())
.err()
} else {
None
};
self.targets[index].record_packets_sent(after); self.targets[index].record_packets_sent(after);
match result { match result {
Poll::Pending => { Poll::Pending => {
@@ -1694,7 +1847,7 @@ impl ManagedClientTask {
let TargetState::Active { client } = let TargetState::Active { client } =
mem::replace(&mut self.targets[index].state, TargetState::Terminal) mem::replace(&mut self.targets[index].state, TargetState::Terminal)
else { else {
unreachable!("probe sender remained active while publishing send events"); unreachable!("sender remained active")
}; };
self.begin_open_session_failure( self.begin_open_session_failure(
index, index,
@@ -1709,6 +1862,7 @@ impl ManagedClientTask {
SendResult::Ready { accepted } SendResult::Ready { accepted }
} }
} }
Poll::Ready(Err(error)) => { Poll::Ready(Err(error)) => {
self.targets[index].send_waiting = false; self.targets[index].send_waiting = false;
self.begin_open_session_failure( self.begin_open_session_failure(
@@ -1972,7 +2126,12 @@ impl ManagedClientTask {
if target.desired && target.retirement.is_none() && !target.send_waiting { if target.desired && target.retirement.is_none() && !target.send_waiting {
send_deadline = send_deadline send_deadline = send_deadline
.into_iter() .into_iter()
.chain(client.next_send_deadline()) .chain(
target
.schedule
.as_ref()
.and_then(ProbeSchedule::next_send_deadline),
)
.min(); .min();
} }
} }
@@ -2289,6 +2448,7 @@ fn build_task(
} }
})?; })?;
runtimes.push(TargetRuntime { runtimes.push(TargetRuntime {
schedule: None,
instance: TargetInstance { instance: TargetInstance {
id: target.id.clone(), id: target.id.clone(),
generation, generation,
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -39,7 +39,8 @@ pub(crate) struct PendingProbe {
/// eligible only for upstream one-way delay after local plausibility /// eligible only for upstream one-way delay after local plausibility
/// validation against `tx_not_before_wall` (see `compute_one_way`'s /// validation against `tx_not_before_wall` (see `compute_one_way`'s
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for /// `preferred_send_wall`). `sent_at` remains the userspace fallback for
/// upstream one-way delay when this is absent or implausible. /// upstream one-way delay when this is absent, implausible, or kernel-ID
/// correlation was invalidated by a failed probe submission.
pub kernel_tx_timestamp: Option<SystemTime>, pub kernel_tx_timestamp: Option<SystemTime>,
} }
@@ -197,7 +198,6 @@ impl PendingMap {
self.map.get_mut(&wire_seq).map(|entry| &mut entry.probe) self.map.get_mut(&wire_seq).map(|entry| &mut entry.probe)
} }
#[cfg(any(feature = "tokio", test))]
pub fn next_timeout_deadline(&self) -> Option<Instant> { pub fn next_timeout_deadline(&self) -> Option<Instant> {
self.first self.first
.and_then(|wire_seq| self.map.get(&wire_seq)) .and_then(|wire_seq| self.map.get(&wire_seq))
+11 -8
View File
@@ -12,14 +12,17 @@
//! After a successful Open, the client adapter best-effort upgrades the //! After a successful Open, the client adapter best-effort upgrades the
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`] //! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic //! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
//! `SOF_TIMESTAMPING_OPT_ID` counter the kernel assigns to each successfully //! `SOF_TIMESTAMPING_OPT_ID` counter normally tracks the probe wire sequence
//! submitted datagram is used directly as the probe's wire sequence number //! after successful sends, so it is used as a best-effort correlation ID.
//! (see the client crate's `AGENTS.md` for the correlation invariant this //! A kernel ID can theoretically be consumed by a send that later fails,
//! relies on). [`drain_tx_timestamps`] performs a small bounded, nonblocking //! desynchronizing that correlation. [`drain_tx_timestamps`] discards
//! read of `MSG_ERRQUEUE` so the adapter can opportunistically collect those //! unmatched or implausible records; the userspace `sent_at` timestamp remains
//! timestamps without ever waiting for one. The observed kernel TX wall time //! the fallback. After a probe send failure, correlation is disabled for that
//! is retained as metadata only; it is not consumed by any measurement in //! session so later IDs cannot be misattributed. It performs a small bounded,
//! this change. //! nonblocking read of
//! `MSG_ERRQUEUE` so the adapter can opportunistically collect timestamps
//! without ever waiting for one. The observed kernel TX wall time is retained
//! as metadata only; it is not consumed by any measurement in this change.
use std::{ use std::{
io::{self, IoSliceMut}, io::{self, IoSliceMut},
@@ -624,22 +624,14 @@ mod tests {
)); ));
} }
// NOTE: a live test proving that a failed nonblocking `sendmsg` does not // NOTE: There is no live test for whether a failed nonblocking `sendmsg`
// consume an automatic OPT_ID is not included here. Forcing a reliable, // consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
// non-flaky `WouldBlock` on a connected loopback UDP socket (e.g. via // on a connected loopback UDP socket (for example through socket-buffer
// socket-buffer saturation) has no deterministic OS-level mechanism // saturation) has no deterministic OS-level mechanism in this environment,
// available in this environment, and the repository's testing policy // and the repository's testing policy forbids sleep/saturation-based
// explicitly forbids faking coverage with sleep/saturation-based // flakiness. The correlation is intentionally best effort: a kernel ID can
// flakiness. That safety is instead established from Linux kernel // theoretically be consumed by a send that later fails. The deterministic
// source: `_sock_tx_timestamp` (include/net/sock.h) and // adapter test covers only the local half: an injected `WouldBlock` does
// `__ip_append_data`/`__ip6_append_data` (net/ipv4/ip_output.c, // not advance the local wire-sequence counter. Any later unmatched kernel
// net/ipv6/ip6_output.c) increment `sk->sk_tskey` speculatively before // timestamp is discarded and the userspace `sent_at` remains the fallback.
// a datagram is fully built and explicitly `atomic_dec` it back out on
// every error path (tracked via a local `hold_tskey` flag), so a failed
// send can never leave a gap in — or otherwise advance — the ID space
// the kernel hands out to successfully submitted datagrams. See the
// client crate's `AGENTS.md` for the full citation. The client-side
// half of this invariant (a `WouldBlock` `try_send` never advances the
// local wire-sequence counter used as the correlation ID) is covered by
// a deterministic test in the adapter module that injects `WouldBlock`.
} }
+77 -82
View File
@@ -60,6 +60,7 @@ struct ActiveSession {
pending: PendingMap, pending: PendingMap,
timed_out: TimedOutMap, timed_out: TimedOutMap,
completed: CompletedSet, completed: CompletedSet,
kernel_tx_correlation_valid: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -314,7 +315,7 @@ impl SessionMachine {
self.config.probe_timeout self.config.probe_timeout
} }
pub(crate) fn prepare_probe(&self) -> Result<Option<PreparedProbe>, ClientError> { pub(crate) fn prepare_probe(&self) -> Result<PreparedProbe, ClientError> {
let session = self.open_session()?; let session = self.open_session()?;
let bytes = encode_request( let bytes = encode_request(
RequestToEncode::Echo { RequestToEncode::Echo {
@@ -325,10 +326,10 @@ impl SessionMachine {
}, },
self.config.hmac_key.as_deref(), self.config.hmac_key.as_deref(),
)?; )?;
Ok(Some(PreparedProbe { Ok(PreparedProbe {
bytes: bytes.into_boxed_slice(), bytes: bytes.into_boxed_slice(),
seq: session.next_wire_seq, seq: session.next_wire_seq,
})) })
} }
pub(crate) fn preflight_probe_commit( pub(crate) fn preflight_probe_commit(
@@ -423,15 +424,27 @@ impl SessionMachine {
} }
} }
/// Disable Linux kernel TX timestamp correlation after a probe send fails.
///
/// A failed submission can consume a kernel `SOF_TIMESTAMPING_OPT_ID`
/// without advancing the wire sequence. Existing associated timestamps
/// remain valid observations; later error-queue timestamps cannot be
/// associated safely and are ignored for the rest of this session.
pub(crate) fn invalidate_kernel_tx_correlation(&mut self) {
if let MachineState::Open(session) = &mut self.state {
session.kernel_tx_correlation_valid = false;
}
}
/// Record an observed Linux kernel TX timestamp for `wire_seq`, the /// Record an observed Linux kernel TX timestamp for `wire_seq`, the
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the /// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
/// datagram was submitted. /// datagram was submitted.
/// ///
/// `wire_seq` doubles as that correlation ID: both counters start at /// `wire_seq` normally matches that correlation ID after successful
/// zero for a session's first timestamped send and advance by exactly /// sends. This is best effort rather than an invariant: a kernel ID can
/// one only on a confirmed successful submission, so they stay /// theoretically be consumed by a send that later fails, leaving the
/// identical for the life of one open session (see the client crate's /// counters desynchronized. An unmatched or implausible ID is discarded;
/// `AGENTS.md` for the full invariant). /// the probe's userspace `sent_at` remains the fallback.
/// ///
/// Updates a still-pending or already-timed-out probe in place. Never /// Updates a still-pending or already-timed-out probe in place. Never
/// resurrects a completed, evicted, or unknown probe, and never touches /// resurrects a completed, evicted, or unknown probe, and never touches
@@ -443,6 +456,9 @@ impl SessionMachine {
let MachineState::Open(session) = &mut self.state else { let MachineState::Open(session) = &mut self.state else {
return; return;
}; };
if !session.kernel_tx_correlation_valid {
return;
}
if let Some(probe) = session.pending.get_mut(wire_seq) { if let Some(probe) = session.pending.get_mut(wire_seq) {
probe.kernel_tx_timestamp.get_or_insert(timestamp); probe.kernel_tx_timestamp.get_or_insert(timestamp);
return; return;
@@ -534,14 +550,21 @@ impl SessionMachine {
} }
} }
pub(crate) fn pending_is_empty(&self) -> bool { pub(crate) fn negotiated_params(&self) -> Option<&NegotiatedParams> {
match &self.state { match &self.state {
MachineState::Open(session) => session.pending.len() == 0, MachineState::Open(session) => Some(&session.negotiated),
MachineState::NoTestCompleted | MachineState::Closed { .. } => true, _ => None,
MachineState::Connected => false,
} }
} }
pub(crate) fn pending_is_empty(&self) -> bool {
match &self.state {
MachineState::Open(session) => session.pending.len() == 0,
_ => true,
}
}
#[cfg(test)]
pub(crate) fn is_terminal(&self) -> bool { pub(crate) fn is_terminal(&self) -> bool {
matches!( matches!(
self.state, self.state,
@@ -559,14 +582,6 @@ impl SessionMachine {
) )
} }
#[cfg(test)]
pub(crate) fn has_timed_out_metadata(&self) -> bool {
matches!(
&self.state,
MachineState::Open(session) if session.timed_out.len() > 0
)
}
#[cfg(any(feature = "tokio", test))] #[cfg(any(feature = "tokio", test))]
pub(crate) fn packets_sent(&self) -> u64 { pub(crate) fn packets_sent(&self) -> u64 {
match &self.state { match &self.state {
@@ -576,7 +591,6 @@ impl SessionMachine {
} }
} }
#[cfg(feature = "tokio")]
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> { pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
match &self.state { match &self.state {
MachineState::Open(session) => session.pending.next_timeout_deadline(), MachineState::Open(session) => session.pending.next_timeout_deadline(),
@@ -601,6 +615,7 @@ impl SessionMachine {
} }
} }
#[cfg(test)]
pub(crate) fn is_open(&self) -> bool { pub(crate) fn is_open(&self) -> bool {
matches!(self.state, MachineState::Open(_)) matches!(self.state, MachineState::Open(_))
} }
@@ -636,6 +651,7 @@ impl SessionMachine {
pending: PendingMap::new(self.config.max_pending_probes), pending: PendingMap::new(self.config.max_pending_probes),
timed_out: TimedOutMap::new(self.config.max_pending_probes), timed_out: TimedOutMap::new(self.config.max_pending_probes),
completed: CompletedSet::new(self.config.max_pending_probes), completed: CompletedSet::new(self.config.max_pending_probes),
kernel_tx_correlation_valid: true,
})); }));
let event = ClientEvent::SessionStarted { let event = ClientEvent::SessionStarted {
@@ -1161,43 +1177,6 @@ fn config_duration_to_ns(field: &str, duration: Duration) -> Result<i64, ClientE
}) })
} }
#[cfg(test)]
impl SessionMachine {
pub(crate) fn seed_wrapped_probe_history_for_test(&mut self, now: ClientTimestamp) {
let session = self
.open_session_mut()
.expect("wrapped probe history requires an open session");
session.next_wire_seq = 0;
session.timed_out.insert(PendingProbe {
wire_seq: 0,
sent_at: now,
timeout_at: now.mono,
tx_not_before_wall: now.wall,
kernel_tx_timestamp: None,
});
session.completed.insert(0);
}
}
#[cfg(all(test, feature = "tokio"))]
impl SessionMachine {
pub(crate) fn remove_pending_for_test(&mut self, wire_seq: u32) -> Option<PendingProbe> {
self.open_session_mut()
.expect("test pending probes require an open session")
.pending
.remove(wire_seq)
}
pub(crate) fn replace_pending_for_test(&mut self, probe: PendingProbe) {
let session = self
.open_session_mut()
.expect("test pending probes require an open session");
session.pending.remove(probe.wire_seq);
session.pending.preflight_insert(probe.wire_seq).unwrap();
session.pending.commit_insert(probe);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1231,6 +1210,7 @@ mod tests {
pending: PendingMap::new(max_pending_probes), pending: PendingMap::new(max_pending_probes),
timed_out: TimedOutMap::new(max_pending_probes), timed_out: TimedOutMap::new(max_pending_probes),
completed: CompletedSet::new(max_pending_probes), completed: CompletedSet::new(max_pending_probes),
kernel_tx_correlation_valid: true,
})); }));
machine machine
} }
@@ -1726,7 +1706,7 @@ mod tests {
#[test] #[test]
fn uncommitted_probe_preparation_changes_no_authoritative_state() { fn uncommitted_probe_preparation_changes_no_authoritative_state() {
let mut machine = open_machine(4, Duration::from_secs(1)); let mut machine = open_machine(4, Duration::from_secs(1));
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
assert_eq!(prepared.seq, 0); assert_eq!(prepared.seq, 0);
// Repeating preflight, and finalizing a commit that is never applied, // Repeating preflight, and finalizing a commit that is never applied,
@@ -1750,8 +1730,8 @@ mod tests {
#[test] #[test]
fn stale_prepared_probe_is_rejected_without_changing_state() { fn stale_prepared_probe_is_rejected_without_changing_state() {
let mut machine = open_machine(4, Duration::from_secs(1)); let mut machine = open_machine(4, Duration::from_secs(1));
let stale = machine.prepare_probe().unwrap().unwrap(); let stale = machine.prepare_probe().unwrap();
let accepted = machine.prepare_probe().unwrap().unwrap(); let accepted = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now()); let sent_at = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&accepted).unwrap(); let preflight = machine.preflight_probe_commit(&accepted).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
@@ -1776,7 +1756,7 @@ mod tests {
#[test] #[test]
fn repeated_would_block_style_preflight_commits_once() { fn repeated_would_block_style_preflight_commits_once() {
let mut machine = open_machine(4, Duration::from_secs(1)); let mut machine = open_machine(4, Duration::from_secs(1));
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now()); let sent_at = timestamp(Instant::now());
for _ in 0..3 { for _ in 0..3 {
@@ -1797,7 +1777,7 @@ mod tests {
#[test] #[test]
fn probe_commit_does_not_require_presentation_timing() { fn probe_commit_does_not_require_presentation_timing() {
let mut machine = open_machine(4, Duration::from_secs(1)); let mut machine = open_machine(4, Duration::from_secs(1));
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
let send_anchor = timestamp(Instant::now()); let send_anchor = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&prepared).unwrap(); let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine let commit = machine
@@ -1822,7 +1802,7 @@ mod tests {
fn commit_uses_reserved_capacity_and_prevalidated_counter() { fn commit_uses_reserved_capacity_and_prevalidated_counter() {
let mut machine = open_machine(2, Duration::from_secs(1)); let mut machine = open_machine(2, Duration::from_secs(1));
active_mut(&mut machine).packets_sent = u64::MAX - 1; active_mut(&mut machine).packets_sent = u64::MAX - 1;
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap(); let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let reserved_capacity = active(&machine).pending.capacity(); let reserved_capacity = active(&machine).pending.capacity();
assert_eq!(preflight.next_packets_sent, u64::MAX); assert_eq!(preflight.next_packets_sent, u64::MAX);
@@ -1842,13 +1822,13 @@ mod tests {
#[test] #[test]
fn pending_capacity_exhaustion_is_detected_before_commit() { fn pending_capacity_exhaustion_is_detected_before_commit() {
let mut machine = open_machine(1, Duration::from_secs(1)); let mut machine = open_machine(1, Duration::from_secs(1));
let first = machine.prepare_probe().unwrap().unwrap(); let first = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now()); let sent_at = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&first).unwrap(); let preflight = machine.preflight_probe_commit(&first).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine.commit_probe_sent(commit, sent_at, first.bytes.len()); machine.commit_probe_sent(commit, sent_at, first.bytes.len());
let second = machine.prepare_probe().unwrap().unwrap(); let second = machine.prepare_probe().unwrap();
assert!(matches!( assert!(matches!(
machine.preflight_probe_commit(&second), machine.preflight_probe_commit(&second),
Err(ClientError::PendingLimitExceeded { limit: 1 }) Err(ClientError::PendingLimitExceeded { limit: 1 })
@@ -1859,14 +1839,14 @@ mod tests {
#[test] #[test]
fn pending_sequence_collision_is_detected_before_commit() { fn pending_sequence_collision_is_detected_before_commit() {
let mut machine = open_machine(2, Duration::from_secs(1)); let mut machine = open_machine(2, Duration::from_secs(1));
let first = machine.prepare_probe().unwrap().unwrap(); let first = machine.prepare_probe().unwrap();
let sent_at = timestamp(Instant::now()); let sent_at = timestamp(Instant::now());
let preflight = machine.preflight_probe_commit(&first).unwrap(); let preflight = machine.preflight_probe_commit(&first).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine.commit_probe_sent(commit, sent_at, first.bytes.len()); machine.commit_probe_sent(commit, sent_at, first.bytes.len());
active_mut(&mut machine).next_wire_seq = 0; active_mut(&mut machine).next_wire_seq = 0;
let reused = machine.prepare_probe().unwrap().unwrap(); let reused = machine.prepare_probe().unwrap();
assert!(matches!( assert!(matches!(
machine.preflight_probe_commit(&reused), machine.preflight_probe_commit(&reused),
Err(ClientError::PendingSequenceCollision { seq: 0 }) Err(ClientError::PendingSequenceCollision { seq: 0 })
@@ -1878,7 +1858,7 @@ mod tests {
fn counter_overflow_is_detected_before_commit() { fn counter_overflow_is_detected_before_commit() {
let mut machine = open_machine(2, Duration::from_secs(1)); let mut machine = open_machine(2, Duration::from_secs(1));
active_mut(&mut machine).packets_sent = u64::MAX; active_mut(&mut machine).packets_sent = u64::MAX;
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
assert!(matches!( assert!(matches!(
machine.preflight_probe_commit(&prepared), machine.preflight_probe_commit(&prepared),
@@ -1892,7 +1872,7 @@ mod tests {
#[test] #[test]
fn timeout_overflow_is_detected_before_commit() { fn timeout_overflow_is_detected_before_commit() {
let mut machine = open_machine(2, Duration::MAX); let mut machine = open_machine(2, Duration::MAX);
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap(); let preflight = machine.preflight_probe_commit(&prepared).unwrap();
assert!(matches!( assert!(matches!(
@@ -1936,7 +1916,7 @@ mod tests {
fn wrapping_sequence_from_max_to_zero_remains_valid() { fn wrapping_sequence_from_max_to_zero_remains_valid() {
let mut machine = open_machine(2, Duration::from_secs(1)); let mut machine = open_machine(2, Duration::from_secs(1));
active_mut(&mut machine).next_wire_seq = u32::MAX; active_mut(&mut machine).next_wire_seq = u32::MAX;
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap(); let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine let commit = machine
.finalize_probe_commit(preflight, timestamp(Instant::now())) .finalize_probe_commit(preflight, timestamp(Instant::now()))
@@ -1944,7 +1924,7 @@ mod tests {
machine.commit_probe_sent(commit, timestamp(Instant::now()), prepared.bytes.len()); machine.commit_probe_sent(commit, timestamp(Instant::now()), prepared.bytes.len());
assert_eq!(active(&machine).next_wire_seq, 0); assert_eq!(active(&machine).next_wire_seq, 0);
assert_eq!(machine.prepare_probe().unwrap().unwrap().seq, 0); assert_eq!(machine.prepare_probe().unwrap().seq, 0);
} }
#[test] #[test]
@@ -1964,7 +1944,7 @@ mod tests {
session.timed_out.insert(obsolete); session.timed_out.insert(obsolete);
session.completed.insert(0); session.completed.insert(0);
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap(); let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine let commit = machine
.finalize_probe_commit(preflight, timestamp(now)) .finalize_probe_commit(preflight, timestamp(now))
@@ -2377,7 +2357,7 @@ mod tests {
} }
fn send_probe(machine: &mut SessionMachine, sent_at: ClientTimestamp) -> u32 { fn send_probe(machine: &mut SessionMachine, sent_at: ClientTimestamp) -> u32 {
let prepared = machine.prepare_probe().unwrap().unwrap(); let prepared = machine.prepare_probe().unwrap();
let preflight = machine.preflight_probe_commit(&prepared).unwrap(); let preflight = machine.preflight_probe_commit(&prepared).unwrap();
let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap(); let commit = machine.finalize_probe_commit(preflight, sent_at).unwrap();
machine machine
@@ -2873,6 +2853,25 @@ mod tests {
); );
} }
#[test]
fn invalidated_kernel_tx_correlation_uses_userspace_send_time() {
let mono = Instant::now();
let mut machine = upstream_probe(send_wall_ms(1_000), mono);
// A failed send can consume a kernel ID without advancing wire_seq.
// A later timestamp whose ID happens to match a pending wire sequence
// must not be associated after that gap.
machine.invalidate_kernel_tx_correlation();
machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005));
let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono);
assert_eq!(
reply_one_way(&events).unwrap().client_to_server,
Some(SignedDuration::from_nanos(20_000_000))
);
}
#[test] #[test]
fn upstream_one_way_delay_falls_back_without_kernel_tx() { fn upstream_one_way_delay_falls_back_without_kernel_tx() {
let mono = Instant::now(); let mono = Instant::now();
@@ -3195,6 +3194,7 @@ mod tests {
pending: PendingMap::new(MAX_PENDING_PROBES), pending: PendingMap::new(MAX_PENDING_PROBES),
timed_out: TimedOutMap::new(MAX_PENDING_PROBES), timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
completed: CompletedSet::new(MAX_PENDING_PROBES), completed: CompletedSet::new(MAX_PENDING_PROBES),
kernel_tx_correlation_valid: true,
})); }));
(machine, params) (machine, params)
} }
@@ -3482,9 +3482,7 @@ mod tests {
machine: &mut SessionMachine, machine: &mut SessionMachine,
now: ClientTimestamp, now: ClientTimestamp,
) -> Result<ProbeSent, ClientError> { ) -> Result<ProbeSent, ClientError> {
let prepared = machine let prepared = machine.prepare_probe()?;
.prepare_probe()?
.expect("prepare_probe always returns Some when Ok");
let preflight = machine.preflight_probe_commit(&prepared)?; let preflight = machine.preflight_probe_commit(&prepared)?;
let commit = machine.finalize_probe_commit(preflight, now)?; let commit = machine.finalize_probe_commit(preflight, now)?;
let bytes = prepared.bytes.len(); let bytes = prepared.bytes.len();
@@ -3592,10 +3590,7 @@ mod tests {
result result
); );
} else { } else {
let prepared_a = machine let prepared_a = machine.prepare_probe().unwrap();
.prepare_probe()
.unwrap()
.expect("open session prepares probes");
prop_assert_eq!(prepared_a.seq, model.next_wire_seq); prop_assert_eq!(prepared_a.seq, model.next_wire_seq);
let sent = do_send(machine, *now).expect( let sent = do_send(machine, *now).expect(
@@ -3627,7 +3622,7 @@ mod tests {
let result = machine.prepare_probe(); let result = machine.prepare_probe();
match model.state { match model.state {
ModelState::Open => { ModelState::Open => {
let prepared = result.unwrap().expect("open session prepares probes"); let prepared = result.unwrap();
prop_assert_eq!(prepared.seq, model.next_wire_seq); prop_assert_eq!(prepared.seq, model.next_wire_seq);
prop_assert!(!prepared.bytes.is_empty()); prop_assert!(!prepared.bytes.is_empty());
} }
+15
View File
@@ -12,6 +12,14 @@ use crate::{
socket_options::apply_ttl_to_socket, socket_options::apply_ttl_to_socket,
}; };
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
use crate::socket_options::apply_routing_options;
pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> { pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> {
if timeouts.is_empty() { if timeouts.is_empty() {
return Err(ClientError::NoOpenTimeouts); return Err(ClientError::NoOpenTimeouts);
@@ -115,6 +123,13 @@ fn create_connected_udp_socket(
remote: SocketAddr, remote: SocketAddr,
) -> Result<UdpSocket, ClientError> { ) -> Result<UdpSocket, ClientError> {
let socket = create_prebind_udp_socket(config, remote)?; let socket = create_prebind_udp_socket(config, remote)?;
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
apply_routing_options(&socket, config, remote)?;
let bind_addr = config.bind_addr.unwrap_or_else(|| { let bind_addr = config.bind_addr.unwrap_or_else(|| {
if remote.is_ipv4() { if remote.is_ipv4() {
SocketAddr::from(([0, 0, 0, 0], 0)) SocketAddr::from(([0, 0, 0, 0], 0))
+53
View File
@@ -5,11 +5,64 @@ use std::{
use socket2::SockRef; use socket2::SockRef;
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
use socket2::Socket;
use crate::{ use crate::{
config::{MAX_DSCP_CODEPOINT, MAX_TTL}, config::{MAX_DSCP_CODEPOINT, MAX_TTL},
error::ClientError, error::ClientError,
}; };
#[cfg(any(
target_os = "android",
target_os = "freebsd",
target_os = "fuchsia",
target_os = "linux"
))]
pub(crate) fn apply_routing_options(
socket: &Socket,
config: &crate::SocketConfig,
remote: SocketAddr,
) -> Result<(), ClientError> {
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
{
if let Some(device) = &config.bind_to_device {
socket
.bind_device(Some(device.as_bytes()))
.map_err(|source| ClientError::SocketOption {
operation: "bind socket to device",
remote,
source,
})?;
}
if let Some(mark) = config.mark {
socket
.set_mark(mark)
.map_err(|source| ClientError::SocketOption {
operation: "set firewall mark",
remote,
source,
})?;
}
}
#[cfg(target_os = "freebsd")]
if let Some(fib) = config.fib {
socket
.set_fib(fib)
.map_err(|source| ClientError::SocketOption {
operation: "set FIB",
remote,
source,
})?;
}
Ok(())
}
/// Converts a public DSCP codepoint (`0..=`[`MAX_DSCP_CODEPOINT`]) into the /// Converts a public DSCP codepoint (`0..=`[`MAX_DSCP_CODEPOINT`]) into the
/// raw IP TOS / Traffic Class byte it occupies the upper six bits of. /// raw IP TOS / Traffic Class byte it occupies the upper six bits of.
/// ///
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.0" version = "0.5.1"
description = "IRTT protocol encoding and decoding primitives" description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -15,7 +15,7 @@ categories = ["network-programming"]
hmac = "0.13" hmac = "0.13"
md-5 = "0.11" md-5 = "0.11"
subtle = "2.6.1" subtle = "2.6.1"
thiserror = "2.0.20" thiserror = "2.0.21"
[dev-dependencies] [dev-dependencies]
proptest = "1.11.0" proptest = "1.11.0"
+44
View File
@@ -60,6 +60,50 @@ pub struct TimestampFields {
pub send_mono: Option<i64>, pub send_mono: Option<i64>,
} }
/// Encodes an ECHO reply.
///
/// `params` supplies the negotiated layout and length, exactly as on the
/// request side, and every optional field the layout requires must be present
/// in `reply`: a `Some` where the layout has no field, or a `None` where it has
/// one, is an error. `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC`
/// and signs the finished datagram.
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`] or [`ProtoError::UnexpectedFlag`] when
/// the reply does not carry exactly the packet-type flags this encoder
/// requires, [`ProtoError::MissingField`] or [`ProtoError::UnexpectedField`]
/// when `reply`'s optional fields do not match the negotiated layout, and
/// [`ProtoError::PayloadTooLarge`] when the payload does not fit the negotiated
/// length.
///
/// # Example
///
/// ```
/// use irtt_proto::{
/// decode_echo_reply, encode_echo_reply, EchoReply, Params, ReceivedStats,
/// TimestampFields, FLAG_REPLY,
/// };
///
/// // `received_stats` negotiates a receive counter; four payload bytes.
/// let params = Params {
/// length: 24,
/// received_stats: ReceivedStats::Count,
/// ..Params::default()
/// };
/// let reply = EchoReply {
/// flags: FLAG_REPLY,
/// token: 0x1234_5678_9abc_def0,
/// sequence: 7,
/// recv_count: Some(3),
/// recv_window: None,
/// timestamps: TimestampFields::default(),
/// payload: vec![1, 2, 3, 4],
/// };
///
/// let packet = encode_echo_reply(&reply, &params, None).unwrap();
/// assert_eq!(decode_echo_reply(&packet, &params, None).unwrap(), reply);
/// ```
pub fn encode_echo_reply( pub fn encode_echo_reply(
reply: &EchoReply, reply: &EchoReply,
params: &Params, params: &Params,
+17
View File
@@ -68,6 +68,23 @@ pub fn verify_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<()>
/// [`ProtoError::InvalidHmacOffset`] when the field is truncated, and /// [`ProtoError::InvalidHmacOffset`] when the field is truncated, and
/// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never /// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never
/// modified. /// modified.
///
/// # Example
///
/// ```
/// use irtt_proto::{encode_request, verify_packet_hmac, Params, RequestToEncode};
///
/// // Synthetic example key — use a real shared secret in production.
/// let key = b"example key";
/// let params = Params::with_protocol_defaults();
/// let packet = encode_request(
/// RequestToEncode::Open { params: &params, no_test: false },
/// Some(key),
/// ).unwrap();
///
/// verify_packet_hmac(key, &packet).unwrap();
/// assert!(verify_packet_hmac(b"wrong key", &packet).is_err());
/// ```
pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> { pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
let envelope = envelope::decode_structural(packet)?; let envelope = envelope::decode_structural(packet)?;
if !envelope.hmac_present { if !envelope.hmac_present {
+14
View File
@@ -135,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 /// A ceiling on what a negotiated length may legitimately *be* — an MTU, a
/// resource bound, a maximum packet size — is deliberately not here. That is /// resource bound, a maximum packet size — is deliberately not here. That is
/// server and runtime policy, and it belongs where the negotiation happens. /// server and runtime policy, and it belongs where the negotiation happens.
///
/// # Example
///
/// ```
/// use irtt_proto::{echo_packet_len, Params};
///
/// // The default layout's mandatory field block is 16 bytes (header, token,
/// // sequence), so a smaller requested length is floored up to it.
/// assert_eq!(echo_packet_len(false, &Params::default()).unwrap(), 16);
/// assert_eq!(
/// echo_packet_len(false, &Params { length: 40, ..Params::default() }).unwrap(),
/// 40,
/// );
/// ```
pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> { pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
let header_len = echo_header_len(hmac, params); let header_len = echo_header_len(hmac, params);
let requested = if params.length <= 0 { let requested = if params.length <= 0 {
+33
View File
@@ -42,6 +42,39 @@
//! which reply kind to expect, and the two need genuinely different semantic //! which reply kind to expect, and the two need genuinely different semantic
//! context. //! context.
//! //!
//! # Example
//!
//! The normal path is directional: a sender encodes with [`encode_request`], a
//! receiver classifies with [`decode_request`], and authentication is a
//! separate step once the applicable key is known.
//!
//! ```
//! use irtt_proto::{
//! decode_request, encode_request, verify_packet_hmac, DecodedRequestKind, Params,
//! RequestToEncode,
//! };
//!
//! // Sender side: encode an authenticated open request.
//! let params = Params::with_protocol_defaults();
//! let key = b"example key";
//! let packet = encode_request(
//! RequestToEncode::Open { params: &params, no_test: false },
//! Some(key),
//! ).unwrap();
//!
//! // Receiver side: classify structurally, before any key is known.
//! let request = decode_request(&packet).unwrap();
//! assert!(request.hmac_present);
//! let DecodedRequestKind::Open { no_test, params: encoded } = request.kind else {
//! panic!("expected an open request");
//! };
//! assert!(!no_test);
//! assert_eq!(Params::decode(encoded).unwrap(), params);
//!
//! // Authentication is a separate step, once the applicable key is known.
//! verify_packet_hmac(key, &packet).unwrap();
//! ```
//!
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
pub mod echo; pub mod echo;
+40
View File
@@ -13,6 +13,34 @@ pub struct OpenReply {
pub params: Params, pub params: Params,
} }
/// Encodes an open reply.
///
/// `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` and signs the
/// finished datagram, while `None` produces an unauthenticated reply and clears
/// any caller-supplied `FLAG_HMAC`. A zero token is rejected unless the reply
/// also carries `FLAG_CLOSE` (a rejected open).
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`] when the reply lacks `FLAG_OPEN` or
/// `FLAG_REPLY`, and [`ProtoError::ZeroToken`] for a zero token without
/// `FLAG_CLOSE`.
///
/// # Example
///
/// ```
/// use irtt_proto::{
/// decode_open_reply, encode_open_reply, OpenReply, Params, FLAG_OPEN, FLAG_REPLY,
/// };
///
/// let reply = OpenReply {
/// flags: FLAG_OPEN | FLAG_REPLY,
/// token: 0x1234_5678_9abc_def0,
/// params: Params::with_protocol_defaults(),
/// };
/// let packet = encode_open_reply(&reply, None).unwrap();
/// assert_eq!(decode_open_reply(&packet, None).unwrap(), reply);
/// ```
pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> { pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
let params = reply.params.encode(); let params = reply.params.encode();
let mut out = envelope::begin_checked( let mut out = envelope::begin_checked(
@@ -30,6 +58,18 @@ pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<V
envelope::finish(out, hmac_key) envelope::finish(out, hmac_key)
} }
/// Decodes an open reply.
///
/// Requires `FLAG_OPEN` and `FLAG_REPLY`, and — when `hmac_key` is supplied —
/// verifies the authentication field. The decoded [`Params`] are validated:
/// malformed or incompatible parameter values are rejected here.
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`], [`ProtoError::HmacPresenceMismatch`],
/// [`ProtoError::BadHmac`], [`ProtoError::PacketTooShort`],
/// [`ProtoError::ZeroToken`], or a parameter decode error. See
/// [`encode_open_reply`] for a round-trip example.
pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenReply> { pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenReply> {
let envelope = envelope::decode( let envelope = envelope::decode(
packet, packet,
+37
View File
@@ -16,6 +16,29 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open /// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open
/// request with an empty parameter payload means. It is not a set of sensible /// request with an empty parameter payload means. It is not a set of sensible
/// client settings; a client builds its request from its own configuration. /// client settings; a client builds its request from its own configuration.
///
/// # Example
///
/// Construct a `Params`, encode it, and decode it back:
///
/// ```
/// use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
///
/// let params = Params {
/// protocol_version: 1,
/// duration_ns: 3_000_000_000,
/// interval_ns: 1_000_000_000,
/// length: 1472,
/// received_stats: ReceivedStats::Both,
/// stamp_at: StampAt::Both,
/// clock: Clock::Both,
/// server_fill: Some(ServerFill { value: "rand".to_owned() }),
/// ..Params::default()
/// };
///
/// let encoded = params.encode();
/// assert_eq!(Params::decode(&encoded).unwrap(), params);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params { pub struct Params {
pub protocol_version: i64, pub protocol_version: i64,
@@ -88,6 +111,20 @@ impl Params {
/// Presence means the tag appeared at least once. Repeated known tags keep /// Presence means the tag appeared at least once. Repeated known tags keep
/// last-value-wins, and unknown tags remain ignored and untracked. /// last-value-wins, and unknown tags remain ignored and untracked.
/// ///
/// # Example
///
/// ```
/// use irtt_proto::Params;
///
/// // Wire bytes: tag 2 (duration) carrying an explicit zero. Interval
/// // (tag 3) is absent, so both fields decode to zero but only one is
/// // reported present.
/// let decoded = Params::decode_with_presence(&[0x02, 0x00]).unwrap();
/// assert_eq!(decoded.params.duration_ns, 0);
/// assert!(decoded.presence.duration_ns);
/// assert!(!decoded.presence.interval_ns);
/// ```
///
/// [`decode`]: Params::decode /// [`decode`]: Params::decode
pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> { pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> {
let mut params = Self::default(); let mut params = Self::default();
+35
View File
@@ -88,6 +88,26 @@ pub enum DecodedRequestKind<'a> {
/// the negotiated length is positive and wider than `usize`. A *negative* /// the negotiated length is positive and wider than `usize`. A *negative*
/// negotiated length is not an error: see [`echo_packet_len`], which floors it /// negotiated length is not an error: see [`echo_packet_len`], which floors it
/// at the mandatory field block. /// at the mandatory field block.
///
/// # Example
///
/// ```
/// use irtt_proto::{encode_request, Params, RequestToEncode};
///
/// // An unauthenticated ECHO probe. `params` supplies the negotiated length,
/// // which reserves four payload bytes beyond the 16-byte echo header.
/// let params = Params { length: 20, ..Params::default() };
/// let packet = encode_request(
/// RequestToEncode::Echo {
/// token: 0x1234_5678_9abc_def0,
/// sequence: 7,
/// params: &params,
/// payload: &[1, 2, 3, 4],
/// },
/// None,
/// ).unwrap();
/// assert_eq!(packet.len(), 20);
/// ```
pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> { pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
match request { match request {
RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key), RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key),
@@ -138,6 +158,21 @@ pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) ->
/// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`], /// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`],
/// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for /// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for
/// `FLAG_REPLY`. /// `FLAG_REPLY`.
///
/// # Example
///
/// ```
/// use irtt_proto::{decode_request, encode_request, DecodedRequestKind, RequestToEncode};
///
/// let packet = encode_request(
/// RequestToEncode::Close { token: 0x1234_5678_9abc_def0 },
/// None,
/// ).unwrap();
///
/// let request = decode_request(&packet).unwrap();
/// assert!(!request.hmac_present);
/// assert_eq!(request.kind, DecodedRequestKind::Close { token: 0x1234_5678_9abc_def0 });
/// ```
pub fn decode_request(packet: &[u8]) -> Result<DecodedRequest<'_>> { pub fn decode_request(packet: &[u8]) -> Result<DecodedRequest<'_>> {
let envelope = envelope::decode_structural(packet)?; let envelope = envelope::decode_structural(packet)?;
if has(envelope.flags, FLAG_REPLY) { if has(envelope.flags, FLAG_REPLY) {
+16
View File
@@ -1,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}; use crate::{ProtoError, Result};
pub fn encode_uvarint(mut value: u64, out: &mut Vec<u8>) { pub fn encode_uvarint(mut value: u64, out: &mut Vec<u8>) {
+8 -7
View File
@@ -500,13 +500,14 @@ session policy lives.
unmarked reply into a *dropped* one, because an unappliable marking stops the unmarked reply into a *dropped* one, because an unappliable marking stops the
send. Verify a platform before extending that list. send. Verify a platform before extending that list.
- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped - A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped
replies, and this is known. It is not fixed by choosing the option per reply, replies on Linux. When reply DSCP/traffic-class marking for IPv4 peers
for the macOS reason above, and it predates wildcard source selection. Leave it matters, configure the normal same-port `[::]:PORT` plus `0.0.0.0:PORT`
until it is worth its own slice, and do not "fix" it by weakening the rule that listener pair instead; `ServerSet` makes the IPv6 half IPv6-only for that
an unappliable marking stops the send. The crate forbids unsafe code; pair. Do not force `IPV6_V6ONLY` on every lone IPv6 listener, and do not
there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. Keep the weaken the rule that an unappliable marking stops the send. The crate forbids
target lists identical to the pinned `socket2` version's own gates — check unsafe code; there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery.
its source rather than copying another crate's older matrix. Keep the target lists identical to the pinned `socket2` version's own gates
— check its source rather than copying another crate's older matrix.
## Rate, lifetime and server-initiated close ## Rate, lifetime and server-initiated close
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-server" name = "irtt-server"
version = "0.5.2" version = "0.5.3"
description = "IRTT-compatible Rust server library" description = "IRTT-compatible Rust server library"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -15,7 +15,7 @@ categories = ["network-programming"]
irtt-proto.workspace = true irtt-proto.workspace = true
getrandom = "0.4.3" getrandom = "0.4.3"
socket2 = { version = "0.6.5", features = ["all"] } socket2 = { version = "0.6.5", features = ["all"] }
thiserror = "2.0.20" thiserror = "2.0.21"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] } tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
# Wildcard listeners recover each request's local destination address and send # Wildcard listeners recover each request's local destination address and send
+22
View File
@@ -98,6 +98,28 @@ pub enum TimestampAllowance {
/// [`max_packet_length`](Self::max_packet_length) like every other reply, and /// [`max_packet_length`](Self::max_packet_length) like every other reply, and
/// `none` is zero-filled here rather than left as residue. An operator /// `none` is zero-filled here rather than left as residue. An operator
/// restriction can be added if a real use case turns up. /// restriction can be added if a real use case turns up.
///
/// # Example
///
/// Configure a modest per-listener session budget and restrict timestamp
/// placement while leaving DSCP marking disabled:
///
/// ```
/// use std::time::Duration;
///
/// use irtt_server::{ServerConfig, TimestampAllowance};
///
/// let config = ServerConfig::default()
/// .with_max_sessions(128)
/// .with_idle_timeout(Duration::from_secs(30))
/// .with_timestamp_allowance(TimestampAllowance::Single)
/// .with_dscp_allowed(false);
///
/// assert_eq!(config.max_sessions(), 128);
/// assert_eq!(config.idle_timeout(), Duration::from_secs(30));
/// assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
/// assert!(!config.dscp_allowed());
/// ```
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig { pub struct ServerConfig {
hmac_key: Option<Vec<u8>>, hmac_key: Option<Vec<u8>>,
+26
View File
@@ -47,6 +47,32 @@ use crate::{
/// a transport abstraction, and there is no blocking or alternate-runtime /// a transport abstraction, and there is no blocking or alternate-runtime
/// counterpart. /// counterpart.
/// ///
/// # Example
///
/// The core can be driven directly with a normally encoded open request. It
/// returns a reply to send and records the new session without performing I/O:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode};
/// use irtt_server::{ServerConfig, ServerCore};
///
/// let peer = SocketAddr::from((Ipv4Addr::LOCALHOST, 40_000));
/// let mut core = ServerCore::new(ServerConfig::default());
/// let params = Params::with_protocol_defaults();
/// let request = encode_request(
/// RequestToEncode::Open { params: &params, no_test: false },
/// None,
/// )
/// .unwrap();
///
/// let reply = core.handle_datagram(peer, &request).unwrap().unwrap();
/// let open = decode_open_reply(reply.bytes(), None).unwrap();
/// assert_ne!(open.token, 0);
/// assert_eq!(core.session_count(), 1);
/// ```
///
/// # Scope /// # Scope
/// ///
/// The core implements open handling, session creation and negotiation, /// The core implements open handling, session creation and negotiation,
+22
View File
@@ -53,6 +53,28 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(1);
/// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than /// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than
/// served by a listener whose replies a client on a second address would /// served by a listener whose replies a client on a second address would
/// silently discard. Explicit-address listeners are unaffected everywhere. /// silently discard. Explicit-address listeners are unaffected everywhere.
///
/// # Example
///
/// Bind one listener, inspect the resolved endpoint, and let the caller decide
/// when serving ends. This example type-checks without opening a socket:
///
/// ```no_run
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
///
/// use irtt_server::{Server, ServerConfig};
///
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// let mut server = Server::bind(
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// ServerConfig::default(),
/// )
/// .await?;
/// println!("listening on {}", server.local_addr()?);
/// server.run(tokio::time::sleep(Duration::from_secs(60))).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct Server { pub struct Server {
socket: UdpSocket, socket: UdpSocket,
+31
View File
@@ -57,6 +57,29 @@ use crate::{Server, ServerConfig, ServerRuntimeError};
/// whole set: a service configured for IPv4 and IPv6 must not quietly continue /// whole set: a service configured for IPv4 and IPv6 must not quietly continue
/// as IPv4 only. Every listener task is joined before `run` returns, so no task /// as IPv4 only. Every listener task is joined before `run` returns, so no task
/// outlives it. /// outlives it.
///
/// # Example
///
/// Bind independent listeners, inspect their resolved addresses, then serve
/// them under one caller-owned shutdown future. This example type-checks
/// without binding sockets:
///
/// ```no_run
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
///
/// use irtt_server::{ServerConfig, ServerSet};
///
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// let addrs = [
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// ];
/// let set = ServerSet::bind(addrs, ServerConfig::default()).await?;
/// println!("listening on {:?}", set.local_addrs());
/// set.run(tokio::time::sleep(Duration::from_secs(60))).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)] #[derive(Debug)]
pub struct ServerSet { pub struct ServerSet {
servers: Vec<Server>, servers: Vec<Server>,
@@ -91,6 +114,14 @@ impl ServerSet {
/// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener /// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener
/// here, not an IPv6 one, matching how the runtime already reads it. /// here, not an IPv6 one, matching how the runtime already reads it.
/// ///
/// A lone explicit dual-stack `[::]:PORT` listener is not the recommended
/// configuration when reply DSCP/traffic-class marking for IPv4 peers
/// matters: Linux serves those peers as IPv4-mapped and cannot retain the
/// negotiated marking on their replies. Request the normal same-port
/// `[::]:PORT` and `0.0.0.0:PORT` pair instead. This method makes the IPv6
/// half IPv6-only for that pair only; a lone IPv6 listener retains its
/// normal socket behavior.
///
/// # Errors /// # Errors
/// ///
/// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set /// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-stats" name = "irtt-stats"
version = "0.5.0" version = "0.5.1"
description = "Statistics aggregation for irtt-client events" description = "Statistics aggregation for irtt-client events"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -12,6 +12,7 @@ keywords = ["irtt", "networking", "latency", "statistics"]
categories = ["network-programming"] categories = ["network-programming"]
[dependencies] [dependencies]
measurement-stats = "0.1.0"
irtt-client.workspace = true irtt-client.workspace = true
[[example]] [[example]]
+14
View File
@@ -10,6 +10,11 @@ This crate does not open sessions or drive sockets. Feed it the `ClientEvent`
stream produced by an `irtt-client` `Client`, `AsyncClient`, or managed stream produced by an `irtt-client` `Client`, `AsyncClient`, or managed
session via `StatsCollector::process`, and read back a `Snapshot`. session via `StatsCollector::process`, and read back a `Snapshot`.
Generic timing accumulation and count-window storage come from
`measurement-stats`. `TimeStats` remains available from `irtt_stats` as a
re-export. Event interpretation, IPDV pairing, loss, and snapshot reconstruction
remain here.
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
scoped to a single target. A managed session covering multiple targets needs scoped to a single target. A managed session covering multiple targets needs
one collector per target — as the `irtt-rs` CLI does — since a target's one collector per target — as the `irtt-rs` CLI does — since a target's
@@ -26,6 +31,15 @@ bounded running statistics instead (no exact median) plus a bounded
unbounded sessions. Rolling-window snapshots are always running-only and unbounded sessions. Rolling-window snapshots are always running-only and
report no medians, whichever `StatsConfig` produced them. report no medians, whichever `StatsConfig` produced them.
An enabled time-based rolling window has no event-count cap, including in
continuous mode. It expires only on new events, anchored at their timestamps;
reading a snapshot does not advance time. Use count-based rolling storage for a
hard bound on retained events. The memory estimate excludes time-window storage.
Exact snapshots sort a temporary copy of each metric's retained samples, adding
O(n) scratch memory and O(n log n) computation for that metric. The retention
estimate is not peak snapshot or allocator accounting.
See `examples/` in the repository for a runnable comparison of both modes. See `examples/` in the repository for a runnable comparison of both modes.
## Documentation ## Documentation
@@ -58,11 +58,11 @@ fn synthetic_probe_stream(count: u32) -> Vec<ClientEvent> {
events.push(ClientEvent::EchoSent { events.push(ClientEvent::EchoSent {
seq, seq,
remote, remote,
scheduled_at: sent_at.mono, scheduled_at: Some(sent_at.mono),
sent_at, sent_at,
bytes: 64, bytes: 64,
send_call: Duration::from_micros(50), send_call: Duration::from_micros(50),
timer_error: Duration::from_micros(20), timer_error: Some(Duration::from_micros(20)),
}); });
if seq == count - 1 { if seq == count - 1 {
+44 -36
View File
@@ -1,9 +1,10 @@
use std::time::Duration; use std::time::Duration;
use measurement_stats::TimeAccumulator;
use crate::ipdv::{IpdvSample, IpdvTracker}; use crate::ipdv::{IpdvSample, IpdvTracker};
use crate::loss::loss_stats; use crate::loss::loss_stats;
use crate::normalization::{ReplySample, StatsEvent}; use crate::normalization::{ReplySample, StatsEvent};
use crate::time_stats::TimeMetric;
use crate::{ use crate::{
EventCounts, EventStatsUpdate, IpdvPairUpdate, IpdvStats, LateReplyMode, OneWayDelayStats, EventCounts, EventStatsUpdate, IpdvPairUpdate, IpdvStats, LateReplyMode, OneWayDelayStats,
PacketCounts, RttStats, SampleMode, ServerProcessingStats, Snapshot, PacketCounts, RttStats, SampleMode, ServerProcessingStats, Snapshot,
@@ -15,17 +16,17 @@ pub(crate) const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096;
pub(crate) struct CoreStats { pub(crate) struct CoreStats {
events: EventCounts, events: EventCounts,
packets: PacketCounts, packets: PacketCounts,
send_call: TimeMetric, send_call: TimeAccumulator,
timer_error: TimeMetric, timer_error: TimeAccumulator,
rtt_primary: TimeMetric, rtt_primary: TimeAccumulator,
rtt_raw: TimeMetric, rtt_raw: TimeAccumulator,
rtt_adjusted: TimeMetric, rtt_adjusted: TimeAccumulator,
ipdv_round_trip: TimeMetric, ipdv_round_trip: TimeAccumulator,
ipdv_send: TimeMetric, ipdv_send: TimeAccumulator,
ipdv_receive: TimeMetric, ipdv_receive: TimeAccumulator,
send_delay: TimeMetric, send_delay: TimeAccumulator,
receive_delay: TimeMetric, receive_delay: TimeAccumulator,
server_processing: TimeMetric, server_processing: TimeAccumulator,
ipdv_tracker: IpdvTracker, ipdv_tracker: IpdvTracker,
late_replies: LateReplyMode, late_replies: LateReplyMode,
} }
@@ -42,20 +43,25 @@ impl CoreStats {
Some(CONTINUOUS_SEQUENCE_LIMIT) Some(CONTINUOUS_SEQUENCE_LIMIT)
}; };
let metric = || match sample_mode {
SampleMode::Exact => TimeAccumulator::exact_unbounded(),
SampleMode::RunningOnly => TimeAccumulator::running(),
};
Self { Self {
events: EventCounts::default(), events: EventCounts::default(),
packets: PacketCounts::default(), packets: PacketCounts::default(),
send_call: TimeMetric::new(retain_exact), send_call: metric(),
timer_error: TimeMetric::new(retain_exact), timer_error: metric(),
rtt_primary: TimeMetric::new(retain_exact), rtt_primary: metric(),
rtt_raw: TimeMetric::new(retain_exact), rtt_raw: metric(),
rtt_adjusted: TimeMetric::new(retain_exact), rtt_adjusted: metric(),
ipdv_round_trip: TimeMetric::new(retain_exact), ipdv_round_trip: metric(),
ipdv_send: TimeMetric::new(retain_exact), ipdv_send: metric(),
ipdv_receive: TimeMetric::new(retain_exact), ipdv_receive: metric(),
send_delay: TimeMetric::new(retain_exact), send_delay: metric(),
receive_delay: TimeMetric::new(retain_exact), receive_delay: metric(),
server_processing: TimeMetric::new(retain_exact), server_processing: metric(),
ipdv_tracker: IpdvTracker::new(sequence_limit), ipdv_tracker: IpdvTracker::new(sequence_limit),
late_replies, late_replies,
} }
@@ -94,12 +100,14 @@ impl CoreStats {
} }
} }
fn apply_sent(&mut self, bytes: usize, send_call_ns: i128, timer_error_ns: i128) { fn apply_sent(&mut self, bytes: usize, send_call_ns: i128, timer_error_ns: Option<i128>) {
self.events.sent_events += 1; self.events.sent_events += 1;
self.packets.packets_sent += 1; self.packets.packets_sent += 1;
self.packets.bytes_sent = self.packets.bytes_sent.saturating_add(bytes as u64); self.packets.bytes_sent = self.packets.bytes_sent.saturating_add(bytes as u64);
self.send_call.push_ns(send_call_ns); self.send_call.push_ns(send_call_ns);
self.timer_error.push_ns(timer_error_ns); if let Some(error) = timer_error_ns {
self.timer_error.push_ns(error);
}
} }
fn apply_unique_reply(&mut self, is_late: bool, sample: ReplySample) -> EventStatsUpdate { fn apply_unique_reply(&mut self, is_late: bool, sample: ReplySample) -> EventStatsUpdate {
@@ -227,8 +235,8 @@ impl CoreStats {
events: self.events, events: self.events,
packets, packets,
loss: loss_stats(packets), loss: loss_stats(packets),
send_call: self.send_call.stats(), send_call: self.send_call.snapshot(),
timer_error: self.timer_error.stats(), timer_error: self.timer_error.snapshot(),
rtt: self.rtt_stats(), rtt: self.rtt_stats(),
ipdv: self.ipdv_stats(), ipdv: self.ipdv_stats(),
one_way_delay: self.one_way_delay_stats(), one_way_delay: self.one_way_delay_stats(),
@@ -238,30 +246,30 @@ impl CoreStats {
fn rtt_stats(&self) -> RttStats { fn rtt_stats(&self) -> RttStats {
RttStats { RttStats {
primary: self.rtt_primary.stats(), primary: self.rtt_primary.snapshot(),
raw: self.rtt_raw.stats(), raw: self.rtt_raw.snapshot(),
adjusted: self.rtt_adjusted.stats(), adjusted: self.rtt_adjusted.snapshot(),
} }
} }
fn ipdv_stats(&self) -> IpdvStats { fn ipdv_stats(&self) -> IpdvStats {
IpdvStats { IpdvStats {
round_trip: self.ipdv_round_trip.stats(), round_trip: self.ipdv_round_trip.snapshot(),
send: self.ipdv_send.stats(), send: self.ipdv_send.snapshot(),
receive: self.ipdv_receive.stats(), receive: self.ipdv_receive.snapshot(),
} }
} }
fn one_way_delay_stats(&self) -> OneWayDelayStats { fn one_way_delay_stats(&self) -> OneWayDelayStats {
OneWayDelayStats { OneWayDelayStats {
send_delay: self.send_delay.stats(), send_delay: self.send_delay.snapshot(),
receive_delay: self.receive_delay.stats(), receive_delay: self.receive_delay.snapshot(),
} }
} }
fn server_processing_stats(&self) -> ServerProcessingStats { fn server_processing_stats(&self) -> ServerProcessingStats {
ServerProcessingStats { ServerProcessingStats {
processing: self.server_processing.stats(), processing: self.server_processing.snapshot(),
} }
} }
} }
+257 -5
View File
@@ -15,8 +15,9 @@
//! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only //! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only
//! running statistics — no exact median — and bounds its adjacent-sequence //! running statistics — no exact median — and bounds its adjacent-sequence
//! IPDV tracking, so memory stays bounded for a long-running or unbounded //! IPDV tracking, so memory stays bounded for a long-running or unbounded
//! session. Rolling snapshots always use running statistics regardless of //! session. An enabled time-based rolling window has no item cap and is not
//! this setting. //! hard-bounded; it expires only when new events arrive. Rolling snapshots
//! always use running statistics regardless of this setting.
//! //!
//! See `examples/` in the repository for a runnable comparison of both modes. //! See `examples/` in the repository for a runnable comparison of both modes.
@@ -34,13 +35,13 @@ mod loss;
mod normalization; mod normalization;
mod retention; mod retention;
mod rolling; mod rolling;
mod time_stats;
use core::CoreStats; use core::CoreStats;
pub use loss::LossStats; pub use loss::LossStats;
#[doc(inline)]
pub use measurement_stats::TimeStats;
use normalization::normalize_event; use normalization::normalize_event;
use rolling::RollingEvents; use rolling::RollingEvents;
pub use time_stats::TimeStats;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Configuration for statistics collection. /// Configuration for statistics collection.
@@ -54,6 +55,10 @@ pub struct StatsConfig {
/// and one unique reply event. /// and one unique reply event.
pub rolling_count: Option<usize>, pub rolling_count: Option<usize>,
/// Time span of recent normalized events retained for time-based rolling snapshots. /// Time span of recent normalized events retained for time-based rolling snapshots.
///
/// This window has no item cap, even in continuous mode. Its memory depends
/// on event volume inside the interval. Expiry occurs on insertion, anchored
/// at the latest event timestamp, not when a snapshot is read.
pub rolling_time: Option<Duration>, pub rolling_time: Option<Duration>,
/// Whether matched late replies contribute measurements. /// Whether matched late replies contribute measurements.
pub late_replies: LateReplyMode, pub late_replies: LateReplyMode,
@@ -66,6 +71,44 @@ impl StatsConfig {
/// them reports a median in the cumulative snapshot once it has samples, /// them reports a median in the cumulative snapshot once it has samples,
/// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent /// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent
/// replies can still complete IPDV pairs. /// replies can still complete IPDV pairs.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::finite());
/// for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
/// collector.process(&sent(seq as u32, seq as u64 * 10, send_call_us));
/// }
///
/// let snapshot = collector.snapshot();
/// // Finite mode retains exact samples, so the send-call metric reports an
/// // exact median once it has samples: the middle of the sorted
/// // 10, 20, 30 µs values, not their mean.
/// assert_eq!(snapshot.send_call.count, 3);
/// assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
/// ```
pub fn finite() -> Self { pub fn finite() -> Self {
Self { Self {
samples: SampleMode::Exact, samples: SampleMode::Exact,
@@ -79,7 +122,46 @@ impl StatsConfig {
/// ///
/// Continuous mode uses running statistics, retains no exact samples so no /// Continuous mode uses running statistics, retains no exact samples so no
/// timing metric reports a median, and bounds adjacent-sequence IPDV /// timing metric reports a median, and bounds adjacent-sequence IPDV
/// tracking for long-running sessions. /// tracking for long-running sessions. An explicitly enabled `rolling_time`
/// window is not hard-bounded; use `rolling_count` for bounded event storage.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::continuous());
/// for seq in 0..3 {
/// collector.process(&sent(seq, u64::from(seq) * 10, 10));
/// }
///
/// let snapshot = collector.snapshot();
/// // Continuous mode still tracks running statistics — count, mean, min,
/// // and max — but retains no exact samples, so it reports no median.
/// assert_eq!(snapshot.send_call.count, 3);
/// assert!(snapshot.send_call.mean_ns > 0.0);
/// assert_eq!(snapshot.send_call.median_ns, None);
/// ```
pub fn continuous() -> Self { pub fn continuous() -> Self {
Self { Self {
samples: SampleMode::RunningOnly, samples: SampleMode::RunningOnly,
@@ -135,6 +217,34 @@ impl StatsConfig {
/// The result is deterministic and computed with saturating arithmetic, so /// The result is deterministic and computed with saturating arithmetic, so
/// an enormous `probe_count` saturates at [`u64::MAX`] rather than /// an enormous `probe_count` saturates at [`u64::MAX`] rather than
/// wrapping or panicking. /// wrapping or panicking.
///
/// # Example
///
/// ```
/// use irtt_stats::StatsConfig;
///
/// // Finite retention grows with the probe count...
/// let finite = StatsConfig::finite();
/// assert!(
/// finite.estimated_retained_bytes(1_000_000)
/// > finite.estimated_retained_bytes(1_000)
/// );
///
/// // ...while continuous retention is bounded, so a far longer run is no
/// // more expensive.
/// let continuous = StatsConfig::continuous();
/// assert_eq!(
/// continuous.estimated_retained_bytes(1_000_000),
/// continuous.estimated_retained_bytes(10_000_000),
/// );
/// assert!(
/// continuous.estimated_retained_bytes(1_000_000)
/// < finite.estimated_retained_bytes(1_000_000)
/// );
///
/// // Enormous probe counts saturate rather than wrapping or panicking.
/// assert_eq!(finite.estimated_retained_bytes(u64::MAX), u64::MAX);
/// ```
pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 { pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 {
retention::estimated_retained_bytes(self, probe_count) retention::estimated_retained_bytes(self, probe_count)
} }
@@ -192,6 +302,60 @@ pub enum SampleMode {
/// ///
/// A collector maintains cumulative statistics and, when configured, rolling /// A collector maintains cumulative statistics and, when configured, rolling
/// windows. Rolling snapshots are recomputed from retained normalized events. /// windows. Rolling snapshots are recomputed from retained normalized events.
///
/// # Example
///
/// Feed a short stream of `irtt-client` events to a finite-mode collector and
/// read back a cumulative snapshot:
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp, RttSample, SignedDuration};
/// #
/// # /// A synthetic reply; a real session delivers these as `ClientEvent`s.
/// # fn reply(seq: u32, rtt_ms: u64) -> ClientEvent {
/// # let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(u64::from(seq) * 10),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(u64::from(seq) * 10),
/// # };
/// # let raw = Duration::from_millis(rtt_ms);
/// # ClientEvent::EchoReply {
/// # seq,
/// # remote,
/// # sent_at,
/// # received_at: ClientTimestamp {
/// # mono: sent_at.mono + raw,
/// # wall: sent_at.wall + raw,
/// # },
/// # rtt: RttSample {
/// # raw,
/// # adjusted: None,
/// # effective: SignedDuration::from_duration(raw),
/// # },
/// # server_timing: None,
/// # one_way: None,
/// # received_stats: None,
/// # bytes: 64,
/// # packet_meta: irtt_client::PacketMeta::default(),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::finite());
/// for (seq, rtt_ms) in [10_u64, 30, 20].into_iter().enumerate() {
/// collector.process(&reply(seq as u32, rtt_ms));
/// }
///
/// let snapshot = collector.snapshot();
/// assert_eq!(snapshot.events.echo_replies, 3);
/// assert_eq!(snapshot.rtt.primary.count, 3);
/// // Finite mode retains exact samples, so the round-trip metric reports an
/// // exact median: the middle of the sorted 10, 20, 30 ms samples.
/// assert_eq!(snapshot.rtt.primary.median_ns, Some(20_000_000.0));
/// ```
pub struct StatsCollector { pub struct StatsCollector {
cumulative: CoreStats, cumulative: CoreStats,
rolling: RollingEvents, rolling: RollingEvents,
@@ -236,6 +400,53 @@ impl StatsCollector {
/// running statistics only, whatever [`StatsConfig::samples`] is set to, /// running statistics only, whatever [`StatsConfig::samples`] is set to,
/// so its timing metrics report no median. A rolling window is a bounded /// so its timing metrics report no median. A rolling window is a bounded
/// recent view rather than the run's retained history. /// recent view rather than the run's retained history.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig {
/// rolling_count: Some(2),
/// ..StatsConfig::finite()
/// });
/// collector.process(&sent(0, 0, 10));
/// collector.process(&sent(1, 10, 10));
/// collector.process(&sent(2, 20, 10));
///
/// // The window keeps only the last two normalized events...
/// let rolling = collector.rolling_count().unwrap();
/// assert_eq!(rolling.packets.packets_sent, 2);
/// // ...and is recomputed with running statistics, so it reports no median
/// // even though the collector retains exact samples for its cumulative
/// // snapshot.
/// assert_eq!(rolling.send_call.median_ns, None);
///
/// // The cumulative snapshot still sees every event.
/// let cumulative = collector.snapshot();
/// assert_eq!(cumulative.packets.packets_sent, 3);
/// assert_eq!(cumulative.send_call.median_ns, Some(10_000.0));
/// ```
pub fn rolling_count(&self) -> Option<Snapshot> { pub fn rolling_count(&self) -> Option<Snapshot> {
self.rolling.count_snapshot() self.rolling.count_snapshot()
} }
@@ -244,6 +455,47 @@ impl StatsCollector {
/// ///
/// Like [`StatsCollector::rolling_count`], this is recomputed with running /// Like [`StatsCollector::rolling_count`], this is recomputed with running
/// statistics only and reports no medians. /// statistics only and reports no medians.
///
/// # Example
///
/// ```
/// use std::time::Duration;
///
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::Instant;
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: Some(sent_at.mono),
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Some(Duration::from_micros(2)),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig {
/// rolling_time: Some(Duration::from_millis(15)),
/// ..StatsConfig::finite()
/// });
/// collector.process(&sent(0, 0, 10));
/// collector.process(&sent(1, 10, 10));
/// collector.process(&sent(2, 30, 10));
///
/// // A 15 ms window anchored at the latest event (30 ms) retains only the
/// // events from the last 15 ms — here just the final send.
/// let rolling = collector.rolling_time().unwrap();
/// assert_eq!(rolling.packets.packets_sent, 1);
/// ```
pub fn rolling_time(&self) -> Option<Snapshot> { pub fn rolling_time(&self) -> Option<Snapshot> {
self.rolling.time_snapshot() self.rolling.time_snapshot()
} }
+2 -2
View File
@@ -13,7 +13,7 @@ pub(crate) enum StatsEvent {
at: Instant, at: Instant,
bytes: usize, bytes: usize,
send_call_ns: i128, send_call_ns: i128,
timer_error_ns: i128, timer_error_ns: Option<i128>,
}, },
UniqueReply { UniqueReply {
at: Instant, at: Instant,
@@ -48,7 +48,7 @@ pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
at: sent_at.mono, at: sent_at.mono,
bytes: *bytes, bytes: *bytes,
send_call_ns: duration_ns_i128(*send_call), send_call_ns: duration_ns_i128(*send_call),
timer_error_ns: duration_ns_i128(*timer_error), timer_error_ns: timer_error.map(duration_ns_i128),
}), }),
ClientEvent::EchoReply { ClientEvent::EchoReply {
seq, seq,
+2 -2
View File
@@ -146,11 +146,11 @@ mod tests {
ClientEvent::EchoSent { ClientEvent::EchoSent {
seq, seq,
remote: "127.0.0.1:2112".parse().unwrap(), remote: "127.0.0.1:2112".parse().unwrap(),
scheduled_at: sent_at.mono, scheduled_at: Some(sent_at.mono),
sent_at, sent_at,
bytes: 32, bytes: 32,
send_call: Duration::from_micros(10 + u64::from(seq)), send_call: Duration::from_micros(10 + u64::from(seq)),
timer_error: Duration::from_micros(2 + u64::from(seq)), timer_error: Some(Duration::from_micros(2 + u64::from(seq))),
} }
} }
+12 -12
View File
@@ -1,14 +1,15 @@
use std::{collections::VecDeque, time::Duration}; use std::{collections::VecDeque, time::Duration};
use measurement_stats::CountWindow;
use crate::{ use crate::{
core::CoreStats, normalization::StatsEvent, LateReplyMode, SampleMode, Snapshot, StatsConfig, core::CoreStats, normalization::StatsEvent, LateReplyMode, SampleMode, Snapshot, StatsConfig,
}; };
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub(crate) struct RollingEvents { pub(crate) struct RollingEvents {
count_limit: Option<usize>,
time_limit: Option<Duration>, time_limit: Option<Duration>,
count_events: Option<VecDeque<StatsEvent>>, count_events: Option<CountWindow<StatsEvent>>,
time_events: Option<VecDeque<StatsEvent>>, time_events: Option<VecDeque<StatsEvent>>,
late_replies: LateReplyMode, late_replies: LateReplyMode,
} }
@@ -16,20 +17,16 @@ pub(crate) struct RollingEvents {
impl RollingEvents { impl RollingEvents {
pub(crate) fn new(config: StatsConfig) -> Self { pub(crate) fn new(config: StatsConfig) -> Self {
Self { Self {
count_limit: config.rolling_count,
time_limit: config.rolling_time, time_limit: config.rolling_time,
count_events: config.rolling_count.map(|_| VecDeque::new()), count_events: config.rolling_count.map(CountWindow::new),
time_events: config.rolling_time.map(|_| VecDeque::new()), time_events: config.rolling_time.map(|_| VecDeque::new()),
late_replies: config.late_replies, late_replies: config.late_replies,
} }
} }
pub(crate) fn push(&mut self, event: StatsEvent) { pub(crate) fn push(&mut self, event: StatsEvent) {
if let (Some(limit), Some(window)) = (self.count_limit, self.count_events.as_mut()) { if let Some(window) = self.count_events.as_mut() {
window.push_back(event.clone()); window.push(event.clone());
while window.len() > limit {
window.pop_front();
}
} }
if let (Some(duration), Some(window)) = (self.time_limit, self.time_events.as_mut()) { if let (Some(duration), Some(window)) = (self.time_limit, self.time_events.as_mut()) {
@@ -46,19 +43,22 @@ impl RollingEvents {
pub(crate) fn count_snapshot(&self) -> Option<Snapshot> { pub(crate) fn count_snapshot(&self) -> Option<Snapshot> {
self.count_events self.count_events
.as_ref() .as_ref()
.map(|events| snapshot_window(events, self.late_replies)) .map(|events| snapshot_window(events.iter(), self.late_replies))
} }
pub(crate) fn time_snapshot(&self) -> Option<Snapshot> { pub(crate) fn time_snapshot(&self) -> Option<Snapshot> {
self.time_events self.time_events
.as_ref() .as_ref()
.map(|events| snapshot_window(events, self.late_replies)) .map(|events| snapshot_window(events.iter(), self.late_replies))
} }
} }
/// Recompute a window snapshot under the same normalized semantics as the /// Recompute a window snapshot under the same normalized semantics as the
/// cumulative collector, including its late-reply measurement policy. /// cumulative collector, including its late-reply measurement policy.
fn snapshot_window(events: &VecDeque<StatsEvent>, late_replies: LateReplyMode) -> Snapshot { fn snapshot_window<'a>(
events: impl Iterator<Item = &'a StatsEvent>,
late_replies: LateReplyMode,
) -> Snapshot {
let mut core = CoreStats::new(SampleMode::RunningOnly, late_replies); let mut core = CoreStats::new(SampleMode::RunningOnly, late_replies);
for event in events { for event in events {
core.apply(event.clone()); core.apply(event.clone());
-160
View File
@@ -1,160 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq)]
/// Summary statistics for timing values.
///
/// All fields use nanoseconds except `variance_ns2`, which is nanoseconds
/// squared. Values may represent signed timing quantities such as scheduling
/// error or one-way delay.
pub struct TimeStats {
/// Number of samples included in this summary.
pub count: u64,
/// Sum of all samples, in nanoseconds.
pub total_ns: i128,
/// Smallest sample, in nanoseconds.
pub min_ns: Option<i128>,
/// Largest sample, in nanoseconds.
pub max_ns: Option<i128>,
/// Arithmetic mean, in nanoseconds.
pub mean_ns: f64,
/// Median, in nanoseconds, when exact samples were retained.
///
/// This is `None` when the collector was not configured to retain exact
/// samples, and also when it was but this metric received none.
pub median_ns: Option<f64>,
/// Sample variance, in nanoseconds squared.
pub variance_ns2: f64,
}
impl TimeStats {
/// Returns the sample standard deviation, in nanoseconds.
pub fn stddev_ns(&self) -> f64 {
self.variance_ns2.sqrt()
}
/// Returns whether this summary contains no samples.
pub fn is_empty(&self) -> bool {
self.count == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TimeMetric {
running: RunningStats,
samples: Option<Vec<i128>>,
}
impl TimeMetric {
pub(crate) fn new(retain_samples: bool) -> Self {
Self {
running: RunningStats::default(),
samples: retain_samples.then(Vec::new),
}
}
pub(crate) fn push_ns(&mut self, value: i128) {
self.running.push(value);
if let Some(samples) = self.samples.as_mut() {
samples.push(value);
}
}
pub(crate) fn stats(&self) -> TimeStats {
self.running
.stats(self.samples.as_ref().and_then(|samples| median_ns(samples)))
}
}
#[derive(Debug, Clone, PartialEq, Default)]
struct RunningStats {
count: u64,
total_ns: i128,
min_ns: Option<i128>,
max_ns: Option<i128>,
mean_ns: f64,
m2_ns2: f64,
}
impl RunningStats {
fn push(&mut self, value: i128) {
self.count += 1;
self.total_ns = self.total_ns.saturating_add(value);
self.min_ns = Some(self.min_ns.map_or(value, |min| min.min(value)));
self.max_ns = Some(self.max_ns.map_or(value, |max| max.max(value)));
let x = value as f64;
let delta = x - self.mean_ns;
self.mean_ns += delta / self.count as f64;
let delta2 = x - self.mean_ns;
self.m2_ns2 += delta * delta2;
}
fn stats(&self, median_ns: Option<f64>) -> TimeStats {
TimeStats {
count: self.count,
total_ns: self.total_ns,
min_ns: self.min_ns,
max_ns: self.max_ns,
mean_ns: if self.count == 0 { 0.0 } else { self.mean_ns },
median_ns,
variance_ns2: sample_variance(self.count, self.m2_ns2),
}
}
}
fn sample_variance(count: u64, m2: f64) -> f64 {
if count < 2 {
0.0
} else {
m2 / (count - 1) as f64
}
}
fn median_ns(samples: &[i128]) -> Option<f64> {
if samples.is_empty() {
return None;
}
let mut sorted = samples.to_vec();
sorted.sort_unstable();
let mid = sorted.len() / 2;
Some(if sorted.len() % 2 == 1 {
sorted[mid] as f64
} else {
(sorted[mid - 1] as f64 + sorted[mid] as f64) / 2.0
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn running_duration_stats_use_sample_variance() {
let mut metric = TimeMetric::new(false);
metric.push_ns(1);
metric.push_ns(2);
metric.push_ns(3);
let stats = metric.stats();
assert_eq!(stats.count, 3);
assert_eq!(stats.total_ns, 6);
assert_eq!(stats.min_ns, Some(1));
assert_eq!(stats.max_ns, Some(3));
assert_eq!(stats.mean_ns, 2.0);
assert_eq!(stats.variance_ns2, 1.0);
assert_eq!(stats.stddev_ns(), 1.0);
}
#[test]
fn exact_median_handles_odd_and_even_samples() {
assert_eq!(median_ns(&[3, 1, 2]), Some(2.0));
assert_eq!(median_ns(&[4, 1, 2, 3]), Some(2.5));
assert_eq!(median_ns(&[-5, 1, 3]), Some(1.0));
assert_eq!(median_ns(&[-5, 1, 3, 7]), Some(2.0));
}
#[test]
fn single_sample_stddev_is_zero() {
let mut metric = TimeMetric::new(false);
metric.push_ns(42);
let stats = metric.stats();
assert_eq!(stats.variance_ns2, 0.0);
assert_eq!(stats.stddev_ns(), 0.0);
}
}
+3 -3
View File
@@ -34,11 +34,11 @@ pub fn sent(seq: u32, sent_at: ClientTimestamp) -> ClientEvent {
ClientEvent::EchoSent { ClientEvent::EchoSent {
seq, seq,
remote: "127.0.0.1:2112".parse().unwrap(), remote: "127.0.0.1:2112".parse().unwrap(),
scheduled_at: sent_at.mono, scheduled_at: Some(sent_at.mono),
sent_at, sent_at,
bytes: 32, bytes: 32,
send_call: Duration::from_micros(10), send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2), timer_error: Some(Duration::from_micros(2)),
} }
} }
@@ -117,7 +117,7 @@ pub fn sent_with_timings(
unreachable!(); unreachable!();
}; };
*send_call = Duration::from_micros(send_call_us); *send_call = Duration::from_micros(send_call_us);
*timer_error = Duration::from_micros(timer_error_us); *timer_error = Some(Duration::from_micros(timer_error_us));
event event
} }
@@ -129,11 +129,11 @@ fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent {
Op::Send => ClientEvent::EchoSent { Op::Send => ClientEvent::EchoSent {
seq, seq,
remote: ADDR.parse().unwrap(), remote: ADDR.parse().unwrap(),
scheduled_at: ts_at(base, clock_ms).mono, scheduled_at: Some(ts_at(base, clock_ms).mono),
sent_at: ts_at(base, clock_ms), sent_at: ts_at(base, clock_ms),
bytes: 32, bytes: 32,
send_call: Duration::from_micros(10), send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2), timer_error: Some(Duration::from_micros(2)),
}, },
Op::Reply { raw_ms } => { Op::Reply { raw_ms } => {
let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms))); let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms)));
+16 -15
View File
@@ -14,23 +14,24 @@ use irtt_stats::{
}; };
#[test] #[test]
fn exact_mode_reports_a_send_call_median() { fn caller_paced_sends_do_not_fabricate_timer_error_samples() {
let mut collector = StatsCollector::new(StatsConfig::finite()); let mut collector = StatsCollector::new(StatsConfig::finite());
for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() { let mut event = sent(0, ts(0));
collector.process(&sent_with_timings( let ClientEvent::EchoSent {
seq as u32, scheduled_at,
ts(seq as u64 * 10), timer_error,
send_call_us, ..
2, } = &mut event
)); else {
} unreachable!()
};
*scheduled_at = None;
*timer_error = None;
collector.process(&event);
let snapshot = collector.snapshot(); let snapshot = collector.snapshot();
assert_eq!(snapshot.send_call.count, 3); assert_eq!(snapshot.packets.packets_sent, 1);
assert_eq!(snapshot.send_call.min_ns, Some(10_000)); assert_eq!(snapshot.send_call.count, 1);
assert_eq!(snapshot.send_call.max_ns, Some(30_000)); assert_eq!(snapshot.timer_error.count, 0);
// Sorted 10, 20, 30 us: the median is the middle sample, not the mean.
assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
} }
#[test] #[test]
+1 -1
View File
@@ -31,7 +31,7 @@ The build also produces dedicated binaries: `irtt-client`, `irtt-tui`, and
what it is invoked or copied as — it does not consult argv0 dispatch logic. what it is invoked or copied as — it does not consult argv0 dispatch logic.
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under `irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
one of the recognized applet names — `irtt-client`, `irtt-tui`, `irttd`, or one of the recognized applet names — `irtt-client`, `irtt-tui`, or
`irtt-server` — it runs that applet directly, without needing a subcommand. `irtt-server` — it runs that applet directly, without needing a subcommand.
Any other name starting with `irtt-` (other than `irtt-rs` itself) is Any other name starting with `irtt-` (other than `irtt-rs` itself) is
rejected as an unknown applet name before subcommand parsing is even rejected as an unknown applet name before subcommand parsing is even
+40 -1
View File
@@ -60,7 +60,17 @@ served.
A port of `0` selects an unused port per listener, so two such binds get two A port of `0` selects an unused port per listener, so two such binds get two
different ports. different ports.
With no `--bind` at all, see [Default bind](#default-bind) above. With no `--bind` at all, see [Description](#description) above for the
default wildcard pair and the address-family fallback it allows.
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers
served through that socket are IPv4-mapped and their replies cannot retain the
negotiated marking. Configure the ordinary paired `[::]:PORT` and
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that
same-port pair, so each family uses the correct socket option. This is a
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone
IPv6 listener.
### `--hmac KEY` ### `--hmac KEY`
@@ -136,6 +146,35 @@ and they are sent unmarked. The session is not refused.
Durations everywhere in this command accept an integer followed by a unit: Durations everywhere in this command accept an integer followed by a unit:
`ms`, `s`, or `m`. `ms`, `s`, or `m`.
## ENVIRONMENT
Every option above can also be supplied through an environment variable, for
deployments that configure by environment rather than by command line
(containers, orchestrators, unit files). A flag given on the command line
overrides the corresponding variable entirely; an unset variable leaves the
built-in default in place.
| Variable | Option |
| --- | --- |
| `IRTT_SERVER_BIND` | `--bind`, one address only |
| `IRTT_SERVER_HMAC` | `--hmac` |
| `IRTT_SERVER_MAX_SESSIONS` | `--max-sessions` |
| `IRTT_SERVER_MAX_PACKET_LENGTH` | `--max-packet-length` |
| `IRTT_SERVER_MIN_INTERVAL` | `--min-interval` |
| `IRTT_SERVER_BURST` | `--burst` |
| `IRTT_SERVER_IDLE_TIMEOUT` | `--idle-timeout` |
| `IRTT_SERVER_MAX_DURATION` | `--max-duration` |
| `IRTT_SERVER_TIMESTAMP_ALLOWANCE` | `--timestamp-allowance` |
| `IRTT_SERVER_NO_DSCP` | `--no-dscp` |
`IRTT_SERVER_BIND` takes exactly one address, the same as a single `--bind`;
there is no way to request several listeners from the environment. Serving
more than one address needs repeated `--bind` options.
`IRTT_SERVER_NO_DSCP` is the only boolean. `0`, `false`, `no`, `n`, `off` and
an empty value leave traffic-class marking enabled; any other value disables
it, and an unset variable leaves it enabled.
## OUTPUT ## OUTPUT
Each bound endpoint is printed on startup, once every listener is up, which Each bound endpoint is printed on startup, once every listener is up, which
+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 total memory is dominated by whichever of the two is larger for your target
count. count.
A **finite** `--duration` TUI run retains exact statistics samples exactly as
`irtt-client(1)` does in finite mode, so its retained state grows with the
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI
prints **no** memory warning when that estimate gets large: it owns the
alternate screen, and a stderr warning there would be invisible or would
corrupt the display. Size a long finite TUI run from the figures in
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
## EXIT BEHAVIOR ## EXIT BEHAVIOR
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise, Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
+144 -17
View File
@@ -8,6 +8,18 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.12.1" version = "0.12.1"
@@ -19,9 +31,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.4" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -31,9 +43,15 @@ dependencies = [
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "cmov" name = "cmov"
@@ -79,9 +97,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.11" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]] [[package]]
name = "getrandom" name = "getrandom"
@@ -105,16 +123,16 @@ dependencies = [
[[package]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.14" version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [ dependencies = [
"typenum", "typenum",
] ]
[[package]] [[package]]
name = "irtt-proto" name = "irtt-proto"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"hmac", "hmac",
"md-5", "md-5",
@@ -126,10 +144,24 @@ dependencies = [
name = "irtt-proto-fuzz" name = "irtt-proto-fuzz"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"arbitrary",
"irtt-proto", "irtt-proto",
"irtt-server",
"libfuzzer-sys", "libfuzzer-sys",
] ]
[[package]]
name = "irtt-server"
version = "0.5.3"
dependencies = [
"getrandom",
"irtt-proto",
"nix",
"socket2",
"thiserror",
"tokio",
]
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.35" version = "0.1.35"
@@ -166,6 +198,45 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -196,6 +267,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -204,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.4" version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -215,18 +296,43 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.20" version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"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 = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -241,6 +347,27 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[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 cargo-fuzz = true
[dependencies] [dependencies]
arbitrary = "1.4.2"
libfuzzer-sys = "0.4.13" libfuzzer-sys = "0.4.13"
[dependencies.irtt-proto] [dependencies.irtt-proto]
path = "../crates/irtt-proto" path = "../crates/irtt-proto"
[dependencies.irtt-server]
path = "../crates/irtt-server"
# Use independent workspace for fuzzers # Use independent workspace for fuzzers
[workspace] [workspace]
members = ["."] members = ["."]
@@ -51,3 +55,10 @@ path = "fuzz_targets/decode_varint.rs"
test = false test = false
doc = false doc = false
bench = false bench = false
[[bin]]
name = "server_core_datagrams"
path = "fuzz_targets/server_core_datagrams.rs"
test = false
doc = false
bench = false
+9 -6
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. [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
This project is intentionally isolated from the normal stable/MSRV This project is intentionally isolated from the normal stable/MSRV
@@ -22,14 +23,16 @@ of the root workspace, requires nightly Rust, and is not built by
| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated | | `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated |
| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip | | `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip |
| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` | | `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
| `server_core_datagrams` | A bounded mix of raw datagrams and production-encoded Open/Echo/Close requests against one `ServerCore`; it varies IPv4/IPv6 peers, session configuration, and bounded (up to 128-byte) HMAC keys, authenticates keyed requests, reuses issued tokens, and asserts the session cap |
The invariant every target asserts is: **arbitrary bytes must never panic**. The invariant every target asserts is: **arbitrary bytes must never panic**.
Returning `Err` is normal and expected; a successful decode is also normal. Returning `Err` is normal and expected; a successful decode is also normal.
Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so Every target bounds its input (`MAX_INPUT_LEN` in each harness) so libFuzzer
libFuzzer spends its time exploring protocol structure rather than spends its time exploring protocol structure rather than allocating/copying
allocating/copying megabytes — production IRTT datagrams are UDP-sized, well megabytes. `server_core_datagrams` additionally caps its sequence length and
under this bound. each individual datagram and configures the core to the same packet cap, so one
libFuzzer input cannot become a tiny DoS benchmark.
## Running ## Running
+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))
}