Compare commits

..
Author SHA1 Message Date
Nils Andreas Svee 0e23017100 Merge pull request #124 from Lochnair/refactor/application-binaries
refactor(app): clarify application and binary roles
2026-08-20 12:09:10 +07:00
Nils Andreas Svee d71503e4fd fix(app): preserve featureless build support
The shared with_shutdown_flag helper, and the AtomicBool/Arc/
install_signal_handler imports it uses, only exist when at least one
applet feature (client, server, tui) is enabled. Gate them accordingly
instead of leaving them unconditionally compiled, which broke
`--no-default-features` workspace CI under -D warnings (unused
function/dead-code).

Also correct the README installation section: tui is already part of
the default feature set, so an ordinary `cargo install` already
produces irtt-tui alongside the other three binaries. Document a
lightweight `--no-default-features --features client` install instead
of the previous (incorrect) framing of tui as an opt-in extra, and fix
the same gap in the binaries/features table.
2026-08-20 12:01:07 +07:00
Nils Andreas Svee 7bea3dd54e refactor(app): clarify application and binary roles
Rename the application Cargo package from irtt-cli to irtt-rs
(crates/irtt-cli -> crates/irtt-app), and hard-rename its scriptable
client binary from irtt-cli to irtt-client, now that the application
also ships a first-class server and the old name reads as an umbrella
CLI for both roles.

Give each dedicated binary (irtt-client, irtt-tui, irtt-server) its
own entry point instead of funneling through the multicall
dispatcher's argv0/subcommand logic, so a dedicated binary only links
the applet code for the role it implements and always runs that role
regardless of the name it's invoked or copied under. irtt-rs remains
the feature-selectable multicall dispatcher and is now the only binary
that inspects argv0 or subcommands.

Move future application releases to the irtt-rs/vX.Y.Z tag namespace
(regenerated via `dist generate`), and bump the application package to
0.6.0 as a breaking distribution change. Library crate versions are
unaffected.
2026-08-20 11:44:35 +07:00
Nils Andreas Svee 7129ee0bc5 Merge pull request #123 from Lochnair/fix/cli-empty-hmac
fix(cli): reject empty HMAC keys
2026-08-20 10:48:54 +07:00
Nils Andreas Svee 75aaf68fea Merge pull request #122 from Lochnair/fix/table-target-width
fix(cli): bound target labels in table output
2026-08-20 10:48:35 +07:00
Nils Andreas Svee f470d4d7a6 Merge pull request #121 from Lochnair/fix/summary-target-order
fix(cli): preserve target order in summaries
2026-08-20 10:48:17 +07:00
Nils Andreas Svee 2cdc084ef1 fix(cli): reject empty HMAC keys
--hmac "" silently produced an empty-byte HMAC key at the protocol
boundary, so an accidentally-empty shell expansion (--hmac "$VAR")
could look like auth was deliberately enabled while actually using a
predictable empty key. Reject a zero-length --hmac value at parse
time in the client CLI, the TUI client (via the shared
CommonClientArgs), and the server CLI. Non-empty values, including
whitespace-only ones, are accepted exactly as supplied.
2026-08-20 10:43:53 +07:00
Nils Andreas Svee 5c7d75e39e fix(cli): bound target labels in table output
TABLE output formatted every column value with format!("{value:<width$}"),
which only sets a minimum width. Target is free-form/user-controlled and
is the first default table column, so a long target label pushed every
later column out of alignment while numeric/measurement columns stayed
correct.

Add a table-only truncation for Column::Target: labels within
table_width() (18) render unchanged; longer labels keep a
character-boundary-safe prefix and end with "..." so the rendered cell
is exactly 18 characters. This is scoped to format_table_cell's Target
case only, so it cannot silently shorten any measurement/data column.
CSV, TSV, and JSONL are untouched and keep emitting the full,
untruncated target label.
2026-08-20 10:42:48 +07:00
Nils Andreas Svee 6a4dce68b3 fix(cli): preserve target order in summaries
Final multi-target summaries were printed by iterating the label-keyed
BTreeMap directly, so they came out in lexicographic label order
instead of the order targets were given on the command line. Iterate
setup.targets (the canonical prepared CLI order) instead, looking up
each target's stats by label, and keep the BTreeMap only for
event-time lookup.
2026-08-20 10:42:31 +07:00
Nils Andreas Svee 0d9826800b Merge pull request #120 from Lochnair/test/session-machine-model
test(client): model SessionMachine operation sequences
2026-08-20 10:09:41 +07:00
Nils Andreas Svee 2dee315a6e Merge pull request #119 from Lochnair/test/server-set-runtime-failure
test(server): cover mid-run ServerSet listener failure
2026-08-20 10:09:19 +07:00
Nils Andreas Svee 369e166a6c Merge pull request #118 from Lochnair/test/stats-properties
test(stats): add rolling-window and IPDV property coverage
2026-08-20 10:09:01 +07:00
Nils Andreas Svee 6df16e0205 test(client): model SessionMachine open-session operation sequences
Adds a proptest-based model test that drives generated sequences of
probe sends, valid/duplicate/late/unknown/wrong-token replies, bounded
and unbounded timeout polling, peer/local close, and u32 wire-sequence
wraparound through a real SessionMachine and an independent reference
model, asserting the observable state and every emitted event match
after each operation. This combinatorial interleaving of an open
session's operations was previously untested; existing tests are all
narrow single-scenario cases.
2026-08-20 10:07:20 +07:00
Nils Andreas Svee 7c190058f4 test(server): cover mid-run ServerSet listener failure
ServerSet's startup/bind-failure paths were already covered, but nothing
exercised a listener failing after every listener had bound and was
actively serving. Inject the failure through the crate's own established
token-source test seam (the same one core::tests already uses) via a
narrow #[cfg(test)] Server::from_socket_with_core constructor, so one
listener answers a first open like its healthy sibling before its second
draw fails deterministically. Confirms ServerSet reports the failure,
shuts the sibling down, joins both tasks and releases both sockets, with
no external shutdown ever sent.
2026-08-20 10:07:19 +07:00
Nils Andreas Svee 3e2cf3bad7 test(stats): add rolling-window and IPDV property coverage
Add proptest-based generative tests for irtt-stats's stateful rolling
windows and IPDV pairing.

The rolling_count and rolling_time bounds turn out to be two
independent VecDeques (rolling.rs), not a single window bounded by
both, so the main property test proves that independence: it drives a
StatsCollector configured with both bounds simultaneously through
generated operation streams, checking after every operation that each
window matches a from-scratch reference model applying only its own
bound (and that the cumulative snapshot matches a full-history
replay). Since both the production window and the reference replay
the same events through the same deterministic aggregation code, the
comparison is exact equality, not a tolerance.

Also add two smaller property tests for IpdvTracker's bounded FIFO
state and at-most-once adjacent-pair emission under arbitrary
(including non-monotonic and duplicate) sequence streams, complementing
the existing strictly-increasing hand test.

No discrepancies between production and the reference model were
found.
2026-08-20 10:07:04 +07:00
Nils Andreas Svee a055e80fd3 Merge pull request #117 from Lochnair/test/proto-properties
test(proto): add codec property tests
2026-08-20 00:48:25 +07:00
Nils Andreas Svee 2634649852 test(proto): add codec property tests
Add proptest-based property tests for irtt-proto's varint and Params
codecs, covering the full u64/i64 domains for varint/zigzag round trips
and generated valid Params for semantic round trips, presence
omission rules, repeated-tag last-wins behavior, and unknown-tag
tolerance.
2026-08-20 00:45:16 +07:00
Nils Andreas Svee b30cf9b3a4 Merge pull request #116 from Lochnair/test/proto-fuzzing
test(proto): add decoder fuzzing
2026-08-20 00:31:40 +07:00
Nils Andreas Svee 5ce5f4fcda fix(fuzz-ci): build cargo-fuzz with stable, not the nightly override
Installing cargo-fuzz under a nightly directory override made rustix's
build script (a cargo-fuzz dependency) gate on `rustc_attrs`, which this
runner's nightly rejects as reserved-for-rustc. Install nightly without
overriding the directory default so `cargo install cargo-fuzz` still
builds under stable; only `fuzz build`/`fuzz run` opt into `+nightly`.
2026-08-19 23:36:08 +07:00
Nils Andreas Svee 719073df1c test(proto): add decoder fuzzing
Add isolated cargo-fuzz/libFuzzer infrastructure for irtt-proto, fuzzing
decode_request, decode_open_reply, decode_echo_reply, Params::decode(_with_presence),
and the varint codec against arbitrary and structurally-derived wire input.
Each target's core invariant is that arbitrary bytes must never panic; a
small clean-room seed corpus (generated from irtt-proto's own encoders) and
a bounded nightly smoke CI workflow are included. The fuzz project is its
own isolated cargo workspace and does not affect the root workspace, its
lockfile, or any published crate's dependency graph.
2026-08-19 23:33:27 +07:00
Nils Andreas Svee 9b70bd8b9d Merge pull request #113 from Lochnair/renovate/lock-file-maintenance
chore(deps): maintain cargo lockfile
2026-08-17 23:16:51 +07:00
renovate[bot] 7b7cb6042e chore(deps): maintain cargo lockfile 2026-08-17 02:55:09 +00:00
88 changed files with 3229 additions and 233 deletions

No files matched your search

+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/irtt-cli-release.yml
files: .github/workflows/irtt-rs-release.yml
shellcheck: false
zizmor:
name: zizmor
+67
View File
@@ -0,0 +1,67 @@
name: Proto Fuzz Smoke
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
CARGO_FUZZ_VERSION: 0.13.1
jobs:
fuzz-smoke:
name: cargo fuzz smoke
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install stable Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
cache-key: fuzz
- name: Install nightly Rust toolchain
# Installed alongside, but deliberately not made the directory
# default: cargo-fuzz itself is built with the default stable
# toolchain below, and only `fuzz build`/`fuzz run` are run with
# `+nightly`. Building cargo-fuzz's own dependency tree under a
# nightly override can trip build scripts (e.g. rustix) that gate
# unstable internal attributes on detecting a nightly compiler.
run: rustup toolchain install nightly --profile minimal --no-self-update
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}"
- name: Build all fuzz targets
run: cargo +nightly fuzz build
working-directory: fuzz
- name: Run bounded fuzz smoke for every target
# A single job building once and running each target sequentially
# avoids recompiling the fuzz workspace per target. Ten seconds per
# target is enough to catch a broken harness or an obvious
# regression; this is not meant to be exhaustive fuzzing.
run: |
set -euo pipefail
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do
echo "::group::fuzz smoke: ${target}"
cargo +nightly fuzz run "${target}" -- \
-max_total_time=10 \
-timeout=5
echo "::endgroup::"
done
working-directory: fuzz
+3 -3
View File
@@ -46,8 +46,8 @@ jobs:
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
"$GOBIN/irtt" version
- name: Build irtt-rs CLI
run: cargo build -p irtt-cli --locked
- name: Build irtt-rs client
run: cargo build -p irtt-rs --bin irtt-client --locked
- name: Run netem interop comparisons
run: |
@@ -55,7 +55,7 @@ jobs:
--all \
--skip-build \
--upstream-irtt "$GOBIN/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli"
--irtt-rs-command "$PWD/target/debug/irtt-client"
- name: Append netem interop summary
if: always()
@@ -42,7 +42,7 @@ on:
pull_request:
push:
tags:
- 'irtt-cli**[0-9]+.[0-9]+.[0-9]+*'
- 'irtt-rs**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- "irtt-client/v*.*.*"
- "irtt-server/v*.*.*"
- "irtt-stats/v*.*.*"
- "irtt-cli/v*.*.*"
- "irtt-rs/v*.*.*"
permissions:
contents: read
+1 -1
View File
@@ -75,5 +75,5 @@ jobs:
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/irtt-cli-release.yml
files: .github/workflows/irtt-rs-release.yml
shellcheck: false
+3 -3
View File
@@ -41,7 +41,7 @@ For compatibility questions, the verified spec and black-box observations define
Main boundary:
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-cli` — presentation/orchestration.
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-rs` (application package, `crates/irtt-app`) — presentation/orchestration.
- `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation.
- `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events.
@@ -51,7 +51,7 @@ Main boundary:
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
- `irtt-cli`: argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Do not duplicate protocol/session state machines here.
- `irtt-rs` (application package, `crates/irtt-app`): argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Produces the `irtt-rs` multicall dispatcher plus the dedicated `irtt-client`, `irtt-tui`, and `irtt-server` binaries. Do not duplicate protocol/session state machines here.
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
@@ -66,7 +66,7 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
- Ownership is layered and the layers do not blur: `Server` is the one-listener primitive (one socket, one `ServerCore`, one sequential loop), and `ServerSet` supervises one or more of them without owning protocol or session state itself. A common `ServerConfig` is cloned into each listener, so its bounds — `max_sessions` included — are per listener and not per process. Listeners share nothing else: no shared `ServerCore`, no global session table or token namespace, no shared clock, and one sequential sender per socket, which is what keeps the socket-wide reply traffic class race-free.
- The CLI server applet exists behind the `irtt-cli` `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
- The server applet exists behind the `irtt-rs` application package's `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
+16 -3
View File
@@ -88,6 +88,17 @@ release. The format is loosely based on
- `LateReplyMode` (`Measure` / `CountOnly`) lets a consumer choose whether replies that arrive after their probe is considered late are still fully measured into the running statistics or only counted, without affecting the other samples.
- `StatsConfig::estimated_retained_bytes(probe_count)` gives callers an API to estimate the memory a stats configuration will retain for a given probe count, ahead of actually running a session (used by `irtt-cli`'s multi-target memory-usage warning, see below).
## irtt-rs
### 0.6.0
#### Changed
- The application Cargo package was renamed from `irtt-cli` to `irtt-rs`, moving to `crates/irtt-app`. This is the application distribution package that produces the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries; it is unrelated to the reusable `irtt-client` library crate, which keeps its existing name and version.
- The `irtt-cli` executable was hard-renamed to `irtt-client` to avoid reading as an umbrella CLI now that the application also ships a first-class server. There is no `irtt-cli` compatibility binary or alias.
- Dedicated binaries now have role-specific entry points (`irtt-client`, `irtt-tui`, `irtt-server`) instead of all funneling through the multicall dispatcher's argv0/subcommand logic. Each dedicated binary always runs its own role regardless of the name it is invoked or copied under, and no longer links code for the other applet roles it doesn't implement. The `irtt-rs` binary remains the feature-selectable multicall dispatcher and is the only binary that inspects argv0 or subcommands.
- Future application releases move to the `irtt-rs/vX.Y.Z` tag namespace (previously `irtt-cli/vX.Y.Z`); see [Releasing](#releasing).
## irtt-cli
### 0.5.0
@@ -125,6 +136,8 @@ rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
libraries published to [crates.io](https://crates.io); their tags exist to
identify the exact source for a given crates.io release and do not produce a
GitHub Release. `irtt-cli` is the only crate with prebuilt binary artifacts:
pushing an `irtt-cli/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
to build and publish a GitHub Release with platform binaries.
GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs`
from 0.6.0 onward) is the only package with prebuilt binary artifacts: pushing
an `irtt-rs/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
to build and publish a GitHub Release with platform binaries. The historical
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
Generated
+185 -29
View File
@@ -109,7 +109,16 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
"bit-vec 0.6.3",
]
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
@@ -118,6 +127,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -516,10 +531,16 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
dependencies = [
"bit-set",
"bit-set 0.5.3",
"regex",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -692,21 +713,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "irtt-cli"
version = "0.5.0"
dependencies = [
"clap",
"crossterm",
"ctrlc",
"irtt-client",
"irtt-proto",
"irtt-server",
"irtt-stats",
"ratatui",
"tokio",
]
[[package]]
name = "irtt-client"
version = "0.5.0"
@@ -714,6 +720,7 @@ dependencies = [
"irtt-proto",
"irtt-server",
"nix 0.31.3",
"proptest",
"socket2",
"thiserror 2.0.20",
"tokio",
@@ -735,10 +742,26 @@ version = "0.5.0"
dependencies = [
"hmac",
"md-5",
"proptest",
"subtle",
"thiserror 2.0.20",
]
[[package]]
name = "irtt-rs"
version = "0.6.0"
dependencies = [
"clap",
"crossterm",
"ctrlc",
"irtt-client",
"irtt-proto",
"irtt-server",
"irtt-stats",
"ratatui",
"tokio",
]
[[package]]
name = "irtt-server"
version = "0.5.0"
@@ -756,6 +779,7 @@ name = "irtt-stats"
version = "0.5.0"
dependencies = [
"irtt-client",
"proptest",
]
[[package]]
@@ -1094,9 +1118,9 @@ dependencies = [
[[package]]
name = "pest"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
dependencies = [
"memchr",
"ucd-trie",
@@ -1104,9 +1128,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
dependencies = [
"pest",
"pest_generator",
@@ -1114,9 +1138,9 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
dependencies = [
"pest",
"pest_meta",
@@ -1127,9 +1151,9 @@ dependencies = [
[[package]]
name = "pest_meta"
version = "2.8.8"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
dependencies = [
"pest",
]
@@ -1161,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
"rand 0.8.7",
]
[[package]]
@@ -1204,6 +1228,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -1213,6 +1246,31 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set 0.8.0",
"bit-vec 0.8.0",
"bitflags 2.13.1",
"num-traits",
"rand 0.9.5",
"rand_chacha",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.47"
@@ -1240,7 +1298,27 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -1249,6 +1327,24 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "ratatui"
version = "0.30.2"
@@ -1416,6 +1512,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1606,6 +1714,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "termina"
version = "0.3.3"
@@ -1780,6 +1901,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1817,9 +1944,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.24.0"
version = "1.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
dependencies = [
"atomic",
"getrandom 0.4.3",
@@ -1842,6 +1969,15 @@ dependencies = [
"utf8parse",
]
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -2016,3 +2152,23 @@ name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = [
"crates/irtt-client",
"crates/irtt-server",
"crates/irtt-stats",
"crates/irtt-cli",
"crates/irtt-app",
"crates/irtt-integration-tests",
]
resolver = "2"
+26 -24
View File
@@ -31,19 +31,21 @@ From a local checkout:
```sh
git clone https://github.com/Lochnair/irtt-rs.git
cd irtt-rs
cargo install --path crates/irtt-cli
cargo install --path crates/irtt-app
```
This installs:
* `irtt-rs`, the canonical multi-applet dispatcher
* `irtt-cli`, the stream and text client
* `irtt-client`, the stream and text client
* `irtt-tui`, the terminal UI
* `irtt-server`, the UDP server
To also install the optional terminal UI:
Each applet can also be built in isolation with `--no-default-features` plus
the applet's own feature. For example, a lightweight client-only install:
```sh
cargo install --path crates/irtt-cli --features tui
cargo install --path crates/irtt-app --no-default-features --features client
```
## Quick start
@@ -51,13 +53,13 @@ cargo install --path crates/irtt-cli --features tui
Probe a server using the default settings:
```sh
irtt-cli netperf-eu.bufferbloat.net:2112
irtt-client netperf-eu.bufferbloat.net:2112
```
Set the test duration and probe interval:
```sh
irtt-cli netperf-eu.bufferbloat.net:2112 \
irtt-client netperf-eu.bufferbloat.net:2112 \
--duration 30s \
--interval 100ms
```
@@ -65,7 +67,7 @@ irtt-cli netperf-eu.bufferbloat.net:2112 \
Run continuously until interrupted:
```sh
irtt-cli netperf-eu.bufferbloat.net:2112 --duration 0
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
```
Use `Ctrl-C` to stop gracefully.
@@ -75,7 +77,7 @@ Use `Ctrl-C` to stop gracefully.
Probe several targets concurrently:
```sh
irtt-cli host-a:2112 host-b:2112
irtt-client host-a:2112 host-b:2112
```
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix assigns the logical
@@ -83,19 +85,19 @@ target name used in output and the TUI; without it, the target string itself is
stable labels to targets:
```sh
irtt-cli ams=ams.example.com:2112 sg=sg.example.com:2112
irtt-client ams=ams.example.com:2112 sg=sg.example.com:2112
```
Labeled and unlabeled targets can be freely mixed in one argument list:
```sh
irtt-cli host-a:2112 ams=ams.example.com:2112
irtt-client host-a:2112 ams=ams.example.com:2112
```
Multi-target sessions use staggered pacing by default. To send each target's probes together:
```sh
irtt-cli host-a:2112 host-b:2112 --pacing burst
irtt-client host-a:2112 host-b:2112 --pacing burst
```
Target labels are included in the default multi-target output.
@@ -112,16 +114,16 @@ The CLI supports four event-row formats:
Examples:
```sh
irtt-cli <server> --format table
irtt-cli <server> --format jsonl
irtt-cli <server> --format csv \
irtt-client <server> --format table
irtt-client <server> --format jsonl
irtt-client <server> --format csv \
--columns event,seq,remote,effective_rtt_us
```
For a stream containing only effective RTT values in microseconds:
```sh
irtt-cli <server> \
irtt-client <server> \
--format tsv \
--columns effective_rtt_us \
--header never
@@ -130,7 +132,7 @@ irtt-cli <server> \
List all available columns with:
```sh
irtt-cli --list-columns
irtt-client --list-columns
```
Useful measurement fields include:
@@ -164,7 +166,7 @@ It runs continuously by default. A finite duration can be selected explicitly:
irtt-tui <server> --duration 30s
```
Multiple targets and pacing options work the same way as in `irtt-cli`:
Multiple targets and pacing options work the same way as in `irtt-client`:
```sh
irtt-tui host-a:2112 host-b:2112 --pacing burst
@@ -312,21 +314,21 @@ synchronous callers. Callers that already own Tokio can drive
## Binaries and features
| Build | Binaries |
| ----------------------------------------- | ------------------------------------------------- |
| -------------------------------------------- | --------------------------------------------------- |
| `--no-default-features` | `irtt-rs` |
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
| Default features | `irtt-rs`, `irtt-cli`, `irtt-server` |
| `--features tui` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
| `--all-features` | `irtt-rs`, `irtt-cli`, `irtt-server`, `irtt-tui` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
`irtt-cli` requires the `client` feature, `irtt-server` the `server` feature,
`irtt-client` requires the `client` feature, `irtt-server` the `server` feature,
and `irtt-tui` the `tui` feature. The `irtt-rs` dispatcher is always built, and
reports which applets the build actually contains.
For available command-line options:
```sh
irtt-cli --help
irtt-client --help
irtt-server --help
irtt-tui --help
```
@@ -339,7 +341,7 @@ Common verification commands:
cargo fmt --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo build -p irtt-cli --all-features --release
cargo build -p irtt-rs --all-features --release
```
## Project status
@@ -1,7 +1,7 @@
[package]
name = "irtt-cli"
version = "0.5.0"
description = "Command-line IRTT-compatible client and server"
name = "irtt-rs"
version = "0.6.0"
description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
@@ -12,6 +12,7 @@ keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
[lib]
name = "irtt_app"
doc = false
[[bin]]
@@ -19,9 +20,12 @@ name = "irtt-rs"
path = "src/bin/irtt-rs.rs"
[[bin]]
name = "irtt-cli"
path = "src/bin/irtt-cli.rs"
name = "irtt-client"
path = "src/bin/irtt-client.rs"
required-features = ["client"]
# This applet shares its name with the irtt-client library crate, whose rustdoc
# output it would otherwise collide with.
doc = false
[[bin]]
name = "irtt-tui"
@@ -20,7 +20,7 @@ pub enum AppletDispatch {
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) {
"irtt-cli" => Some(RequestedApplet::Client),
"irtt-client" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
@@ -58,7 +58,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
"client" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: applet_argv("irtt-cli", &argv[2..]),
argv: applet_argv("irtt-client", &argv[2..]),
}),
"tui" => Ok(AppletDispatch::Run {
applet: RequestedApplet::Tui,
@@ -111,7 +111,7 @@ fn server_applet_about() -> &'static str {
}
fn known_applet_binary_names() -> &'static str {
"irtt-rs, irtt-cli, irtt-tui, irtt-server"
"irtt-rs, irtt-client, irtt-tui, irtt-server"
}
fn applet_command_names() -> &'static str {
@@ -119,11 +119,11 @@ fn applet_command_names() -> &'static str {
}
fn dispatcher_after_help() -> String {
let mut help = "Recognized applet binary names: irtt-cli, irtt-tui, irtt-server".to_owned();
let mut help = "Recognized applet binary names: irtt-client, irtt-tui, irtt-server".to_owned();
let mut missing = Vec::new();
if !cfg!(feature = "client") {
missing.push("irtt-cli requires the client feature");
missing.push("irtt-client requires the client feature");
}
if !cfg!(feature = "server") {
missing.push("irtt-server requires the server feature");
@@ -188,7 +188,7 @@ mod tests {
#[test]
fn applet_detection_maps_binary_names() {
assert_eq!(
detect_applet_from_argv0("/usr/bin/irtt-cli"),
detect_applet_from_argv0("/usr/bin/irtt-client"),
Some(RequestedApplet::Client)
);
assert_eq!(
@@ -203,6 +203,12 @@ mod tests {
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
#[test]
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
assert_eq!(detect_applet_from_argv0("irtt-cli"), None);
assert_eq!(detect_applet_from_argv0("/usr/bin/irtt-cli"), None);
}
#[test]
fn applet_detection_ignores_the_platform_executable_suffix() {
let exe = std::env::consts::EXE_SUFFIX;
@@ -212,7 +218,7 @@ mod tests {
Some(RequestedApplet::Server)
);
assert_eq!(
detect_applet_from_argv0(&format!("irtt-cli{exe}")),
detect_applet_from_argv0(&format!("irtt-client{exe}")),
Some(RequestedApplet::Client)
);
@@ -288,7 +294,7 @@ mod tests {
.unwrap(),
AppletDispatch::Run {
applet: RequestedApplet::Client,
argv: vec!["irtt-cli".into(), "host:2112".into()],
argv: vec!["irtt-client".into(), "host:2112".into()],
}
);
assert_eq!(
@@ -311,7 +317,7 @@ mod tests {
fn unknown_irtt_binary_name_is_an_error() {
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
assert!(err.contains("unknown applet name 'irtt-typo'"));
assert!(err.contains("irtt-cli"));
assert!(err.contains("irtt-client"));
if cfg!(feature = "tui") {
assert!(err.contains("irtt-tui"));
}
@@ -1,5 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_cli::entry::main()
irtt_app::entry::client_main()
}
@@ -1,5 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_cli::entry::main()
irtt_app::entry::dispatcher_main()
}
@@ -1,5 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_cli::entry::main()
irtt_app::entry::server_main()
}
@@ -1,5 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
irtt_cli::entry::main()
irtt_app::entry::tui_main()
}
@@ -9,14 +9,14 @@ use clap::{Parser, ValueEnum};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 0..,
value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-cli host-a:2112 host-b:2112\n irtt-cli eu=host-a:2112 us=host-b:2112\n irtt-cli host-a:2112 eu=host-b:2112"
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client host-a:2112 eu=host-b:2112"
)]
pub targets: Vec<TargetArg>,
@@ -149,7 +149,7 @@ mod tests {
use irtt_proto::{Clock, ReceivedStats, StampAt};
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
let mut argv = vec!["irtt-client"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
@@ -296,6 +296,19 @@ mod tests {
assert!(help.contains("--loose"));
}
#[test]
fn an_empty_hmac_key_is_rejected() {
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn a_non_empty_hmac_key_is_accepted() {
let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap();
let config = args.prepare().unwrap().client;
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
}
#[test]
fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary());
File renamed without changes.
@@ -802,6 +802,11 @@ fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<
fn format_table_cell(column: &Column, value: Option<String>) -> String {
let value = value.unwrap_or_else(|| ABSENT.to_owned());
let width = column.table_width();
let value = if matches!(column, Column::Target) {
truncate_for_table(&value, width)
} else {
value
};
if column.align_right() {
format!("{value:>width$}")
} else {
@@ -809,6 +814,25 @@ fn format_table_cell(column: &Column, value: Option<String>) -> String {
}
}
/// Shortens `value` to fit within `max_width` display characters for TABLE
/// presentation only. Values within the limit are returned unchanged. Longer
/// values keep a prefix and end with `"..."` so the result is exactly
/// `max_width` characters. Operates on `char`s (never raw byte indices) so a
/// multibyte UTF-8 value cannot be split mid-code-point.
///
/// This is a presentation-only concern: callers must keep the untruncated
/// value for CSV/TSV/JSONL and any non-display use.
fn truncate_for_table(value: &str, max_width: usize) -> String {
if value.chars().count() <= max_width {
return value.to_owned();
}
let suffix = "...";
let prefix_len = max_width.saturating_sub(suffix.len());
let mut truncated: String = value.chars().take(prefix_len).collect();
truncated.push_str(suffix);
truncated
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DelimitedFormat {
Csv,
@@ -1304,4 +1328,150 @@ mod tests {
assert!(line.starts_with("{\"target\":\"ams\""));
}
#[test]
fn truncate_for_table_leaves_short_value_unchanged() {
assert_eq!(truncate_for_table("ams", 18), "ams");
}
#[test]
fn truncate_for_table_leaves_exact_width_value_unchanged() {
let value = "a".repeat(18);
assert_eq!(truncate_for_table(&value, 18), value);
}
#[test]
fn truncate_for_table_shortens_long_value_to_width() {
let value = "a".repeat(40);
let truncated = truncate_for_table(&value, 18);
assert_eq!(truncated.chars().count(), 18);
assert!(truncated.len() <= 18); // ASCII: byte len matches char len
}
#[test]
fn truncate_for_table_keeps_prefix_and_appends_ellipsis_deterministically() {
let value = "abcdefghijklmnopqrstuvwxyz";
let truncated = truncate_for_table(value, 18);
assert_eq!(truncated, "abcdefghijklmno...");
assert_eq!(truncated.chars().count(), 18);
}
#[test]
fn truncate_for_table_does_not_split_multibyte_code_points() {
// Each character below is multibyte in UTF-8; a byte-index slice
// would panic or corrupt the string on a non-boundary cut.
let value = "\u{1F600}".repeat(40); // 😀 repeated, well past width 18
let truncated = truncate_for_table(&value, 18);
// Must not panic (validated by test completing) and must remain
// valid UTF-8 with exactly 18 chars (15 prefix chars + "...").
assert_eq!(truncated.chars().count(), 18);
assert!(truncated.ends_with("..."));
assert_eq!(truncated.chars().filter(|c| *c == '\u{1F600}').count(), 15);
}
#[test]
fn table_row_with_long_target_keeps_next_column_aligned() {
let config = output_config(OutputFormat::Table, None);
let long_target = "a".repeat(40);
let header = config.render_header().unwrap();
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
// Target column is truncated to its table width (18), so the
// "event" column starts at the same offset for both a normal and an
// overlong target label.
let short_config = output_config(OutputFormat::Table, None);
let short_line = short_config
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
.unwrap();
let target_width = Column::Target.table_width();
let expected_event_offset = target_width + " ".len();
assert_eq!(&header[expected_event_offset..][..5], "event");
// The event column ("echo_reply") starts at the same offset
// regardless of whether the target label was short or overlong.
assert_eq!(
&line[expected_event_offset..][.."echo_reply".len()],
"echo_reply"
);
assert_eq!(
&short_line[expected_event_offset..][.."echo_reply".len()],
"echo_reply"
);
}
#[test]
fn csv_preserves_full_long_target_label() {
let config = output_config(OutputFormat::Csv, Some("target,seq"));
let long_target = "a".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
assert_eq!(line, format!("{long_target},7"));
}
#[test]
fn tsv_preserves_full_long_target_label() {
let config = output_config(OutputFormat::Tsv, Some("target,seq"));
let long_target = "b".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
assert_eq!(line, format!("{long_target}\t7"));
}
#[test]
fn jsonl_preserves_full_long_target_label() {
let config = output_config(OutputFormat::Jsonl, Some("target,seq"));
let long_target = "c".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}"));
}
#[test]
fn explicit_table_columns_with_target_truncate_same_as_default() {
let config = output_config(OutputFormat::Table, Some("target,seq"));
let long_target = "d".repeat(40);
let line = config
.render_event(
&reply(),
Some(&long_target),
Some(&EventRenderStats::default()),
)
.unwrap();
let target_width = Column::Target.table_width();
let expected_prefix: String = long_target
.chars()
.take(target_width - 3)
.chain("...".chars())
.collect();
assert!(line.starts_with(&expected_prefix));
assert_eq!(&line[..target_width], expected_prefix.as_str());
}
}
@@ -176,15 +176,18 @@ pub fn run_stream(
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.show_running_only_summary_note =
continuous && interrupted && stream_output.print_final_summary;
for (label, stats) in &stats {
for target in &setup.targets {
let target_stats = stats.get(&target.label).expect(
"every prepared target must have a stats collector inserted before the run begins",
);
if multi_target
&& stream_output.print_final_summary
&& stream_output.config.prints_summary()
{
writeln!(stream_output.out)?;
writeln!(stream_output.out, "target: {label}")?;
writeln!(stream_output.out, "target: {}", target.label)?;
}
stream_output.print_summary(stats)?;
stream_output.print_summary(target_stats)?;
}
stream_output.out.flush()?;
if let Some(error) = terminal_error {
@@ -389,7 +392,7 @@ mod tests {
use clap::Parser;
ClientArgs::try_parse_from([
"irtt-cli",
"irtt-client",
"--duration",
"4200s",
"--interval",
File renamed without changes.
File renamed without changes.
@@ -26,6 +26,7 @@ pub struct ServerArgs {
#[arg(
long,
value_name = "KEY",
value_parser = parse_hmac_key,
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments."
)]
pub hmac: Option<String>,
@@ -160,6 +161,15 @@ impl ServerArgs {
}
}
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
/// enable authentication with predictable, empty key material.
fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
/// Parses a duration whose zero is meaningful server policy.
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
if input == "0" {
@@ -404,6 +414,18 @@ mod tests {
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
}
#[test]
fn an_empty_hmac_key_is_rejected() {
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn a_whitespace_only_hmac_key_is_accepted_literally() {
let config = bound(&["--hmac", " "]).server_config();
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
}
#[test]
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
File renamed without changes.
File renamed without changes.
@@ -270,6 +270,14 @@ mod tests {
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
}
#[test]
fn an_empty_hmac_key_is_rejected_via_the_shared_args() {
// CommonClientArgs is flattened into both the client and TUI parsers,
// so proving rejection once here covers both without duplicating it.
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
assert!(err.to_string().contains("HMAC key must not be empty"));
}
#[test]
fn tui_help_lists_multi_target_options() {
let help = TuiArgs::command().render_help().to_string();
File renamed without changes.
File renamed without changes.
File renamed without changes.
+133
View File
@@ -0,0 +1,133 @@
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
use std::sync::{atomic::AtomicBool, Arc};
use std::{env, ffi::OsString, process::ExitCode};
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
use crate::signal::install_signal_handler;
/// Entry point for the `irtt-rs` multicall dispatcher binary.
///
/// Chooses an applet by subcommand or by recognized argv0 and may reference
/// every applet enabled by the active feature set.
pub fn dispatcher_main() -> ExitCode {
run_with_exit_code("irtt-rs", run_dispatcher_from_env)
}
/// Entry point for the dedicated `irtt-client` binary.
///
/// Runs the stream client directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the client.
#[cfg(feature = "client")]
pub fn client_main() -> ExitCode {
run_with_exit_code("irtt-client", || {
run_client_applet(env::args_os().collect())
})
}
/// Entry point for the dedicated `irtt-tui` binary.
///
/// Runs the terminal UI directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the TUI.
#[cfg(feature = "tui")]
pub fn tui_main() -> ExitCode {
run_with_exit_code("irtt-tui", || run_tui_applet(env::args_os().collect()))
}
/// Entry point for the dedicated `irtt-server` binary.
///
/// Runs the server directly; it never consults argv0 or the applet
/// dispatcher, so a renamed or copied executable still runs the server.
#[cfg(feature = "server")]
pub fn server_main() -> ExitCode {
run_with_exit_code("irtt-server", || {
run_server_applet(env::args_os().collect())
})
}
fn run_with_exit_code(
program: &str,
f: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
) -> ExitCode {
match f() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("{program}: {err}");
ExitCode::FAILURE
}
}
}
fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let (requested, argv) = match dispatch_from_argv(argv)? {
AppletDispatch::Run { applet, argv } => (applet, argv),
AppletDispatch::Help(help) => {
print!("{help}");
return Ok(());
}
};
match requested {
RequestedApplet::Client => run_client_applet(argv),
RequestedApplet::Tui => run_tui_applet(argv),
RequestedApplet::Server => run_server_applet(argv),
}
}
/// Installs the shutdown signal handler shared by every applet and hands the
/// resulting flag to `f`.
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
fn with_shutdown_flag(
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
) -> Result<(), Box<dyn std::error::Error>> {
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
f(shutdown_requested.as_ref())
}
#[cfg(feature = "client")]
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::client::ClientArgs::parse_from(argv);
crate::cmd::client::run_stream(args, shutdown_requested)
})
}
#[cfg(not(feature = "client"))]
fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
}
#[cfg(feature = "server")]
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::server::ServerArgs::parse_from(argv);
crate::cmd::server::run_server(args, shutdown_requested)
})
}
#[cfg(not(feature = "server"))]
fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("server applet is not available; rebuild with the server feature".into())
}
#[cfg(feature = "tui")]
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
with_shutdown_flag(|shutdown_requested| {
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
crate::cmd::tui::run_tui(args, shutdown_requested)
})
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
}
File renamed without changes.
@@ -15,7 +15,7 @@ pub struct CommonClientArgs {
pub length: u32,
/// HMAC key.
#[arg(long)]
#[arg(long, value_parser = parse_hmac_key)]
pub hmac: Option<String>,
/// Clock mode to request.
@@ -189,6 +189,13 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
Ok(length)
}
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("server fill must not be empty".to_owned());
File renamed without changes.
File renamed without changes.
@@ -13,6 +13,31 @@ fn irtt_rs_with_arg0(arg0: &str) -> Command {
command
}
/// Copies a dedicated binary to `name` in a temp dir and returns a `Command`
/// invoking it under that name, proving dedicated binaries decide their role
/// from the entry point they were built with, not from argv0.
#[cfg(all(unix, any(feature = "client", feature = "server")))]
fn copied_binary(cargo_bin_exe: &str, name: &str) -> Command {
let dir = std::env::temp_dir().join(format!(
"irtt-copied-binary-test-{}-{}",
std::process::id(),
name
));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join(name);
std::fs::copy(cargo_bin_exe, &dest).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&dest).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&dest, perms).unwrap();
}
Command::new(dest)
}
fn output_text(output: &Output) -> String {
let mut text = String::new();
text.push_str(&String::from_utf8_lossy(&output.stdout));
@@ -97,9 +122,37 @@ fn canonical_server_subcommand_reports_unavailable_when_disabled() {
);
}
#[cfg(feature = "client")]
#[test]
fn dedicated_client_binary_always_enters_the_client_role() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
assert!(text.contains("--format <FORMAT>"));
}
#[cfg(feature = "tui")]
#[test]
fn dedicated_tui_binary_always_enters_the_tui_role() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-tui"))
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible TUI client"));
assert!(text.contains("--duration <DURATION>"));
}
#[cfg(feature = "server")]
#[test]
fn standalone_server_binary_exposes_the_same_arguments() {
fn dedicated_server_binary_always_enters_the_server_role() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-server"))
.arg("--help")
.output()
@@ -111,11 +164,37 @@ fn standalone_server_binary_exposes_the_same_arguments() {
assert!(text.contains("--bind <ADDR>"));
}
#[cfg(all(unix, feature = "client"))]
#[test]
fn renaming_the_dedicated_client_binary_does_not_change_its_role() {
let output = copied_binary(env!("CARGO_BIN_EXE_irtt-client"), "whatever")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible stream client"));
}
#[cfg(all(unix, feature = "server"))]
#[test]
fn renaming_the_dedicated_server_binary_does_not_change_its_role() {
let output = copied_binary(env!("CARGO_BIN_EXE_irtt-server"), "irtt-cli")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
}
#[cfg(unix)]
#[cfg(feature = "client")]
#[test]
fn client_applet_name_dispatches_to_client() {
let output = irtt_rs_with_arg0("irtt-cli")
let output = irtt_rs_with_arg0("irtt-client")
.arg("--help")
.output()
.unwrap();
@@ -130,7 +209,7 @@ fn client_applet_name_dispatches_to_client() {
#[cfg(all(unix, not(feature = "client")))]
#[test]
fn client_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-cli")
let output = irtt_rs_with_arg0("irtt-client")
.arg("--help")
.output()
.unwrap();
@@ -205,7 +284,17 @@ fn unknown_irtt_applet_name_errors() {
assert!(!output.status.success());
assert!(text.contains("unknown applet name 'irtt-typo'"), "{text}");
assert!(text.contains("irtt-cli"));
assert!(text.contains("irtt-client"));
assert!(text.contains("irtt-tui"));
assert!(text.contains("irtt-server"));
}
#[cfg(unix)]
#[test]
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
let output = irtt_rs_with_arg0("irtt-cli").output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("unknown applet name 'irtt-cli'"), "{text}");
}
@@ -55,7 +55,7 @@ impl InterruptibleFakeServer {
#[test]
fn old_target_option_is_rejected_as_an_unknown_argument() {
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args(["--target", "eu=127.0.0.1:2112"])
.output()
.unwrap();
@@ -67,7 +67,7 @@ fn old_target_option_is_rejected_as_an_unknown_argument() {
#[test]
fn list_columns_succeeds_without_target() {
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.arg("--list-columns")
.output()
.unwrap();
@@ -88,7 +88,7 @@ fn single_target_default_table_includes_target_and_accepts_pacing() {
let server = InTreeServer::start();
let target = server.addr.to_string();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -125,7 +125,7 @@ fn single_target_default_table_includes_target_and_accepts_pacing() {
fn single_labeled_target_default_table_includes_target() {
let server = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -160,7 +160,7 @@ fn single_labeled_target_default_table_includes_target() {
fn single_labeled_target_default_csv_includes_target_and_event_wall_ns() {
let server = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -199,7 +199,7 @@ fn single_positional_target_default_csv_includes_target_as_positional_label() {
let server = InTreeServer::start();
let target = server.addr.to_string();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -237,7 +237,7 @@ fn multi_target_default_table_includes_both_labels() {
let a = InTreeServer::start();
let b = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"40ms",
@@ -277,13 +277,61 @@ fn multi_target_default_table_includes_both_labels() {
);
}
#[test]
fn multi_target_final_summary_preserves_cli_argument_order() {
// Labels are chosen so lexicographic (BTreeMap) order differs from the
// CLI argument order, to prove the final per-target summary headings
// follow the order targets were given on the command line rather than
// the internal label-keyed lookup map's iteration order.
let z = InTreeServer::start();
let a = InTreeServer::start();
let m = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"40ms",
"--interval",
"10ms",
&format!("z={}", z.addr),
&format!("a={}", a.addr),
&format!("m={}", m.addr),
])
.output()
.unwrap();
drop(z);
drop(a);
drop(m);
assert!(
output.status.success(),
"status={:?}\nstderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let heading_position = |label: &str| {
stdout
.find(&format!("target: {label}"))
.unwrap_or_else(|| panic!("missing summary heading for target {label}\n{stdout}"))
};
let z_pos = heading_position("z");
let a_pos = heading_position("a");
let m_pos = heading_position("m");
assert!(
z_pos < a_pos && a_pos < m_pos,
"expected summary headings in CLI argument order z, a, m; got positions z={z_pos} a={a_pos} m={m_pos}\n{stdout}"
);
}
#[test]
fn mixed_labeled_and_unlabeled_positional_targets_both_appear_in_output() {
let a = InTreeServer::start();
let b = InTreeServer::start();
let unlabeled_addr = a.addr.to_string();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"40ms",
@@ -328,7 +376,7 @@ fn single_target_custom_target_column_renders_positional_label() {
let server = InTreeServer::start();
let target = server.addr.to_string();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -366,7 +414,7 @@ fn single_target_custom_target_column_renders_positional_label() {
fn custom_columns_remain_authoritative_and_omit_target() {
let server = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -400,7 +448,7 @@ fn custom_columns_remain_authoritative_and_omit_target() {
fn columns_default_keyword_includes_target_for_single_target() {
let server = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -436,7 +484,7 @@ fn columns_default_keyword_includes_target_for_single_target() {
fn single_labeled_target_default_jsonl_includes_target() {
let server = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"30ms",
@@ -469,7 +517,7 @@ fn single_labeled_target_default_jsonl_includes_target() {
#[test]
fn continuous_single_target_peer_close_exits_nonzero() {
let server = start_peer_close_server(test_params(None, Duration::from_millis(10)));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"0s",
@@ -505,7 +553,7 @@ fn continuous_single_target_peer_close_exits_nonzero() {
fn continuous_single_target_interruption_drains_final_events_and_succeeds() {
let params = test_params(None, Duration::from_millis(10));
let server = start_interruptible_echo_server(params);
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"0s",
@@ -563,7 +611,7 @@ fn multi_target_csv_emits_rows_for_both_labels() {
let a = InTreeServer::start();
let b = InTreeServer::start();
let output = Command::new(env!("CARGO_BIN_EXE_irtt-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_irtt-client"))
.args([
"--duration",
"40ms",
@@ -606,7 +654,7 @@ fn finite_multi_target_peer_close_is_accepted_as_completion() {
let params = test_params(Some(Duration::from_millis(40)), Duration::from_millis(10));
let a = start_peer_close_server(params.clone());
let b = start_peer_close_server(params);
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"40ms",
@@ -668,7 +716,7 @@ fn continuous_all_peer_closed_targets_exit_nonzero() {
.send(())
.expect("target B stopped waiting for peer-close release");
});
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"0s",
@@ -718,7 +766,7 @@ fn continuous_partial_peer_close_preserves_queued_rows_and_reports_peer_close()
let (healthy_reply_tx, healthy_reply_rx) = mpsc::channel();
let healthy = start_echo_server_with_first_reply(params.clone(), healthy_reply_tx);
let peer_closed = start_gated_peer_close_server(params, vec![healthy_reply_rx]);
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"0s",
@@ -772,7 +820,7 @@ fn continuous_mixed_peer_close_and_open_failure_exits_nonzero() {
let failure = start_open_failure_server_with_signal(params.clone(), failure_tx);
let healthy = start_echo_server_with_first_reply(params.clone(), healthy_reply_tx);
let peer_closed = start_gated_peer_close_server(params, vec![failure_rx, healthy_reply_rx]);
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"0s",
@@ -824,7 +872,7 @@ fn explicit_group_interruption_succeeds_without_peer_close_error() {
let params = test_params(None, Duration::from_millis(10));
let a = start_interruptible_echo_server(params.clone());
let b = start_interruptible_echo_server(params);
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"0s",
@@ -884,7 +932,7 @@ fn all_open_failures_exit_nonzero_with_diagnostics() {
let params = test_params(None, Duration::from_millis(10));
let a = start_open_failure_server(params.clone());
let b = start_open_failure_server(params);
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-cli"));
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-client"));
command.args([
"--duration",
"0s",
-99
View File
@@ -1,99 +0,0 @@
use std::{
env,
ffi::OsString,
process::ExitCode,
sync::{atomic::AtomicBool, Arc},
};
use crate::{
applet::{dispatch_from_argv, AppletDispatch, RequestedApplet},
signal::install_signal_handler,
};
pub fn main() -> ExitCode {
match run_from_env() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("irtt-rs: {err}");
ExitCode::FAILURE
}
}
}
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let (requested, argv) = match dispatch_from_argv(argv)? {
AppletDispatch::Run { applet, argv } => (applet, argv),
AppletDispatch::Help(help) => {
print!("{help}");
return Ok(());
}
};
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
match requested {
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Server => run_server_applet(argv, shutdown_requested.as_ref()),
}
}
#[cfg(feature = "client")]
fn run_client_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = crate::cmd::client::ClientArgs::parse_from(argv);
crate::cmd::client::run_stream(args, shutdown_requested)
}
#[cfg(not(feature = "client"))]
fn run_client_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
}
#[cfg(feature = "server")]
fn run_server_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = crate::cmd::server::ServerArgs::parse_from(argv);
crate::cmd::server::run_server(args, shutdown_requested)
}
#[cfg(not(feature = "server"))]
fn run_server_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("server applet is not available; rebuild with the server feature".into())
}
#[cfg(feature = "tui")]
fn run_tui_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
crate::cmd::tui::run_tui(args, shutdown_requested)
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
}
+1
View File
@@ -30,4 +30,5 @@ all-features = true
[dev-dependencies]
irtt-server.workspace = true
proptest = "1.11.0"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
+686
View File
@@ -3097,4 +3097,690 @@ mod tests {
Some(SignedDuration::from_nanos(12_000_000))
);
}
/// Model-based tests for `SessionMachine`'s open-session operation
/// contract: probe sends, reply classification, timeouts, and close.
///
/// ## Reference-model boundary
///
/// The open handshake itself (accept/reject, run-mode, version and
/// token checks) is deliberately out of scope here: it already has
/// focused deterministic coverage above in `mod tests`, and every
/// generated case here starts from an already-`Open` session built the
/// same way `open_machine` above builds one, by constructing
/// `MachineState::Open` directly. What *is* generated and checked
/// against an independent reference model is the combinatorial space
/// that only exists once a session is open: interleaved probe sends,
/// valid/duplicate/late/unknown/wrong-token replies, peer and local
/// close, bounded and unbounded timeout polling, and `u32` wire-sequence
/// wraparound.
///
/// The reference [`Model`] tracks only state the public contract
/// documents: open/closed(local)/closed(peer) state, the next wire
/// sequence and total sent-packet counters (mirroring
/// `SessionMachine::packets_sent`), which sequences are pending, timed
/// out, or completed (this drives the exact
/// `EchoReply`/`LateReply`/`DuplicateReply`/`Warning` classification
/// documented on `ClientEvent`), each pending probe's timeout deadline
/// (documented on `ClientEvent::EchoLoss::timeout_at`), and the highest
/// accepted in-window sequence used for late-reply classification. It
/// deliberately does *not* model the bounded FIFO eviction that
/// `PendingMap`/`TimedOutMap`/`CompletedSet` apply once
/// `max_pending_probes` is exceeded: that is a private capacity policy
/// with its own focused unit tests in `probe.rs`, not part of this
/// contract. Every generated case here uses a `max_pending_probes` far
/// larger than the generated operation count, so capacity/eviction
/// behavior never confounds the classification assertions made here.
///
/// `u32` sequence wraparound is exercised by seeding a session's
/// starting wire sequence near `u32::MAX` through direct construction,
/// the same way `open_machine` above builds an open session without
/// running the handshake. The repository's `AGENTS.md` testing policy
/// names "sequence wrap" as a sanctioned reason to construct state
/// directly rather than drive billions of real sends.
mod model_properties {
use std::collections::{HashSet, VecDeque};
use proptest::prelude::*;
use proptest::test_runner::TestCaseError;
use super::*;
use irtt_proto::{encode_echo_reply, ReceivedStats, StampAt};
const TOKEN: u64 = 0x1122_3344_5566_7788;
const WRONG_TOKEN: u64 = 0x8877_6655_4433_2211;
const PROBE_TIMEOUT: Duration = Duration::from_millis(30);
/// Far larger than the generated operation count (`1..=OP_LIMIT`),
/// so pending/timed-out/completed bounded eviction never triggers;
/// see the module doc comment.
const MAX_PENDING_PROBES: usize = 4096;
const OP_LIMIT: usize = 50;
const CASES: u32 = 256;
fn model_config() -> ClientConfig {
ClientConfig {
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
clock: Clock::Wall,
probe_timeout: PROBE_TIMEOUT,
max_pending_probes: MAX_PENDING_PROBES,
run_mode: RunMode::Normal,
..ClientConfig::default()
}
}
/// Builds an already-`Open` session directly, the same way `mod
/// tests`'s `open_machine` does, starting its wire sequence at
/// `start_seq` instead of always zero. See the module doc comment
/// for why this bypasses the open handshake and for the sequence
/// seeding rationale.
fn open_machine_for_model(start_seq: u32) -> (SessionMachine, Params) {
let config = model_config();
let remote = "127.0.0.1:2112".parse().unwrap();
let mut machine = SessionMachine::new(config, remote).unwrap();
let params = machine.requested.clone();
let negotiated = NegotiatedParams {
params: params.clone(),
restrictions: Vec::new(),
};
machine.state = MachineState::Open(Box::new(ActiveSession {
token: TOKEN,
negotiated,
local_close_packet: encode_request(RequestToEncode::Close { token: TOKEN }, None)
.unwrap()
.into_boxed_slice(),
next_wire_seq: start_seq,
highest_received_seq: None,
packets_sent: 0,
pending: PendingMap::new(MAX_PENDING_PROBES),
timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
completed: CompletedSet::new(MAX_PENDING_PROBES),
}));
(machine, params)
}
fn build_echo_reply(params: &Params, token: u64, seq: u32, close: bool) -> Vec<u8> {
let mut flags_value = flags::FLAG_REPLY;
if close {
flags_value |= flags::FLAG_CLOSE;
}
let reply = EchoReply {
flags: flags_value,
token,
sequence: seq,
recv_count: None,
recv_window: None,
timestamps: TimestampFields::default(),
payload: Vec::new(),
};
encode_echo_reply(&reply, params, None).unwrap()
}
/// Independently-authored mirror of the sequence-space half-window
/// "after" comparison (RFC 1982 style), used only to predict
/// expected late/in-window classification. Deliberately not a call
/// into `super::sequence_is_after`/`sequence_is_before`, so a bug in
/// the production comparator is not automatically invisible to this
/// model.
fn seq_after(candidate: u32, current: u32) -> bool {
candidate != current && candidate.wrapping_sub(current) < (1 << 31)
}
fn seq_before(candidate: u32, current: u32) -> bool {
seq_after(current, candidate)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelState {
Open,
ClosedLocal,
ClosedPeer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ExpectedEvent {
EchoReply(u32),
LateReply(u32),
DuplicateReply(u32),
WarnWrongToken,
WarnUntracked,
SessionClosed,
}
/// The independent reference model. See the module doc comment for
/// its deliberate boundary against `SessionMachine`'s private state.
struct Model {
state: ModelState,
next_wire_seq: u32,
packets_sent: u64,
pending: VecDeque<(u32, Instant)>,
timed_out: HashSet<u32>,
completed: HashSet<u32>,
highest_received_seq: Option<u32>,
}
impl Model {
fn new(start_seq: u32) -> Self {
Self {
state: ModelState::Open,
next_wire_seq: start_seq,
packets_sent: 0,
pending: VecDeque::new(),
timed_out: HashSet::new(),
completed: HashSet::new(),
highest_received_seq: None,
}
}
fn pending_remove(&mut self, seq: u32) -> bool {
if let Some(pos) = self.pending.iter().position(|&(s, _)| s == seq) {
self.pending.remove(pos);
true
} else {
false
}
}
fn sorted_pending(&self) -> Vec<u32> {
let mut v: Vec<u32> = self.pending.iter().map(|&(s, _)| s).collect();
v.sort_unstable();
v
}
fn sorted_timed_out(&self) -> Vec<u32> {
let mut v: Vec<u32> = self.timed_out.iter().copied().collect();
v.sort_unstable();
v
}
fn sorted_completed(&self) -> Vec<u32> {
let mut v: Vec<u32> = self.completed.iter().copied().collect();
v.sort_unstable();
v
}
/// Resolves a generated [`ReplyTarget`] to a concrete sequence
/// number against the model's *current* membership. When the
/// requested bucket is empty this falls back to the raw index as
/// a literal (likely-unknown) sequence, so every `ReplyTarget`
/// variant remains a valid, meaningful generator input
/// regardless of what has happened so far.
fn resolve(&self, target: ReplyTarget) -> u32 {
fn pick(v: &[u32], i: u32) -> u32 {
if v.is_empty() {
i
} else {
v[(i as usize) % v.len()]
}
}
match target {
ReplyTarget::Pending(i) => pick(&self.sorted_pending(), i),
ReplyTarget::TimedOut(i) => pick(&self.sorted_timed_out(), i),
ReplyTarget::Completed(i) => pick(&self.sorted_completed(), i),
ReplyTarget::Unknown(seq) => seq,
}
}
fn update_highest(&mut self, seq: u32) {
self.highest_received_seq = Some(match self.highest_received_seq {
None => seq,
Some(h) if seq_after(seq, h) => seq,
Some(h) => h,
});
}
fn apply_send(&mut self, seq: u32, timeout_at: Instant) {
assert_eq!(
seq, self.next_wire_seq,
"model/machine wire sequence desync"
);
self.pending.push_back((seq, timeout_at));
self.next_wire_seq = self.next_wire_seq.wrapping_add(1);
self.packets_sent += 1;
}
fn apply_poll(&mut self, now: Instant, limit: usize) -> (Vec<u32>, bool) {
let mut expired = Vec::new();
while expired.len() < limit {
let Some(&(seq, timeout_at)) = self.pending.front() else {
break;
};
if timeout_at > now {
break;
}
self.pending.pop_front();
self.timed_out.insert(seq);
expired.push(seq);
}
let more_due = self
.pending
.front()
.is_some_and(|&(_, timeout_at)| timeout_at <= now);
(expired, more_due)
}
/// Mirrors `SessionMachine::process_echo_reply`'s classification
/// exactly (see that function's branches), mutating this model
/// the same way. Returns the event kinds a correct
/// implementation must produce, in order.
fn reply(&mut self, seq: u32, wrong_token: bool, close: bool) -> Vec<ExpectedEvent> {
if wrong_token {
// A wrong-token reply is rejected before the close flag
// is ever inspected, so `close` has no effect here.
return vec![ExpectedEvent::WarnWrongToken];
}
let mut events = Vec::new();
if self.pending_remove(seq) {
let is_late = self
.highest_received_seq
.is_some_and(|h| seq_before(seq, h));
self.update_highest(seq);
self.completed.insert(seq);
events.push(if is_late {
ExpectedEvent::LateReply(seq)
} else {
ExpectedEvent::EchoReply(seq)
});
} else if self.completed.contains(&seq) {
self.update_highest(seq);
events.push(ExpectedEvent::DuplicateReply(seq));
} else if self.timed_out.remove(&seq) {
self.update_highest(seq);
self.completed.insert(seq);
events.push(ExpectedEvent::LateReply(seq));
} else if self
.highest_received_seq
.is_some_and(|h| seq_before(seq, h))
{
events.push(ExpectedEvent::LateReply(seq));
} else {
events.push(ExpectedEvent::WarnUntracked);
}
if close {
self.state = ModelState::ClosedPeer;
events.push(ExpectedEvent::SessionClosed);
}
events
}
}
fn classify_real(events: &[ClientEvent]) -> Vec<ExpectedEvent> {
events
.iter()
.map(|event| match event {
ClientEvent::EchoReply { seq, .. } => ExpectedEvent::EchoReply(*seq),
ClientEvent::LateReply { seq, .. } => ExpectedEvent::LateReply(*seq),
ClientEvent::DuplicateReply { seq, .. } => ExpectedEvent::DuplicateReply(*seq),
ClientEvent::Warning {
kind: WarningKind::WrongToken,
..
} => ExpectedEvent::WarnWrongToken,
ClientEvent::Warning {
kind: WarningKind::UntrackedReply,
..
} => ExpectedEvent::WarnUntracked,
ClientEvent::SessionClosed { .. } => ExpectedEvent::SessionClosed,
other => panic!("unexpected event from an open session: {other:?}"),
})
.collect()
}
fn is_already_closed<T: std::fmt::Debug>(result: &Result<T, ClientError>) -> bool {
matches!(result, Err(ClientError::AlreadyClosed))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Observable {
is_open: bool,
is_terminal: bool,
is_peer_closed: bool,
packets_sent: u64,
pending_is_empty: bool,
}
fn observe(machine: &SessionMachine) -> Observable {
Observable {
is_open: machine.is_open(),
is_terminal: machine.is_terminal(),
is_peer_closed: machine.is_peer_closed(),
packets_sent: machine.packets_sent(),
pending_is_empty: machine.pending_is_empty(),
}
}
fn check_generic_invariants(
machine: &SessionMachine,
model: &Model,
) -> Result<(), TestCaseError> {
prop_assert_eq!(machine.is_open(), model.state == ModelState::Open);
prop_assert_eq!(
machine.is_terminal(),
matches!(
model.state,
ModelState::ClosedLocal | ModelState::ClosedPeer
)
);
prop_assert_eq!(
machine.is_peer_closed(),
model.state == ModelState::ClosedPeer
);
prop_assert_eq!(machine.packets_sent(), model.packets_sent);
if model.state == ModelState::Open {
prop_assert_eq!(machine.pending_is_empty(), model.pending.is_empty());
} else {
prop_assert!(machine.pending_is_empty());
}
Ok(())
}
/// Drives the full real send transaction: prepare, preflight,
/// finalize, commit. Mirrors how `Client`/`AsyncClient` drive
/// `SessionMachine` around a real socket send, using `now` for both
/// the pre-send anchor and the post-send `sent_at` sample since no
/// real send elapses time here.
fn do_send(
machine: &mut SessionMachine,
now: ClientTimestamp,
) -> Result<ProbeSent, ClientError> {
let prepared = machine
.prepare_probe()?
.expect("prepare_probe always returns Some when Ok");
let preflight = machine.preflight_probe_commit(&prepared)?;
let commit = machine.finalize_probe_commit(preflight, now)?;
let bytes = prepared.bytes.len();
Ok(machine.commit_probe_sent(commit, now, bytes))
}
#[derive(Debug, Clone, Copy)]
enum ReplyTarget {
Pending(u32),
TimedOut(u32),
Completed(u32),
Unknown(u32),
}
#[derive(Debug, Clone, Copy)]
enum Op {
Send,
StaleProbe,
PrepareAbandon,
Reply {
target: ReplyTarget,
wrong_token: bool,
close: bool,
},
PollTimeouts,
PollTimeoutsBounded(u8),
AdvanceClock(u16),
LocalClose,
ReopenAttempt,
}
fn op_strategy() -> impl Strategy<Value = Op> {
let reply_target = prop_oneof![
any::<u32>().prop_map(ReplyTarget::Pending),
any::<u32>().prop_map(ReplyTarget::TimedOut),
any::<u32>().prop_map(ReplyTarget::Completed),
any::<u32>().prop_map(ReplyTarget::Unknown),
];
prop_oneof![
4 => Just(Op::Send),
1 => Just(Op::StaleProbe),
1 => Just(Op::PrepareAbandon),
5 => (reply_target, any::<bool>(), any::<bool>()).prop_map(
|(target, wrong_token, close)| Op::Reply {
target,
wrong_token,
close,
}
),
2 => Just(Op::PollTimeouts),
1 => (0u8..=6).prop_map(Op::PollTimeoutsBounded),
4 => (0u16..=120).prop_map(Op::AdvanceClock),
1 => Just(Op::LocalClose),
1 => Just(Op::ReopenAttempt),
]
}
/// Biased toward `0` (the common case) and toward the last few
/// values before `u32::MAX` (so a handful of sends from here cross
/// the wraparound boundary), with a uniformly random third case for
/// general coverage.
fn start_seq_strategy() -> impl Strategy<Value = u32> {
prop_oneof![
2 => Just(0u32),
3 => (u32::MAX - 4)..=u32::MAX,
2 => any::<u32>(),
]
}
fn apply_op(
machine: &mut SessionMachine,
model: &mut Model,
params: &Params,
now: &mut ClientTimestamp,
op: Op,
) -> Result<(), TestCaseError> {
match op {
Op::Send => {
let result = do_send(machine, *now);
match model.state {
ModelState::Open => {
let sent = result.expect(
"open session send should succeed within chosen capacity bounds",
);
prop_assert_eq!(sent.seq, model.next_wire_seq);
let timeout_at = now
.mono
.checked_add(PROBE_TIMEOUT)
.expect("no overflow at test timescales");
model.apply_send(sent.seq, timeout_at);
}
_ => prop_assert!(
is_already_closed(&result),
"expected AlreadyClosed, got {:?}",
result
),
}
}
Op::StaleProbe => {
if model.state != ModelState::Open {
let result = machine.prepare_probe();
prop_assert!(
is_already_closed(&result),
"expected AlreadyClosed, got {:?}",
result
);
} else {
let prepared_a = machine
.prepare_probe()
.unwrap()
.expect("open session prepares probes");
prop_assert_eq!(prepared_a.seq, model.next_wire_seq);
let sent = do_send(machine, *now).expect(
"intervening send should succeed within chosen capacity bounds",
);
prop_assert_eq!(sent.seq, prepared_a.seq);
let timeout_at = now
.mono
.checked_add(PROBE_TIMEOUT)
.expect("no overflow at test timescales");
model.apply_send(sent.seq, timeout_at);
match machine.preflight_probe_commit(&prepared_a) {
Err(ClientError::StalePreparedProbe {
prepared_seq,
next_wire_seq,
}) => {
prop_assert_eq!(prepared_seq, prepared_a.seq);
prop_assert_eq!(next_wire_seq, model.next_wire_seq);
}
other => {
prop_assert!(false, "expected StalePreparedProbe, got {:?}", other)
}
}
}
}
Op::PrepareAbandon => {
let before = observe(machine);
let result = machine.prepare_probe();
match model.state {
ModelState::Open => {
let prepared = result.unwrap().expect("open session prepares probes");
prop_assert_eq!(prepared.seq, model.next_wire_seq);
prop_assert!(!prepared.bytes.is_empty());
}
_ => prop_assert!(
is_already_closed(&result),
"expected AlreadyClosed, got {:?}",
result
),
}
prop_assert_eq!(observe(machine), before);
}
Op::Reply {
target,
wrong_token,
close,
} => {
let seq = model.resolve(target);
let token = if wrong_token { WRONG_TOKEN } else { TOKEN };
let packet = build_echo_reply(params, token, seq, close);
let result =
machine.process_received_echo_packet(&packet, *now, ReceiveMeta::default());
match model.state {
ModelState::Open => {
let events = result.expect("open session processes echo packets");
let expected = model.reply(seq, wrong_token, close);
prop_assert_eq!(classify_real(&events), expected);
}
_ => prop_assert!(
is_already_closed(&result),
"expected AlreadyClosed, got {:?}",
result
),
}
}
Op::PollTimeouts => {
let result = machine.poll_timeouts_at(now.mono);
match model.state {
ModelState::Open => {
let events = result.expect("open session polls succeed");
let (expired, _more_due) = model.apply_poll(now.mono, usize::MAX);
let real: Vec<u32> = events
.iter()
.map(|event| match event {
ClientEvent::EchoLoss { seq, .. } => *seq,
other => {
panic!("unexpected event from poll_timeouts_at: {other:?}")
}
})
.collect();
prop_assert_eq!(real, expired);
}
_ => prop_assert!(
is_already_closed(&result),
"expected AlreadyClosed, got {:?}",
result
),
}
}
Op::PollTimeoutsBounded(limit) => {
let result = machine.poll_timeouts_bounded_at(now.mono, limit as usize);
match model.state {
ModelState::Open => {
let batch = result.expect("open session polls succeed");
let (expired, more_due) = model.apply_poll(now.mono, limit as usize);
let real: Vec<u32> = batch
.events
.iter()
.map(|event| match event {
ClientEvent::EchoLoss { seq, .. } => *seq,
other => panic!(
"unexpected event from poll_timeouts_bounded_at: {other:?}"
),
})
.collect();
prop_assert_eq!(real, expired);
prop_assert_eq!(batch.more_due, more_due);
}
_ => prop_assert!(
is_already_closed(&result),
"expected AlreadyClosed, got {:?}",
result
),
}
}
Op::AdvanceClock(millis) => {
let delta = Duration::from_millis(u64::from(millis));
now.mono += delta;
now.wall += delta;
}
Op::LocalClose => match model.state {
ModelState::Open => {
let PreparedClose { commit, .. } = machine
.prepare_close()
.expect("open session can prepare close");
let event = machine.commit_local_close(commit, *now);
let is_session_closed = matches!(event, ClientEvent::SessionClosed { .. });
prop_assert!(is_session_closed);
model.state = ModelState::ClosedLocal;
}
_ => {
let result = machine.prepare_close();
prop_assert!(
is_already_closed(&result),
"expected AlreadyClosed, got {:?}",
result
);
}
},
Op::ReopenAttempt => {
let before = observe(machine);
let result = machine.prepare_open_request();
match model.state {
ModelState::Open => {
prop_assert!(matches!(result, Err(ClientError::AlreadyOpen)));
}
ModelState::ClosedLocal | ModelState::ClosedPeer => {
prop_assert!(is_already_closed(&result));
}
}
prop_assert_eq!(observe(machine), before);
}
}
check_generic_invariants(machine, model)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(CASES))]
/// Drives a generated sequence of operations through both a real,
/// already-`Open` `SessionMachine` and an independent reference
/// [`Model`], asserting after every operation that the observable
/// state (open/terminal/peer-closed/packets-sent/pending-empty)
/// matches, every error returned matches the model's
/// expectation, and every event/classification produced for a
/// probe send, a reply, or a timeout poll matches the model's
/// prediction. No operation should ever panic.
#[test]
fn open_session_matches_reference_model(
start_seq in start_seq_strategy(),
ops in proptest::collection::vec(op_strategy(), 1..=OP_LIMIT),
) {
let (mut machine, params) = open_machine_for_model(start_seq);
let mut model = Model::new(start_seq);
let mut now = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::now(),
};
check_generic_invariants(&machine, &model)?;
for op in ops {
apply_op(&mut machine, &mut model, &params, &mut now, op)?;
}
}
}
}
}
+3
View File
@@ -16,3 +16,6 @@ hmac = "0.13"
md-5 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.20"
[dev-dependencies]
proptest = "1.11.0"
+336
View File
@@ -764,4 +764,340 @@ mod tests {
let encoded = [99];
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
}
mod properties {
use super::*;
use proptest::prelude::*;
fn received_stats_strategy() -> impl Strategy<Value = ReceivedStats> {
prop_oneof![
Just(ReceivedStats::None),
Just(ReceivedStats::Count),
Just(ReceivedStats::Window),
Just(ReceivedStats::Both),
]
}
fn stamp_at_strategy() -> impl Strategy<Value = StampAt> {
prop_oneof![
Just(StampAt::None),
Just(StampAt::Send),
Just(StampAt::Receive),
Just(StampAt::Both),
Just(StampAt::Midpoint),
]
}
/// A `Clock` valid for direct construction, including `Unspecified`
/// (wire omission). Never produces an explicit zero on the wire; see
/// `explicit_clock_strategy` for that.
fn clock_strategy() -> impl Strategy<Value = Clock> {
prop_oneof![
Just(Clock::Unspecified),
Just(Clock::Wall),
Just(Clock::Monotonic),
Just(Clock::Both),
]
}
/// A `Clock` value valid to encode *explicitly* on the wire (tag 7).
/// Excludes `Unspecified`/zero, which is only ever reached by omission.
fn explicit_clock_value_strategy() -> impl Strategy<Value = i64> {
1_i64..=3
}
fn server_fill_strategy() -> impl Strategy<Value = Option<ServerFill>> {
prop_oneof![
Just(None),
proptest::collection::vec(proptest::char::any(), 0..=8).prop_map(|chars| Some(
ServerFill {
value: chars.into_iter().collect(),
}
)),
]
}
fn params_strategy() -> impl Strategy<Value = Params> {
(
any::<i64>(),
any::<i64>(),
any::<i64>(),
any::<i64>(),
any::<i64>(),
received_stats_strategy(),
stamp_at_strategy(),
clock_strategy(),
server_fill_strategy(),
)
.prop_map(
|(
protocol_version,
duration_ns,
interval_ns,
length,
dscp,
received_stats,
stamp_at,
clock,
server_fill,
)| Params {
protocol_version,
duration_ns,
interval_ns,
length,
received_stats,
stamp_at,
clock,
dscp,
server_fill,
},
)
}
fn encode_tagged_int(tag: u64, value: i64, out: &mut Vec<u8>) {
varint::encode_uvarint(tag, out);
varint::encode_varint(value, out);
}
fn encode_tagged_server_fill(value: &str, out: &mut Vec<u8>) {
varint::encode_uvarint(9, out);
varint::encode_uvarint(value.len() as u64, out);
out.extend_from_slice(value.as_bytes());
}
proptest! {
#[test]
fn valid_params_round_trip(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode(&encoded).unwrap();
prop_assert_eq!(decoded, params);
}
#[test]
fn encoded_param_presence_matches_omission_rules(params in params_strategy()) {
let encoded = params.encode();
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(&decoded.params, &params);
prop_assert_eq!(decoded.presence.protocol_version, params.protocol_version != 0);
prop_assert_eq!(decoded.presence.duration_ns, params.duration_ns != 0);
prop_assert_eq!(decoded.presence.interval_ns, params.interval_ns != 0);
prop_assert_eq!(decoded.presence.length, params.length != 0);
prop_assert_eq!(decoded.presence.dscp, params.dscp != 0);
prop_assert_eq!(
decoded.presence.received_stats,
params.received_stats != ReceivedStats::None
);
prop_assert_eq!(decoded.presence.stamp_at, params.stamp_at != StampAt::None);
prop_assert_eq!(decoded.presence.clock, params.clock != Clock::Unspecified);
prop_assert_eq!(decoded.presence.server_fill, params.server_fill.is_some());
}
#[test]
fn repeated_protocol_version_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(1, first, &mut encoded);
encode_tagged_int(1, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.protocol_version, second);
prop_assert!(decoded.presence.protocol_version);
}
#[test]
fn repeated_duration_ns_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(2, first, &mut encoded);
encode_tagged_int(2, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.duration_ns, second);
prop_assert!(decoded.presence.duration_ns);
}
#[test]
fn repeated_interval_ns_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(3, first, &mut encoded);
encode_tagged_int(3, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.interval_ns, second);
prop_assert!(decoded.presence.interval_ns);
}
#[test]
fn repeated_length_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(4, first, &mut encoded);
encode_tagged_int(4, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.length, second);
prop_assert!(decoded.presence.length);
}
#[test]
fn repeated_received_stats_is_last_wins(first in 0_i64..=3, second in 0_i64..=3) {
let mut encoded = Vec::new();
encode_tagged_int(5, first, &mut encoded);
encode_tagged_int(5, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.received_stats, ReceivedStats::try_from(second).unwrap());
prop_assert!(decoded.presence.received_stats);
}
#[test]
fn repeated_stamp_at_is_last_wins(first in 0_i64..=4, second in 0_i64..=4) {
let mut encoded = Vec::new();
encode_tagged_int(6, first, &mut encoded);
encode_tagged_int(6, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.stamp_at, StampAt::try_from(second).unwrap());
prop_assert!(decoded.presence.stamp_at);
}
#[test]
fn repeated_clock_is_last_wins(
first in explicit_clock_value_strategy(),
second in explicit_clock_value_strategy(),
) {
let mut encoded = Vec::new();
encode_tagged_int(7, first, &mut encoded);
encode_tagged_int(7, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.clock, Clock::try_from(second).unwrap());
prop_assert!(decoded.presence.clock);
}
#[test]
fn repeated_dscp_is_last_wins(first: i64, second: i64) {
let mut encoded = Vec::new();
encode_tagged_int(8, first, &mut encoded);
encode_tagged_int(8, second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(decoded.params.dscp, second);
prop_assert!(decoded.presence.dscp);
}
#[test]
fn repeated_server_fill_is_last_wins(
first in proptest::collection::vec(proptest::char::any(), 0..=8)
.prop_map(|chars| chars.into_iter().collect::<String>()),
second in proptest::collection::vec(proptest::char::any(), 0..=8)
.prop_map(|chars| chars.into_iter().collect::<String>()),
) {
let mut encoded = Vec::new();
encode_tagged_server_fill(&first, &mut encoded);
encode_tagged_server_fill(&second, &mut encoded);
let decoded = Params::decode_with_presence(&encoded).unwrap();
prop_assert_eq!(
decoded.params.server_fill,
Some(ServerFill { value: second })
);
prop_assert!(decoded.presence.server_fill);
}
#[test]
fn unknown_scalar_tag_prefix_does_not_disturb_known_params(
params in params_strategy(),
unknown_tag in 10_u64..=u16::MAX as u64,
unknown_value: u64,
) {
let mut prefixed = Vec::new();
varint::encode_uvarint(unknown_tag, &mut prefixed);
varint::encode_uvarint(unknown_value, &mut prefixed);
prefixed.extend_from_slice(&params.encode());
let decoded = Params::decode(&prefixed).unwrap();
prop_assert_eq!(decoded, params.clone());
let decoded_with_presence = Params::decode_with_presence(&prefixed).unwrap();
let expected_presence = Params::decode_with_presence(&params.encode()).unwrap().presence;
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
}
#[test]
fn unknown_scalar_tag_suffix_does_not_disturb_known_params(
params in params_strategy(),
unknown_tag in 10_u64..=u16::MAX as u64,
unknown_value: u64,
) {
let mut suffixed = params.encode();
varint::encode_uvarint(unknown_tag, &mut suffixed);
varint::encode_uvarint(unknown_value, &mut suffixed);
let decoded = Params::decode(&suffixed).unwrap();
prop_assert_eq!(decoded, params.clone());
let decoded_with_presence = Params::decode_with_presence(&suffixed).unwrap();
let expected_presence = Params::decode_with_presence(&params.encode()).unwrap().presence;
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
}
}
/// Omitted-vs-explicit-zero presence, for the fields where explicit
/// zero is valid on the wire. `Clock` is deliberately excluded: an
/// explicit zero there is rejected outright, not merely "present with
/// the default value" (see `explicit_clock_zero_is_rejected`).
#[test]
fn omitted_vs_explicit_zero_presence_for_zero_valid_fields() {
let scalar_tags = [1_u64, 2, 3, 4, 8];
for tag in scalar_tags {
let omitted = Params::decode_with_presence(&[]).unwrap();
let mut explicit_zero = Vec::new();
encode_tagged_int(tag, 0, &mut explicit_zero);
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
assert_eq!(omitted.params, explicit.params);
assert_eq!(explicit.params, Params::default());
let (omitted_presence, explicit_presence) = match tag {
1 => (
omitted.presence.protocol_version,
explicit.presence.protocol_version,
),
2 => (omitted.presence.duration_ns, explicit.presence.duration_ns),
3 => (omitted.presence.interval_ns, explicit.presence.interval_ns),
4 => (omitted.presence.length, explicit.presence.length),
8 => (omitted.presence.dscp, explicit.presence.dscp),
_ => unreachable!(),
};
assert!(!omitted_presence);
assert!(explicit_presence);
}
let enum_tags = [5_u64, 6];
for tag in enum_tags {
let omitted = Params::decode_with_presence(&[]).unwrap();
let mut explicit_zero = Vec::new();
encode_tagged_int(tag, 0, &mut explicit_zero);
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
assert_eq!(omitted.params, explicit.params);
let (omitted_presence, explicit_presence) = match tag {
5 => (
omitted.presence.received_stats,
explicit.presence.received_stats,
),
6 => (omitted.presence.stamp_at, explicit.presence.stamp_at),
_ => unreachable!(),
};
assert!(!omitted_presence);
assert!(explicit_presence);
}
// `Clock` deliberately breaks the pattern above: explicit zero is
// rejected rather than accepted-but-present.
let omitted = Params::decode_with_presence(&[]).unwrap();
assert_eq!(omitted.params.clock, Clock::Unspecified);
assert!(!omitted.presence.clock);
let mut explicit_clock_zero = Vec::new();
encode_tagged_int(7, 0, &mut explicit_clock_zero);
assert_eq!(
Params::decode_with_presence(&explicit_clock_zero),
Err(ProtoError::InvalidEnum {
name: "Clock",
value: 0,
})
);
}
}
}
+39
View File
@@ -112,4 +112,43 @@ mod tests {
encode_uvarint(24, &mut tag);
assert_eq!(tag, [0x18]);
}
mod properties {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn uvarint_round_trips(value: u64) {
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
let (decoded, used) = decode_uvarint(&encoded).unwrap();
prop_assert_eq!(decoded, value);
prop_assert_eq!(used, encoded.len());
}
#[test]
fn signed_varint_round_trips(value: i64) {
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
prop_assert!(!encoded.is_empty());
prop_assert!(encoded.len() <= 10);
let (decoded, used) = decode_varint(&encoded).unwrap();
prop_assert_eq!(decoded, value);
prop_assert_eq!(used, encoded.len());
}
#[test]
fn zigzag_round_trips_from_signed(value: i64) {
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
}
#[test]
fn zigzag_round_trips_from_unsigned(value: u64) {
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
}
}
}
}
+23 -1
View File
@@ -109,6 +109,28 @@ impl Server {
socket: UdpSocket,
config: ServerConfig,
) -> Result<Self, ServerRuntimeError> {
Self::with_core(socket, ServerCore::new(config))
}
/// Test-only seam: builds a listener around an already-constructed core
/// instead of one this method would build from a [`ServerConfig`].
///
/// This exists so a unit test can substitute a scripted
/// [`TokenSource`](crate::token::TokenSource) and make one listener in a
/// [`ServerSet`](crate::ServerSet) fail deterministically after it has
/// already bound and answered real traffic — the runtime otherwise offers
/// no way to make a core failure happen on command without OS-level
/// socket sabotage. Every other code path here — wildcard setup, kernel
/// timestamp configuration — is unchanged from [`Server::from_socket`].
#[cfg(test)]
pub(crate) fn from_socket_with_core(
socket: UdpSocket,
core: ServerCore,
) -> Result<Self, ServerRuntimeError> {
Self::with_core(socket, core)
}
fn with_core(socket: UdpSocket, core: ServerCore) -> Result<Self, ServerRuntimeError> {
let addr = socket
.local_addr()
.map_err(|source| ServerRuntimeError::LocalAddr { source })?;
@@ -131,7 +153,7 @@ impl Server {
Ok(Self {
socket,
core: ServerCore::new(config),
core,
recv_buffer: vec![0; RECEIVE_BUFFER_LEN],
listener_is_ipv4: marks_with_ipv4_option(addr),
select_reply_source,
+161
View File
@@ -323,7 +323,10 @@ pub enum ServerSetError {
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::{token::TokenSource, ServerCore, ServerError};
/// Which listener of a requested set has to be bound IPv6-only.
///
@@ -409,4 +412,162 @@ mod tests {
Err(ServerSetError::NoListeners)
));
}
/// A token source that draws one value successfully and then fails every
/// later draw.
///
/// This is the same seam the core's own tests already use to make token
/// allocation fail deterministically (see [`crate::token::TokenSource`]
/// and its `ScriptedTokens::failing`), specialized here so the *first*
/// open still succeeds: the listener built from it answers one real
/// exchange exactly like a healthy listener would, and only a second open
/// discovers the failure.
#[derive(Debug)]
struct FailAfterFirstToken(Option<u64>);
impl TokenSource for FailAfterFirstToken {
fn next_token(&mut self) -> Result<u64, ServerError> {
self.0.take().ok_or_else(|| ServerError::RandomSource {
reason: "test fault injection: exhausted after the first token".to_owned(),
})
}
}
const FIRST_TOKEN: u64 = 0xdead_beef_dead_beef;
const TEST_TIMEOUT: Duration = Duration::from_secs(5);
const REPLY_TIMEOUT: Duration = Duration::from_secs(1);
fn loopback(port: u16) -> SocketAddr {
SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, port))
}
fn requested_params() -> irtt_proto::Params {
irtt_proto::Params {
protocol_version: 1,
duration_ns: 10_000_000_000,
interval_ns: 100_000_000,
received_stats: irtt_proto::ReceivedStats::Both,
stamp_at: irtt_proto::StampAt::Both,
clock: irtt_proto::Clock::Both,
..irtt_proto::Params::default()
}
}
async fn send_open(client: &UdpSocket, addr: SocketAddr) {
let request = irtt_proto::encode_request(
irtt_proto::RequestToEncode::Open {
params: &requested_params(),
no_test: false,
},
None,
)
.unwrap();
client.send_to(&request, addr).await.unwrap();
}
/// Sends one open request and returns its reply, or `None` if the
/// listener stayed silent within [`REPLY_TIMEOUT`].
async fn open(client: &UdpSocket, addr: SocketAddr) -> Option<irtt_proto::OpenReply> {
send_open(client, addr).await;
let mut buffer = [0u8; 4096];
match tokio::time::timeout(REPLY_TIMEOUT, client.recv_from(&mut buffer)).await {
Ok(received) => {
let (len, from) = received.unwrap();
assert_eq!(from, addr, "a reply came from the wrong listener");
Some(irtt_proto::decode_open_reply(&buffer[..len], None).unwrap())
}
Err(_) => None,
}
}
/// A fatal failure inside one listener, after every listener in the set
/// has already bound and answered real traffic, shuts its siblings down,
/// joins every task and fails the set — with no external shutdown ever
/// sent.
///
/// The failure is injected the same way the core's own tests already
/// drive a token-allocation failure: a scripted
/// [`crate::token::TokenSource`] substituted for one listener's
/// [`crate::ServerCore`], through the test-only
/// [`Server::from_socket_with_core`]. That source draws one token
/// successfully, so the listener built from it answers a first open
/// exactly like its healthy sibling does — which is the startup/readiness
/// synchronization this test relies on: both are bound and actively
/// serving real traffic before anything is asked to fail, not merely
/// spawned. Only a *second* open against that listener draws its second
/// token, which the scripted source refuses, and that single draw is the
/// one injected fatal failure. Nothing about the socket, the receive loop
/// or the sibling listener is touched.
#[tokio::test(flavor = "current_thread")]
async fn a_mid_run_listener_failure_shuts_down_its_siblings_and_fails_the_set() {
let config = ServerConfig::default().with_min_send_interval(Duration::ZERO);
let failing_socket = UdpSocket::bind(loopback(0)).await.unwrap();
let failing_core = ServerCore::with_token_source(
config.clone(),
Box::new(FailAfterFirstToken(Some(FIRST_TOKEN))),
);
let failing = Server::from_socket_with_core(failing_socket, failing_core).unwrap();
let failing_addr = failing.local_addr().unwrap();
let healthy_socket = UdpSocket::bind(loopback(0)).await.unwrap();
let healthy = Server::from_socket(healthy_socket, config).unwrap();
let healthy_addr = healthy.local_addr().unwrap();
let set = ServerSet {
servers: vec![failing, healthy],
local_addrs: vec![failing_addr, healthy_addr],
};
// A shutdown future that never resolves: whatever stops the set has
// to come from the injected failure, not from this caller.
let run = tokio::spawn(set.run(std::future::pending::<()>()));
let client = UdpSocket::bind(loopback(0)).await.unwrap();
// Both listeners answer a first, ordinary open before anything fails.
let opened = open(&client, failing_addr)
.await
.expect("the failing listener is bound and serving before it fails");
assert_eq!(opened.token, FIRST_TOKEN);
assert!(
open(&client, healthy_addr).await.is_some(),
"the healthy listener is bound and serving too"
);
// The second open at the failing listener draws its second token,
// which the scripted source refuses. It terminates that listener
// before any reply could be built, so this exchange places no
// expectation on a reply.
send_open(&client, failing_addr).await;
let outcome = tokio::time::timeout(TEST_TIMEOUT, run)
.await
.expect("the set stopped on its own, with no external shutdown sent")
.expect("the supervising task did not panic");
match outcome {
Err(ServerSetError::ListenerRun { addr, source }) => {
assert_eq!(
addr, failing_addr,
"the failure names the listener that actually failed"
);
assert!(
matches!(
source,
ServerRuntimeError::Core(ServerError::RandomSource { .. })
),
"unexpected failure source: {source}"
);
}
other => panic!("expected the injected listener failure, got {other:?}"),
}
// The sibling did not linger after the failure: its task was joined
// and its socket released, exactly like the listener that failed —
// proof neither task nor socket was leaked.
for addr in [healthy_addr, failing_addr] {
std::net::UdpSocket::bind(addr)
.unwrap_or_else(|error| panic!("{addr} was not released: {error}"));
}
}
}
+3
View File
@@ -13,3 +13,6 @@ categories = ["network-programming"]
[dependencies]
irtt-client.workspace = true
[dev-dependencies]
proptest = "1.11.0"
+55
View File
@@ -235,4 +235,59 @@ mod tests {
assert!(tracker.completed_pairs.len() <= limit, "seq {seq}");
}
}
mod properties {
use super::*;
use proptest::prelude::*;
proptest! {
/// The hand-written `ipdv_tracker_bounded_mode_limits_sequence_state`
/// test above only exercises a strictly increasing `0..512`
/// sequence. Real traffic can duplicate, skip, or otherwise not
/// strictly increase sequence numbers (see the client's
/// `LateReply`/`DuplicateReply` handling), so this generates
/// arbitrary, possibly-repeated, possibly-non-monotonic sequence
/// streams and checks the same bounds still hold after every
/// insert: `samples` and `sample_order` never exceed the
/// configured limit and stay in lockstep, and `completed_pairs`
/// never exceeds `samples.len()` (a successful entry can only
/// persist once both its endpoint samples are present, see
/// `try_pair`, so it is always a subset of the live sample keys).
#[test]
fn bounded_tracker_state_never_exceeds_the_limit(
limit in 1usize..8,
seqs in prop::collection::vec(0u32..40, 0..300),
) {
let mut tracker = IpdvTracker::new(Some(limit));
for seq in seqs {
tracker.insert(ipdv_sample(seq, i128::from(seq)));
prop_assert!(tracker.samples.len() <= limit);
prop_assert_eq!(tracker.sample_order.len(), tracker.samples.len());
prop_assert!(tracker.completed_pairs.len() <= tracker.samples.len());
}
}
/// A pair for a given adjacent `(seq - 1, seq)` boundary is only
/// ever emitted once, however many times `insert` is called
/// (including for already-seen or unrelated sequences in
/// between). This is the unbounded case, so eviction cannot be
/// the reason a pair fails to repeat.
#[test]
fn each_adjacent_pair_is_emitted_at_most_once(
seqs in prop::collection::vec(0u32..12, 0..200),
) {
let mut tracker = IpdvTracker::new(None);
let mut seen_pairs = std::collections::HashSet::new();
for seq in seqs {
for pair in tracker.insert(ipdv_sample(seq, i128::from(seq))) {
let key = (pair.previous_seq, pair.current_seq);
prop_assert!(
seen_pairs.insert(key),
"pair {key:?} emitted more than once"
);
}
}
}
}
}
}
@@ -0,0 +1,338 @@
//! Property tests for the count-based and time-based rolling windows.
//!
//! [`RollingEvents`] (in `src/rolling.rs`) keeps two independent `VecDeque`s:
//! one bounded by [`StatsConfig::rolling_count`] and one bounded by
//! [`StatsConfig::rolling_time`]. They are pushed to together but evicted
//! independently, and each exposes its own snapshot
//! ([`StatsCollector::rolling_count`] / [`StatsCollector::rolling_time`]).
//! Reading `rolling.rs` and the existing `rolling_count_eviction_recomputes_*`
//! / `rolling_time_eviction_uses_event_timestamps` tests in `tests/stats.rs`
//! confirms there is no single window bounded by *both* limits at once: the
//! two bounds do not compose as AND or OR on shared storage, they are simply
//! two separate parallel windows over the same input stream. This file proves
//! that independence generatively: with both bounds configured
//! simultaneously, each window must behave exactly as if the *other* bound
//! did not exist.
//!
//! The reference model is a plain `Vec<(ClientEvent, at_ms)>` history that is
//! re-filtered from scratch after every operation (once by count, once by
//! time), then replayed through a fresh [`StatsCollector`] to obtain a
//! [`Snapshot`]. Because [`StatsCollector::rolling_count`] /
//! [`StatsCollector::rolling_time`] are themselves computed by replaying the
//! retained events through a fresh `CoreStats` on every call (see
//! `rolling.rs`'s `snapshot_window`), and because both the production window
//! and this reference model process the *same* `ClientEvent` values in the
//! *same* order through the *same* pure, deterministic normalization and
//! aggregation code, the two snapshots are expected to be bit-for-bit equal,
//! not merely close. `Snapshot` derives `PartialEq`, so the comparison below
//! is exact `assert_eq!` with no floating-point tolerance: there is no
//! independent numerical computation on the reference side to accumulate
//! error against, both sides run the identical arithmetic in the identical
//! order.
//!
//! The reference model's time-window recomputation (filter the whole history
//! by `at_ms >= latest_at_ms - window_ms`) is only equivalent to the
//! production incremental sliding-eviction (which pops from the front using
//! the cutoff computed at each push) when event timestamps are pushed in
//! non-decreasing order, exactly as `normalization.rs` documents ("Rolling-
//! window eviction assumes events are pushed in non-decreasing `at()`
//! order"). The operation generator below enforces that by construction: a
//! shared clock only ever moves forward, and every generated event borrows
//! its `at()` timestamp from the clock's current value.
use std::time::{Duration, Instant, UNIX_EPOCH};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration,
};
use irtt_stats::{LateReplyMode, SampleMode, Snapshot, StatsCollector, StatsConfig};
use proptest::prelude::*;
/// One generated operation. `Advance` moves the shared monotonic clock
/// forward without producing an event; every other variant produces exactly
/// one normalized event at the clock's current value.
#[derive(Debug, Clone)]
enum Op {
/// Advance the shared clock by this many milliseconds.
Advance(u16),
/// A probe send.
Send,
/// An on-time unique reply with the given client-observed RTT.
Reply { raw_ms: u16 },
/// A late reply matched to retained send state (measurable).
LateReplyMatched { raw_ms: u16 },
/// A late reply that could not be matched to retained state.
LateReplyUnmatched,
/// A duplicate reply for an already-completed sequence.
Duplicate,
/// A probe timeout / loss.
Loss,
/// A diagnostic warning event.
Warning,
}
fn op_strategy() -> impl Strategy<Value = Op> {
prop_oneof![
3 => (0u16..40).prop_map(Op::Advance),
4 => Just(Op::Send),
4 => (0u16..60).prop_map(|raw_ms| Op::Reply { raw_ms }),
2 => (0u16..60).prop_map(|raw_ms| Op::LateReplyMatched { raw_ms }),
1 => Just(Op::LateReplyUnmatched),
1 => Just(Op::Duplicate),
2 => Just(Op::Loss),
1 => Just(Op::Warning),
]
}
const ADDR: &str = "127.0.0.1:2112";
/// Builds a `ClientTimestamp` purely by adding a millisecond offset to a
/// fixed `base` `Instant` (and to `UNIX_EPOCH` for the wall-clock half). No
/// operation below ever reads the real clock more than once (to establish
/// `base`), so the resulting timestamps are fully deterministic functions of
/// the generated `Op`s, with no wall-clock jitter to make a proptest shrink
/// or CI run flaky.
fn ts_at(base: Instant, ms: u64) -> ClientTimestamp {
ClientTimestamp {
mono: base + Duration::from_millis(ms),
wall: UNIX_EPOCH + Duration::from_millis(ms),
}
}
fn server_timing(seq: u32) -> ServerTiming {
let base_ns = i64::from(seq) * 10_000_000;
ServerTiming {
receive_mono_ns: Some(base_ns + 1_000_000),
send_mono_ns: Some(base_ns + 2_000_000),
receive_wall_ns: Some(base_ns + 1_000_000),
send_wall_ns: Some(base_ns + 2_000_000),
midpoint_mono_ns: None,
midpoint_wall_ns: None,
processing: Some(Duration::from_micros(100)),
}
}
fn one_way(seq: u32) -> OneWayDelaySample {
OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(1_000_000 + i128::from(seq))),
server_to_client: Some(SignedDuration::from_nanos(2_000_000 + i128::from(seq))),
}
}
/// Builds the `ClientEvent` for one non-`Advance` op. `clock_ms` is the
/// shared clock's current value, used as the event's windowing timestamp
/// (`at()`, see `normalization.rs`) for every variant.
fn build_event(op: &Op, seq: u32, clock_ms: u64, base: Instant) -> ClientEvent {
match *op {
Op::Advance(_) => unreachable!("Advance does not produce an event"),
Op::Send => ClientEvent::EchoSent {
seq,
remote: ADDR.parse().unwrap(),
scheduled_at: ts_at(base, clock_ms).mono,
sent_at: ts_at(base, clock_ms),
bytes: 32,
send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2),
},
Op::Reply { raw_ms } => {
let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms)));
let received_at = ts_at(base, clock_ms);
ClientEvent::EchoReply {
seq,
remote: ADDR.parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_millis(u64::from(raw_ms)),
adjusted: None,
effective: SignedDuration::from_duration(Duration::from_millis(u64::from(
raw_ms,
))),
},
server_timing: Some(server_timing(seq)),
one_way: Some(one_way(seq)),
received_stats: Some(ReceivedStatsSample {
count: Some(seq + 1),
window: Some(0xff),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
Op::LateReplyMatched { raw_ms } => {
let sent_at = ts_at(base, clock_ms.saturating_sub(u64::from(raw_ms)));
let received_at = ts_at(base, clock_ms);
ClientEvent::LateReply {
seq,
highest_seen: seq + 1,
remote: ADDR.parse().unwrap(),
sent_at: Some(sent_at),
received_at,
rtt: Some(RttSample {
raw: Duration::from_millis(u64::from(raw_ms)),
adjusted: None,
effective: SignedDuration::from_duration(Duration::from_millis(u64::from(
raw_ms,
))),
}),
server_timing: Some(server_timing(seq)),
one_way: Some(one_way(seq)),
received_stats: Some(ReceivedStatsSample {
count: Some(seq + 1),
window: Some(0xff),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
Op::LateReplyUnmatched => ClientEvent::LateReply {
seq,
highest_seen: seq + 1,
remote: ADDR.parse().unwrap(),
sent_at: None,
received_at: ts_at(base, clock_ms),
rtt: None,
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
Op::Duplicate => ClientEvent::DuplicateReply {
seq,
remote: ADDR.parse().unwrap(),
received_at: ts_at(base, clock_ms),
bytes: 64,
},
Op::Loss => ClientEvent::EchoLoss {
seq,
sent_at: ts_at(base, clock_ms),
timeout_at: ts_at(base, clock_ms).mono,
},
Op::Warning => ClientEvent::Warning {
kind: irtt_client::WarningKind::UntrackedReply,
message: "generated".to_owned(),
at: ts_at(base, clock_ms),
},
}
}
/// Replays `events` in order through a fresh collector using the rolling
/// windows' own semantics (running statistics only, same late-reply policy),
/// mirroring exactly what `rolling.rs`'s private `snapshot_window` does for
/// the production windows. This is the "recompute from scratch" reference
/// primitive both the count and time reference windows below are built on.
fn replay(events: &[ClientEvent], late_replies: LateReplyMode) -> Snapshot {
let mut collector = StatsCollector::new(StatsConfig {
samples: SampleMode::RunningOnly,
rolling_count: None,
rolling_time: None,
late_replies,
});
for event in events {
collector.process(event);
}
collector.snapshot()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })]
/// With `rolling_count` and `rolling_time` configured simultaneously,
/// each window must match a reference model that applies *only its own*
/// bound to the full event history, re-filtered from scratch after every
/// operation. Any accidental AND/OR coupling between the two bounds (for
/// example the count window also dropping events outside the time
/// window, or vice versa) would show up here as a mismatch.
///
/// The cumulative snapshot is checked too: it must equal a reference
/// that replays the *entire* history, proving rolling eviction never
/// reaches into the cumulative accounting.
#[test]
fn rolling_count_and_rolling_time_windows_are_independent(
ops in prop::collection::vec(op_strategy(), 1..40),
count_limit in 1usize..6,
time_limit_ms in 1u64..80,
count_only in prop::bool::ANY,
) {
let late_replies = if count_only {
LateReplyMode::CountOnly
} else {
LateReplyMode::Measure
};
let config = StatsConfig {
samples: SampleMode::RunningOnly,
rolling_count: Some(count_limit),
rolling_time: Some(Duration::from_millis(time_limit_ms)),
late_replies,
};
let mut collector = StatsCollector::new(config);
let base = Instant::now();
let mut clock_ms: u64 = 0;
// Full, unfiltered event history alongside each event's windowing
// timestamp, so the reference windows below can be recomputed from
// scratch after every step.
let mut history: Vec<(ClientEvent, u64)> = Vec::new();
for (idx, op) in ops.iter().enumerate() {
if let Op::Advance(delta) = op {
clock_ms += u64::from(*delta);
} else {
let seq = u32::try_from(idx).unwrap();
let event = build_event(op, seq, clock_ms, base);
collector.process(&event);
history.push((event, clock_ms));
}
// Cumulative snapshot: unaffected by rolling eviction, so it must
// equal a fresh replay of the entire history so far.
let full: Vec<ClientEvent> = history.iter().map(|(event, _)| event.clone()).collect();
prop_assert_eq!(
collector.snapshot(),
replay(&full, late_replies),
"cumulative snapshot diverged from a full replay after op {}", idx
);
// Count window reference: the last `count_limit` retained
// events, ignoring the time bound entirely.
let count_start = history.len().saturating_sub(count_limit);
let count_ref: Vec<ClientEvent> = history[count_start..]
.iter()
.map(|(event, _)| event.clone())
.collect();
prop_assert_eq!(
collector.rolling_count(),
Some(replay(&count_ref, late_replies)),
"count window diverged from the count-only reference after op {}", idx
);
// Time window reference: every retained event whose timestamp is
// within `time_limit_ms` of the *latest processed event's*
// timestamp, ignoring the count bound entirely. This recompute-
// from-scratch filter is equivalent to the production window's
// incremental sliding-eviction only because `clock_ms` is
// non-decreasing across pushes (enforced by construction above:
// `Advance` never subtracts).
if let Some((_, latest_at_ms)) = history.last() {
let cutoff = latest_at_ms.checked_sub(time_limit_ms);
let time_ref: Vec<ClientEvent> = history
.iter()
.filter(|(_, at_ms)| cutoff.is_none_or(|cutoff| *at_ms >= cutoff))
.map(|(event, _)| event.clone())
.collect();
prop_assert_eq!(
collector.rolling_time(),
Some(replay(&time_ref, late_replies)),
"time window diverged from the time-only reference after op {}", idx
);
} else {
prop_assert_eq!(
collector.rolling_time(),
Some(replay(&[], late_replies)),
"time window should be empty before any event after op {}", idx
);
}
}
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ ci = "github"
installers = []
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
# Scope dist's tag matching to irtt-cli releases only: irtt-proto/irtt-client/
# Scope dist's tag matching to irtt-rs releases only: irtt-proto/irtt-client/
# irtt-server/irtt-stats are libraries published to crates.io and tagged for
# provenance, but must not trigger cargo-dist or produce a GitHub Release.
tag-namespace = "irtt-cli"
tag-namespace = "irtt-rs"
+3 -3
View File
@@ -13,7 +13,7 @@ upstream executable.
Build `irtt-rs` and ensure an upstream `irtt` executable is available:
```sh
cargo build -p irtt-cli
cargo build -p irtt-rs --bin irtt-client
go install github.com/heistp/irtt/cmd/irtt@v0.9.1
```
@@ -118,13 +118,13 @@ python3 scripts/run_netem_interop_ci.py \
On Linux:
```sh
cargo build -p irtt-cli
cargo build -p irtt-rs --bin irtt-client
go install github.com/heistp/irtt/cmd/irtt@v0.9.1
python3 scripts/run_netem_interop_ci.py \
--all \
--skip-build \
--upstream-irtt "$HOME/go/bin/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli"
--irtt-rs-command "$PWD/target/debug/irtt-client"
```
Run one scenario:
+10
View File
@@ -0,0 +1,10 @@
target
artifacts
coverage
# The committed seed corpus under corpus/ is small and curated by hand; a
# local `cargo fuzz run` grows each target's corpus directory far beyond
# that, so ignore anything not already tracked instead of the directory
# itself.
corpus/*/*
!corpus/*/
+246
View File
@@ -0,0 +1,246 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cc"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"ctutils",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
name = "irtt-proto"
version = "0.5.0"
dependencies = [
"hmac",
"md-5",
"subtle",
"thiserror",
]
[[package]]
name = "irtt-proto-fuzz"
version = "0.0.0"
dependencies = [
"irtt-proto",
"libfuzzer-sys",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom",
"libc",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+53
View File
@@ -0,0 +1,53 @@
[package]
name = "irtt-proto-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.irtt-proto]
path = "../crates/irtt-proto"
# Use independent workspace for fuzzers
[workspace]
members = ["."]
[[bin]]
name = "decode_request"
path = "fuzz_targets/decode_request.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_open_reply"
path = "fuzz_targets/decode_open_reply.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_echo_reply"
path = "fuzz_targets/decode_echo_reply.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_params"
path = "fuzz_targets/decode_params.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_varint"
path = "fuzz_targets/decode_varint.rs"
test = false
doc = false
bench = false
+77
View File
@@ -0,0 +1,77 @@
# `irtt-proto` fuzzing
Coverage-guided fuzzing of `irtt-proto`'s wire decoders via
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
This project is intentionally isolated from the normal stable/MSRV
workspace: it lives in its own `[workspace]` under `fuzz/`, is never a member
of the root workspace, requires nightly Rust, and is not built by
`cargo check`/`test`/`clippy`/`doc` at the workspace root.
## Prerequisites
- nightly Rust (`rustup toolchain install nightly`)
- `cargo-fuzz` (`cargo install cargo-fuzz --locked --version 0.13.1`)
## Targets
| Target | Exercises |
| ------------------- | ----------------------------------------------------------------- |
| `decode_request` | `decode_request`, `Params::decode` on a decoded Open, `verify_packet_hmac` |
| `decode_open_reply` | `decode_open_reply`, 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_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
The invariant every target asserts is: **arbitrary bytes must never panic**.
Returning `Err` is normal and expected; a successful decode is also normal.
Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so
libFuzzer spends its time exploring protocol structure rather than
allocating/copying megabytes — production IRTT datagrams are UDP-sized, well
under this bound.
## Running
```sh
cargo +nightly fuzz run decode_request
```
Bounded smoke run, e.g. 30 seconds:
```sh
cargo +nightly fuzz run decode_request -- -max_total_time=30
```
Build every target without running:
```sh
cargo +nightly fuzz build
```
## Corpus
Seed corpora live under `fuzz/corpus/<target>/`. They were generated once
from `irtt-proto`'s own public encoders (`encode_request`, `encode_open_reply`,
`encode_echo_reply`, `Params::encode`) by a throwaway local script, not copied
from any external source, and not derived from upstream `heistp/irtt`. Each
file is a small, representative wire packet (minimal Open, Close, Echo, HMAC
variants, ordinary/recv-count/recv-window/midpoint Echo replies, and a few
`Params` payloads including an unknown tag and `server_fill`).
## Crash artifacts
A failing case is written under `fuzz/artifacts/<target>/`. To replay it:
```sh
cargo +nightly fuzz run decode_request fuzz/artifacts/decode_request/<crash-file>
```
## Isolation notes
- `fuzz/Cargo.toml` declares its own `[workspace]`, so it is never picked up
by the root `[workspace]` in `../Cargo.toml`.
- `libfuzzer-sys` and `arbitrary` are fuzz-only dependencies; they never
appear in any published crate's dependency graph.
- `fuzz/Cargo.lock` is committed for reproducible fuzzing builds, independent
of the root `Cargo.lock`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
§[ u9ü;dº%Íý¶Ä¢•oRq‡«¶–x€ø‚­€¨Ö¹
Binary file not shown.
@@ -0,0 +1 @@
ァ[Rq�カ堀€�ュ€ィヨケ
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
€�ュ€ィヨケ
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@
 pattern:abc
+1
View File
@@ -0,0 +1 @@
c�
+1
View File
@@ -0,0 +1 @@
ァ[�fUD3"
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
§[ Ай ;¦w6Џос†{‘в‚Ђш‚­ЂЁЦ№
+1
View File
@@ -0,0 +1 @@
ァ[
+1
View File
@@ -0,0 +1 @@
ァ[€�ュ€ィヨケ
+133
View File
@@ -0,0 +1,133 @@
#![no_main]
use irtt_proto::{
decode_echo_reply, echo_packet_len, hmac::compute_hmac_in_place, Clock, PacketLayout, Params,
ReceivedStats, StampAt, FLAG_HMAC, FLAG_REPLY, TIMESTAMP_SIZE,
};
use libfuzzer_sys::fuzz_target;
/// See `decode_request.rs` for the rationale behind this bound.
const MAX_INPUT_LEN: usize = 128 * 1024;
/// Mirrors `irtt-proto`'s crate-private HMAC field offset, the same way the
/// crate's own external integration tests do.
const HMAC_OFFSET: usize = 4;
const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-echo-reply-key";
/// Negotiated-length vocabulary around the boundaries that matter for this
/// decoder: zero, tiny, MTU-ish, and modestly larger. Deliberately bounded so
/// we fuzz the decoder rather than benchmark OOM handling.
const LENGTH_BUCKETS: &[i64] = &[
0, 1, 63, 64, 65, 127, 128, 129, 511, 512, 513, 1199, 1200, 1201, 1471, 1472, 1473, 1499, 1500,
1501, 4096, 65507,
];
fn pick_received_stats(v: u8) -> ReceivedStats {
match v % 4 {
0 => ReceivedStats::None,
1 => ReceivedStats::Count,
2 => ReceivedStats::Window,
_ => ReceivedStats::Both,
}
}
fn pick_stamp_at(v: u8) -> StampAt {
match v % 5 {
0 => StampAt::None,
1 => StampAt::Send,
2 => StampAt::Receive,
3 => StampAt::Both,
_ => StampAt::Midpoint,
}
}
fn pick_clock(v: u8) -> Clock {
match v % 4 {
0 => Clock::Unspecified,
1 => Clock::Wall,
2 => Clock::Monotonic,
_ => Clock::Both,
}
}
fuzz_target!(|data: &[u8]| {
if data.len() > MAX_INPUT_LEN {
return;
}
// Need enough control bytes to derive Params before any packet bytes.
const CONTROL_LEN: usize = 6;
if data.len() < CONTROL_LEN {
return;
}
let (control, rest) = data.split_at(CONTROL_LEN);
let received_stats = pick_received_stats(control[0]);
let stamp_at = pick_stamp_at(control[1]);
let clock = pick_clock(control[2]);
let length_bucket = LENGTH_BUCKETS[control[3] as usize % LENGTH_BUCKETS.len()];
let hmac_present = control[4] & 1 == 1;
let use_compat_len = control[5] & 1 == 1;
let params = Params {
protocol_version: 1,
length: length_bucket,
received_stats,
stamp_at,
clock,
..Params::default()
};
let Ok(normal_len) = echo_packet_len(hmac_present, &params) else {
return;
};
let layout = PacketLayout::echo(hmac_present, &params);
let is_single_clock_midpoint =
stamp_at == StampAt::Midpoint && matches!(clock, Clock::Wall | Clock::Monotonic);
let target_len = if use_compat_len && is_single_clock_midpoint {
(layout.header_len() + TIMESTAMP_SIZE).max(normal_len)
} else {
normal_len
};
let flags = FLAG_REPLY | if hmac_present { FLAG_HMAC } else { 0 };
let mut packet = Vec::with_capacity(target_len);
packet.extend_from_slice(&irtt_proto::MAGIC);
packet.push(flags);
if hmac_present {
packet.extend_from_slice(&[0u8; 16]);
}
// Fill the remainder of the negotiated length from the fuzz-provided
// tail, truncating or zero-padding to hit the target length exactly so
// the decoder's field parsing gets real coverage rather than bailing out
// on a length mismatch every time.
let body_len = target_len.saturating_sub(packet.len());
if rest.len() >= body_len {
packet.extend_from_slice(&rest[..body_len]);
} else {
packet.extend_from_slice(rest);
packet.resize(target_len, 0);
}
packet.resize(target_len, 0);
if hmac_present {
if compute_hmac_in_place(FUZZ_HMAC_KEY, &mut packet, HMAC_OFFSET).is_err() {
return;
}
let reply = decode_echo_reply(&packet, &params, Some(FUZZ_HMAC_KEY));
if let Ok(reply) = reply {
assert!(reply.payload.len() <= packet.len());
}
} else {
let reply = decode_echo_reply(&packet, &params, None);
if let Ok(reply) = reply {
assert!(reply.payload.len() <= packet.len());
}
}
// Also exercise the raw fuzz bytes directly against the same negotiated
// Params, unmodified and un-lengthened, purely for the no-panic
// invariant on genuinely arbitrary bytes.
let _ = decode_echo_reply(data, &params, None);
});
+49
View File
@@ -0,0 +1,49 @@
#![no_main]
use irtt_proto::{
decode_open_reply, hmac::compute_hmac_in_place, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY,
};
use libfuzzer_sys::fuzz_target;
/// See `decode_request.rs` for the rationale behind this bound.
const MAX_INPUT_LEN: usize = 128 * 1024;
/// Offset of the HMAC field, right after the 4-byte header. `irtt-proto`
/// keeps the real constant crate-private; this mirrors it the same way the
/// crate's own external integration tests do.
const HMAC_OFFSET: usize = 4;
const HMAC_SIZE: usize = 16;
const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-open-reply-key";
fuzz_target!(|data: &[u8]| {
if data.len() > MAX_INPUT_LEN {
return;
}
// Unauthenticated decode path: no panic is the invariant, Ok/Err both
// acceptable.
let _ = decode_open_reply(data, None);
// Authenticated decode path against arbitrary bytes. Most inputs will
// fail on HMAC presence/verification before reaching the post-HMAC
// parser, which is expected and fine on its own.
let _ = decode_open_reply(data, Some(FUZZ_HMAC_KEY));
// To also give the post-HMAC parser sustained coverage, derive a packet
// that *will* authenticate: when the input is structurally capable of
// carrying an HMAC field and its flags already indicate HMAC, patch in a
// freshly-computed HMAC over the untouched body via the crate's own
// helper (never reimplemented here) and decode that.
if data.len() >= HMAC_OFFSET + HMAC_SIZE {
let flags = data[3];
let looks_like_open_reply =
flags & FLAG_OPEN != 0 && flags & FLAG_REPLY != 0 && flags & FLAG_HMAC != 0;
if looks_like_open_reply {
let mut signed = data.to_vec();
if compute_hmac_in_place(FUZZ_HMAC_KEY, &mut signed, HMAC_OFFSET).is_ok() {
let _ = decode_open_reply(&signed, Some(FUZZ_HMAC_KEY));
}
}
}
});
+46
View File
@@ -0,0 +1,46 @@
#![no_main]
use irtt_proto::Params;
use libfuzzer_sys::fuzz_target;
/// See `decode_request.rs` for the rationale behind this bound.
const MAX_INPUT_LEN: usize = 128 * 1024;
fuzz_target!(|data: &[u8]| {
if data.len() > MAX_INPUT_LEN {
return;
}
let decoded = Params::decode(data);
let decoded_with_presence = Params::decode_with_presence(data);
// Both entry points share one parser, so when both succeed they must
// agree on the decoded values.
match (&decoded, &decoded_with_presence) {
(Ok(params), Ok(with_presence)) => {
assert_eq!(params, &with_presence.params);
}
(Ok(_), Err(_)) | (Err(_), Ok(_)) => {
panic!("decode and decode_with_presence disagreed on success for {data:02x?}");
}
(Err(_), Err(_)) => {}
}
// No panic is the primary invariant for malformed input; unknown tags are
// tolerated and repeated known tags are last-value-wins by design, so we
// do not assert a particular failure mode here.
if let Ok(params) = decoded {
let encoded = params.encode();
// Re-encoding and decoding again must preserve the semantic value.
// Presence information does not round-trip through `encode` for
// absent/default fields, so we compare `Params` values, not the
// original wire bytes.
if let Ok(reencoded_params) = Params::decode(&encoded) {
assert_eq!(params, reencoded_params);
} else {
panic!(
"re-encoding a successfully decoded Params produced bytes that failed to decode"
);
}
}
});
+45
View File
@@ -0,0 +1,45 @@
#![no_main]
use irtt_proto::{decode_request, verify_packet_hmac, DecodedRequestKind, Params};
use libfuzzer_sys::fuzz_target;
/// Keeps individual cases bounded so libFuzzer explores protocol structure
/// instead of spending time allocating/copying megabytes. This is UDP wire
/// protocol code; production datagrams are well under this bound.
const MAX_INPUT_LEN: usize = 128 * 1024;
/// Fixed key used only to exercise the HMAC verification path. Never treated
/// as a secret.
const FUZZ_HMAC_KEY: &[u8] = b"fuzz-decode-request-key";
fuzz_target!(|data: &[u8]| {
if data.len() > MAX_INPUT_LEN {
return;
}
// The core invariant: arbitrary bytes must never panic. Ok/Err are both
// normal outcomes.
let decoded = match decode_request(data) {
Ok(decoded) => decoded,
Err(_) => return,
};
match decoded.kind {
DecodedRequestKind::Open { params, .. } => {
// Structural Open decoding deliberately permits malformed
// parameter bytes; both Ok and Err from the secondary parser are
// acceptable, and a structurally decoded Open is not required to
// contain valid Params.
let _ = Params::decode(params);
}
DecodedRequestKind::Close { .. } | DecodedRequestKind::Echo { .. } => {
// No token/session policy belongs here; that's above irtt-proto.
}
}
if decoded.hmac_present {
// Most arbitrary HMAC fields will fail authentication; both outcomes
// are acceptable, we're only exercising the verifier for panics.
let _ = verify_packet_hmac(FUZZ_HMAC_KEY, data);
}
});
+38
View File
@@ -0,0 +1,38 @@
#![no_main]
use irtt_proto::varint::{decode_uvarint, decode_varint, encode_uvarint, encode_varint};
use libfuzzer_sys::fuzz_target;
/// See `decode_request.rs` for the rationale behind this bound. Varint
/// grammar is trivial, but the bound is kept for consistency with the other
/// targets.
const MAX_INPUT_LEN: usize = 128 * 1024;
fuzz_target!(|data: &[u8]| {
if data.len() > MAX_INPUT_LEN {
return;
}
if let Ok((value, used)) = decode_uvarint(data) {
assert!(used > 0);
assert!(used <= data.len());
assert!(used <= 10);
let mut encoded = Vec::new();
encode_uvarint(value, &mut encoded);
// Canonical re-encoding need not reproduce noncanonical fuzz input,
// but it must decode back to the same value and consume exactly its
// own bytes.
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
}
if let Ok((value, used)) = decode_varint(data) {
assert!(used > 0);
assert!(used <= data.len());
assert!(used <= 10);
let mut encoded = Vec::new();
encode_varint(value, &mut encoded);
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
}
});
+1 -1
View File
@@ -21,7 +21,7 @@
"labels": ["dependencies"],
"ignorePaths": [".github/workflows/irtt-cli-release.yml"],
"ignorePaths": [".github/workflows/irtt-rs-release.yml"],
"lockFileMaintenance": {
"enabled": true,
+5 -5
View File
@@ -32,7 +32,7 @@ DEFAULT_REMOTE_TARGETS = [
]
DEFAULT_LOCAL_TARGET = ("127.0.0.1", "10s", "100ms", "local")
DEFAULT_OUTPUT_ROOT = Path("target/interop-comparisons")
DEFAULT_IRTT_RS_COMMAND = "target/debug/irtt-cli"
DEFAULT_IRTT_RS_COMMAND = "target/debug/irtt-client"
@dataclass
@@ -163,7 +163,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--skip-build",
action="store_true",
help="Do not run cargo build -p irtt-cli before comparisons.",
help="Do not run cargo build -p irtt-rs --bin irtt-client before comparisons.",
)
parser.add_argument(
"--localhost-port",
@@ -335,8 +335,8 @@ def check_local_server(args: argparse.Namespace) -> tuple[bool, str]:
)
def preflight_build_irtt_cli(run_dir: Path) -> bool:
command = ["cargo", "build", "-p", "irtt-cli"]
def preflight_build_irtt_client(run_dir: Path) -> bool:
command = ["cargo", "build", "-p", "irtt-rs", "--bin", "irtt-client"]
started = utc_now()
print(f"[{started}] preflight build: {shlex.join(command)}", flush=True)
completed = subprocess.run(
@@ -1117,7 +1117,7 @@ def main() -> int:
print(message, file=sys.stderr)
cases = [case for case in cases if not case.is_local]
if cases and not args.skip_build and not preflight_build_irtt_cli(run_dir):
if cases and not args.skip_build and not preflight_build_irtt_client(run_dir):
write_index(run_dir, [], local_error)
return 1
+7 -4
View File
@@ -105,13 +105,13 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument(
"--irtt-rs-command",
default=str(REPO_ROOT / "target" / "debug" / "irtt-cli"),
help="Path or command for irtt-rs client (default: target/debug/irtt-cli).",
default=str(REPO_ROOT / "target" / "debug" / "irtt-client"),
help="Path or command for irtt-rs client (default: target/debug/irtt-client).",
)
parser.add_argument(
"--skip-build",
action="store_true",
help="Do not run cargo build -p irtt-cli before comparisons.",
help="Do not run cargo build -p irtt-rs --bin irtt-client before comparisons.",
)
parser.add_argument(
"--sudo",
@@ -176,7 +176,10 @@ def main() -> int:
upstream_irtt = resolve_command(args.upstream_irtt)
irtt_rs_command = resolve_irtt_rs_command(args.irtt_rs_command)
if not args.skip_build:
run_checked(["cargo", "build", "-p", "irtt-cli"], run_dir / "cargo-build")
run_checked(
["cargo", "build", "-p", "irtt-rs", "--bin", "irtt-client"],
run_dir / "cargo-build",
)
cleanup(args, quiet=True)
setup_namespaces(args, run_dir)