Compare commits

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

No conventional tests are migrated: every existing irtt-proto test
exercises wire vectors, flag matrices, malformed input, layout rules,
HMAC error behavior, or encode/decode round trips, which the crate's
AGENTS.md treats as durable compatibility coverage rather than
documentation cleanup targets.
2026-09-24 16:29:40 +07:00
Nils Andreas Svee 19782dd3fd docs(irtt-stats): add runnable doctests for public API
Rust CI / cargo clippy (push) Successful in 45s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 41s
Rust CI / cargo doc (push) Successful in 38s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m30s
Supply chain / cargo-deny advisories (push) Successful in 10s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 22s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 42s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m31s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m44s
Netem Interop / netem interop (push) Successful in 1m13s
Add executable doctests that document the consumer-facing contract of
irtt-stats: finite vs continuous retention, the exact-median guarantee,
the retained-memory planning estimate, and count-/time-based rolling
snapshots. Migrate the send-call exact-median test into the
StatsConfig::finite() doc, which fully subsumes it.
2026-09-18 00:10:49 +07:00
Nils Andreas Svee b3241958d9 Merge branch 'test/server-core-fuzz'
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 39s
Rust CI / cargo doc (push) Successful in 38s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m32s
Supply chain / cargo-deny advisories (push) Successful in 10s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m21s
Netem Interop / netem interop (push) Successful in 1m18s
Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m49s
2026-09-15 16:29:21 +07:00
Nils Andreas Svee 8644b70b89 test(server): vary fuzz HMAC keys 2026-09-15 14:24:51 +07:00
Nils Andreas Svee 196512068b Merge branch 'docs/audit-corrections'
Rust CI / cargo clippy (push) Successful in 43s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 38s
Rust CI / cargo doc (push) Successful in 32s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m34s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m35s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m29s
Netem Interop / netem interop (push) Successful in 1m12s
2026-09-15 13:20:25 +07:00
Nils Andreas Svee e861de5247 fix(client): invalidate TX correlation after send failures 2026-09-15 13:03:36 +07:00
Nils Andreas Svee d335ad78c9 test(server): exercise keyed stateful fuzz paths 2026-09-15 12:59:53 +07:00
Nils Andreas Svee f650b6220a Merge branch 'test/doctest-ci'
Rust CI / cargo clippy (push) Successful in 37s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 40s
Rust CI / cargo doc (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m30s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m30s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 15s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Netem Interop / netem interop (push) Successful in 1m16s
2026-09-15 12:51:01 +07:00
Nils Andreas Svee 865c166828 Merge branch 'refactor/remove-irttd-alias'
Rust CI / cargo clippy (push) Successful in 42s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 41s
Rust CI / cargo doc (push) Successful in 21s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m31s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m31s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 9s
Supply chain / actionlint (push) Successful in 3s
Rust CI / cargo fmt (push) Successful in 19s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 41s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m28s
Netem Interop / netem interop (push) Successful in 1m14s
2026-09-15 12:50:25 +07:00
Nils Andreas Svee bc2bf34ed4 test(server): fuzz ServerCore datagram handling 2026-09-15 12:39:59 +07:00
Nils Andreas Svee 4864357486 refactor(app): remove undocumented irttd applet alias 2026-09-15 12:32:34 +07:00
Nils Andreas Svee 188d6e5354 docs(client): clarify terminal open replies 2026-09-15 12:31:45 +07:00
Nils Andreas Svee 2882beb2c9 docs(client): clarify TX timestamp correlation 2026-09-15 12:31:26 +07:00
Nils Andreas Svee ed805e854f docs(server): document DSCP-safe listener pairing 2026-09-15 12:31:10 +07:00
Nils Andreas Svee f2e9c72b22 ci: run workspace doctests 2026-09-15 12:29:03 +07:00
Nils Andreas Svee 4563816873 docs(tui): advise on memory usage for finite-duration runs
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 42s
Rust CI / cargo doc (push) Successful in 25s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m29s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m29s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 4s
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m27s
Netem Interop / netem interop (push) Successful in 1m22s
2026-09-14 15:33:57 +07:00
Nils Andreas Svee 70a2998f8c docs(server): update --bind default bind reference 2026-09-14 15:33:57 +07:00
Nils Andreas Svee 4ddefb2101 docs(server): document all IRTT_SERVER_ environment variables 2026-09-14 15:33:57 +07:00
Nils Andreas Svee 04291a0b7b feat(server): allow IRTT_SERVER_NO_DSCP to set --no-dscp flag 2026-09-14 15:33:57 +07:00
Nils Andreas Svee c426884fd1 fix(client): prevent stagger gate from accumulating wakeup latency 2026-09-14 15:33:56 +07:00
Nils Andreas Svee b9c2d024d9 chore(release): irtt-client 0.5.1, irtt-rs 0.7.0
Rust CI / cargo clippy (push) Successful in 1m3s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 1m2s
Rust CI / cargo doc (push) Successful in 38s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m51s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m45s
Supply chain / cargo-deny advisories (push) Successful in 14s
Supply chain / cargo-deny bans licenses sources (push) Successful in 11s
Supply chain / actionlint (push) Successful in 7s
Semver / cargo-semver-checks (push) Successful in 1m31s
Publish irtt-server image / publish (push) Successful in 3m9s
Rust CI / cargo fmt (push) Successful in 40s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m3s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m48s
Netem Interop / netem interop (push) Successful in 1m36s
irtt-client exposes a synchronous wait for managed target-set updates and keeps its packaged test dependency resolvable from crates.io. irtt-rs adds stdin-managed desired target sets and per-target HMAC configuration.
2026-09-01 12:55:44 +07:00
Nils Andreas Svee 7ee7474605 fix(ci): use GNU target for fuzzing
Rust CI / cargo clippy (push) Successful in 1m1s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 59s
Rust CI / cargo doc (push) Successful in 47s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m50s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m41s
Supply chain / cargo-deny advisories (push) Successful in 15s
Supply chain / cargo-deny bans licenses sources (push) Successful in 11s
Supply chain / actionlint (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 41s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m1s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m44s
Netem Interop / netem interop (push) Successful in 1m31s
2026-09-01 12:48:01 +07:00
Nils Andreas Svee c18903b097 ci: download Gitea tool binaries
Rust CI / cargo clippy (push) Successful in 1m4s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 51s
Rust CI / cargo doc (push) Successful in 44s
Proto Fuzz Smoke / cargo fuzz smoke (push) Failing after 41s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m43s
Supply chain / cargo-deny advisories (push) Successful in 14s
Supply chain / cargo-deny bans licenses sources (push) Successful in 11s
Supply chain / actionlint (push) Successful in 7s
Rust CI / cargo fmt (push) Successful in 42s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m3s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m47s
Netem Interop / netem interop (push) Successful in 1m29s
2026-09-01 12:27:45 +07:00
Nils Andreas Svee b397916ec6 fix(ci): run cargo-deny directly on Gitea
Rust CI / cargo clippy (push) Successful in 54s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 51s
Rust CI / cargo doc (push) Successful in 43s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m55s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m5s
Supply chain / actionlint (push) Failing after 14s
Supply chain / cargo-deny advisories (push) Successful in 2m34s
Supply chain / cargo-deny bans licenses sources (push) Successful in 2m25s
Rust CI / cargo fmt (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 53s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m42s
Netem Interop / netem interop (push) Successful in 1m32s
2026-09-01 12:06:59 +07:00
Nils Andreas Svee 9f6ef340f9 docs(managed): fix broken intra-doc link for BlockingManagedClient
Rust CI / cargo clippy (push) Successful in 55s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 59s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m49s
Rust CI / cargo doc (push) Successful in 47s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m5s
Supply chain / cargo-deny advisories (push) Canceled after 0s
Supply chain / cargo-deny bans licenses sources (push) Canceled after 0s
Supply chain / actionlint (push) Canceled after 0s
Rust CI / cargo fmt (push) Successful in 35s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 50s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m43s
Netem Interop / netem interop (push) Successful in 1m33s
Update the link path to use a crate-relative reference, ensuring the documentation resolves correctly for users.
2026-09-01 12:02:01 +07:00
Nils Andreas Svee 3a29b4efb2 ci: make Gitea primary Linux forge
Rust CI / cargo clippy (push) Successful in 1m6s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Successful in 50s
Rust CI / cargo doc (push) Failing after 48s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Successful in 1m49s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m3s
Supply chain / cargo-deny advisories (push) Failing after 29s
Supply chain / cargo-deny bans licenses sources (push) Failing after 27s
Supply chain / actionlint (push) Failing after 14s
Rust CI / cargo fmt (push) Successful in 45s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 1m4s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m48s
Netem Interop / netem interop (push) Successful in 1m33s
2026-09-01 11:18:51 +07:00
Lochnair a2f542bb97 Merge pull request 'feat(app): add stdin-managed targets and per-target HMAC' (#136) from feature/targets-stdin into main
Reviewed-on: #136
2026-09-01 04:06:19 +00:00
Nils Andreas Svee 5dd478e81a fix(app): bound dynamic stream accounting 2026-09-01 10:36:05 +07:00
Nils Andreas Svee 1ba4f61072 feat(app): add stdin-managed client target sets 2026-09-01 01:50:56 +07:00
Nils Andreas Svee 31fd76f78d feat(client): await managed updates from blocking callers 2026-09-01 01:49:59 +07:00
Nils Andreas Svee 344533f8b6 feat(app): parse target-level HMAC authentication 2026-09-01 01:49:48 +07:00
renovate Bot e8b3f64292 Merge pull request 'chore(deps): update github actions to v0.6.3' (#135) from renovate/github-actions into main 2026-08-30 22:07:41 +00:00
renovate Bot c98452aa2c chore(deps): update github actions to v0.6.3 2026-08-30 22:07:36 +00:00
renovate Bot 807d8f2846 chore(deps): maintain cargo lockfile 2026-08-29 14:40:37 +00:00
renovate Bot 0cc6b1ea80 chore(deps): update github actions 2026-08-29 14:40:09 +00:00
renovate Bot 0ccca9b0ab chore(deps): update rust docker tag to v1.98 2026-08-29 14:39:18 +00:00
renovate Bot df0d69e35d fix(deps): update rust crate libfuzzer-sys to 0.4.13 2026-08-29 14:38:56 +00:00
renovate Bot 9ab1b78637 chore(deps): pin dependencies 2026-08-29 14:36:38 +00:00
Nils Andreas Svee 45351e0ba1 chore(release): irtt-server 0.5.2, irtt-rs 0.6.2
Publish irtt-server image / publish (push) Successful in 1m17s
irtt-server's hex-pattern chunking moves to the stable as_chunks API
with no behavior change. irtt-rs picks up IRTT_SERVER_* environment
variable configuration, SIGTERM handling via ctrlc's termination
feature, and the Dockerfile/Gitea Actions pipeline that publishes its
server image to Gitea and GHCR.
2026-08-26 17:03:47 +07:00
Nils Andreas Svee e2b70ac50a ci: add Dockerfile and Gitea Actions workflow for irtt-server
Introduce a multi-platform build pipeline to automate the publication of a minimal, statically linked irtt-server image to Gitea and GHCR.
2026-08-25 00:50:04 +07:00
Nils Andreas Svee 95037f7fa0 fix(clippy): use as_chunks instead of chunks_exact 2026-08-25 00:16:21 +07:00
Nils Andreas Svee 614579b8b6 build(app): enable termination feature for ctrlc dependency
GitHub Actions Security / actionlint (push) Failing after 36s
Rust CI / cargo clippy (push) Failing after 45s
Rust CI / cargo doc (push) Successful in 45s
Supply chain / cargo-deny advisories (push) Failing after 9s
GitHub Actions Security / zizmor (push) Failing after 3m52s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 1m57s
Supply chain / cargo-deny bans licenses sources (push) Failing after 8s
Supply chain / actionlint (push) Failing after 38s
Clippy SARIF / Clippy SARIF (push) Failing after 5m13s
Rust CI / cargo check (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo check (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo fmt (push) Successful in 40s
Netem Interop / netem interop (push) Failing after 45s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Enable the termination feature to allow the application to handle SIGTERM signals gracefully, ensuring proper cleanup during shutdown.
2026-08-24 16:13:23 +07:00
Lochnair 46d7fdfe30 Merge pull request 'feat(app): add environment variable support for server configuration' (#129) from feature/clap-env into main
GitHub Actions Security / actionlint (push) Failing after 46s
Rust CI / cargo clippy (push) Failing after 45s
Rust CI / cargo doc (push) Successful in 46s
GitHub Actions Security / zizmor (push) Failing after 3m37s
Supply chain / cargo-deny advisories (push) Failing after 8s
Proto Fuzz Smoke / cargo fuzz smoke (push) Successful in 2m5s
Supply chain / cargo-deny bans licenses sources (push) Failing after 8s
Supply chain / actionlint (push) Failing after 46s
Clippy SARIF / Clippy SARIF (push) Failing after 5m22s
Rust CI / cargo check (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo check (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-musl) (push) Canceled after 0s
Rust CI / cargo fmt (push) Successful in 42s
Netem Interop / netem interop (push) Failing after 49s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Canceled after 0s
Reviewed-on: Lochnair/irtt-rs#129
2026-08-24 08:56:47 +00:00
Nils Andreas Svee 8602f73728 feat(app): add environment variable support for server configuration
GitHub Actions Security / actionlint (pull_request) Failing after 46s
Rust CI / cargo fmt (pull_request) Successful in 36s
Rust CI / cargo clippy (pull_request) Failing after 41s
Rust CI / cargo doc (pull_request) Successful in 46s
Supply chain / cargo-deny advisories (pull_request) Failing after 8s
Supply chain / cargo-deny bans licenses sources (pull_request) Failing after 8s
GitHub Actions Security / zizmor (pull_request) Failing after 3m46s
Proto Fuzz Smoke / cargo fuzz smoke (pull_request) Successful in 2m0s
Supply chain / actionlint (pull_request) Failing after 45s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (pull_request) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (pull_request) Canceled after 0s
Rust CI / cargo check (aarch64-unknown-linux-gnu) (pull_request) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-gnu) (pull_request) Canceled after 0s
Rust CI / cargo check (x86_64-unknown-linux-musl) (pull_request) Canceled after 0s
Rust CI / cargo test (x86_64-unknown-linux-musl) (pull_request) Canceled after 0s
Rust CI / cargo test (aarch64-unknown-linux-musl) (pull_request) Canceled after 0s
Release / plan (pull_request) Canceled after 0s
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (pull_request) Canceled after 0s
Release / build-global-artifacts (pull_request) Canceled after 0s
Release / host (pull_request) Canceled after 0s
Release / announce (pull_request) Canceled after 0s
Netem Interop / netem interop (pull_request) Failing after 40s
Rust CI / cargo check (aarch64-unknown-linux-musl) (pull_request) Canceled after 0s
Enable configuration via IRTT_SERVER_* environment variables to simplify deployments in containerized or orchestrated environments. This adds the clap "env" feature and includes thread-safe tests to verify variable precedence.
2026-08-24 15:47:09 +07:00
Nils Andreas Svee 5d07853893 chore(release): irtt-server 0.5.1, irtt-rs 0.6.1
irtt-server gains address_family_available() as a public helper, and
irtt-rs picks up the server zero-argument defaults and single-family
fallback that depend on it.
2026-08-20 22:39:40 +07:00
Nils Andreas Svee ccb31a59e7 Merge pull request #128 from Lochnair/feat/docs-layout-and-server-defaults
Server bind defaults + documentation architecture cleanup
2026-08-20 22:29:02 +07:00
Nils Andreas Svee 56a2a653d0 docs(crates): give each published crate its own crates.io README and examples
Each published crate (irtt-rs, irtt-client, irtt-server, irtt-stats,
irtt-proto) now points crates.io at its own README instead of
inheriting the root project README via the workspace-wide readme
default, which is removed. Each crate README is a short landing page:
what the crate is, who should use it, which sibling crate to use
instead if this isn't the right abstraction level, and links to
docs.rs and the main repository.

Add real Cargo example targets teaching irtt-client's blocking/Tokio/
managed API tiers, a basic and a multi-listener irtt-server example,
and an irtt-stats example contrasting finite vs continuous retention.
irtt-proto intentionally has none. Update each crate's top-level
rustdoc to explain its API-tier choices and point at the examples, and
enable docs.rs rustdoc example scraping for irtt-client and
irtt-server (via doc-scrape-examples on each example target, needed
because the crate has dev-dependencies).

The client examples are caller-paced against next_send_deadline()
rather than sending on every loop iteration, bound their receive wait
by the next send/timeout deadline instead of awaiting indefinitely,
poll timeouts on a short fixed cadence, and surface send/receive/close
errors instead of discarding them via unwrap_or_default()/if-let-Ok --
so they demonstrate correct pacing and terminate/report cleanly rather
than racing the server, hanging on a lost reply, or silently dropping a
failed final close.
2026-08-20 22:17:53 +07:00
Nils Andreas Svee 1ec82d8c56 docs: turn the root README into a project landing page
Reduce the root README to project discovery and navigation: what
irtt-rs is, its relationship to upstream IRTT (including a factual
capability comparison checked against upstream v0.9.2), installation,
a short quick start, and links out to detailed documentation. Detailed
CLI behavior, output/measurement semantics, continuous-mode memory
guidance, and server bind/session/negotiation semantics move into new
manpage-shaped docs under docs/ (irtt-rs, irtt-client, irtt-tui,
irtt-server), rewritten as operational guidance rather than copied
verbatim from the old README sections they replace.
2026-08-20 22:17:25 +07:00
Nils Andreas Svee e9e1ff4218 feat(server): give irtt-server sensible zero-argument defaults
With no --bind, the server now binds the wildcard IRTT port on both
address families ([::]:2112 then 0.0.0.0:2112) instead of requiring an
explicit address, on platforms where wildcard reply-source selection
is already supported. Any explicit --bind fully replaces the default
pair rather than adding to it; repeated explicit binds are unaffected.

If one of the two default listeners fails because its address family
has no local support on this host at all (IPv6 disabled on an
otherwise IPv4-capable host, for example), the server falls back to
serving just the surviving family instead of failing outright. Any
other failure reason -- the port already in use, permission denied, no
safe wildcard reply-source path -- is not a family-support problem and
is not silently worked around; startup fails as it would for an
explicit --bind. This distinction is made with the new
irtt_server::address_family_available, which actually binds the
wildcard address of the failed listener's family on an ephemeral port:
a bare socket can construct successfully even when every bind for that
family fails (Linux administratively disabling IPv6 per-interface
leaves AF_INET6 sockets constructible but every bind EADDRNOTAVAIL), so
only a real bind attempt is a reliable signal.
2026-08-20 22:17:07 +07:00
Nils Andreas Svee 2521959364 Merge pull request #127 from Lochnair/docs/release-readiness-audit-fixes
docs: implement release-readiness audit findings
2026-08-20 15:33:08 +07:00
Nils Andreas Svee 1754b77ea5 docs: scope kernel-timestamp claim to the Tokio server runtime
irtt_server::ServerCore::handle_datagram is a public API that
always passes kernel_rx_timestamp = None; only the crate-private
entry point the Tokio runtime (the irtt-server applet) uses
supplies one. Say so explicitly instead of implying every use of
this crate's server gets the preference.
2026-08-20 15:24:43 +07:00
Nils Andreas Svee cab1fd1aab docs: scope server kernel-timestamp claim to this crate's server
irtt-client can talk to any IRTT-compatible peer, not only this
repo's own irtt-server, and has no way to know what clock source
produced a peer-reported wall-clock endpoint. Restrict the
kernel-receive-timestamp-preference claim to when the peer is
this crate's own Linux irtt-server; state plainly that another
peer's reported value is used as-is otherwise.
2026-08-20 15:18:48 +07:00
Nils Andreas Svee c9269ca0ee docs: qualify RTT-unaffected claim to raw RTT only
adjusted_rtt_us/effective_rtt_us are derived in part from
server-reported processing time, which is computed from the same
server receive endpoint the client_to_server one-way-delay field
documents as kernel-preferred. Only raw RTT is guaranteed to stay
on userspace monotonic timing; say so precisely instead of
claiming RTT in general is unaffected.
2026-08-20 15:12:01 +07:00
Nils Andreas Svee 98e7231ede fix(app): gate feature-specific formatting helpers
RUSTFLAGS="-D warnings" cargo check -p irtt-rs --no-default-features
--features client (or --features tui) warned on dead code: some
format.rs helpers are only called from the client applet, others
only from the TUI. Narrowly #[cfg(feature = "...")]-gate each to
the applet that actually uses it instead of allowing the warning
or introducing a new feature, and split the tests that exercised
both groups to match.
2026-08-20 14:57:02 +07:00
Nils Andreas Svee a0b51032f2 docs: document update_targets() replacement semantics
ManagedClientHandle::update_targets() had a one-line rustdoc that
didn't say targets is a full desired-set replacement, that an
unchanged live target continues undisturbed, or that a changed
config or a terminal-generation resubmission starts a fresh
generation even for byte-identical config.
2026-08-20 14:56:56 +07:00
Nils Andreas Svee 13a907bc28 docs: document Linux kernel-timestamp preference for OWD
On Linux, one-way delay measurement best-effort prefers kernel
send/receive timestamps over userspace timing, falling back
whenever a kernel reading is unavailable or implausible. RTT is
unaffected and remains userspace-timed.

Add a user-facing README note near the measurement/output
explanation, and bring OneWayDelaySample::client_to_server's
rustdoc in line with the send-endpoint behavior, matching the
style already used for server_to_client. Also fix a stale
socket_io module doc that still claimed the captured kernel
receive timestamp went unconsumed; ServerCore has used it via
preferred_receive_wall_ns since d3f93a9.
2026-08-20 14:56:50 +07:00
Nils Andreas Svee 1ce65c0db4 docs: note that final target summaries follow CLI order
Final per-target summaries print in the order targets were
supplied on the command line, not finish order or alphabetical
label order. This only concerns the end-of-run summary printout;
it says nothing about ordering of live/interleaved per-event
output.
2026-08-20 14:56:34 +07:00
Nils Andreas Svee 90178cfb75 docs: point installation and library usage at crates.io
The README led application users through cloning the repo and
building with cargo install --path, and led library consumers
through a local path dependency, even though irtt-rs and
irtt-client are now published. Make the registry the default
path for both and keep the local-checkout/path forms documented
as the alternative for development.
2026-08-20 14:56:22 +07:00
Nils Andreas Svee 4928661467 docs: clarify testing ownership and cost 2026-08-20 14:20:49 +07:00
Nils Andreas Svee 65eb494f7a test(app): yeet even more nonsense tests dreamt up by the criminally insane 2026-08-20 14:02:47 +07:00
Nils Andreas Svee 08a9bc4e63 test(app): remove applet dispatch integration tests
This is utter nonsense dreamt up by insane clankers
2026-08-20 13:48:41 +07:00
Nils Andreas Svee 3c676edb38 Merge pull request #125 from Lochnair/docs/continuous-mode-memory
docs: describe continuous-mode memory bounds
2026-08-20 13:44:34 +07:00
Nils Andreas Svee e936d8147d docs: describe continuous-mode memory bounds 2026-08-20 13:40:21 +07:00
Nils Andreas Svee d51e154783 build(app): mirror release profile from workspace
because cargo's workspace profiles are not inherited when doing cargo install
2026-08-20 12:41:48 +07:00
Nils Andreas Svee 02bad76676 build: optimize release and distribution profiles
Remove the panic abort override and synchronize LTO settings across release and distribution profiles to ensure consistent binary optimization and debugging behavior.
2026-08-20 12:41:16 +07:00
89 changed files with 5348 additions and 2665 deletions

No files matched your search

@@ -7,7 +7,6 @@ on:
pull_request:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
@@ -29,10 +28,10 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
components: rustfmt
cache-key: fmt
cache: false
- name: Check formatting
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
@@ -49,10 +48,10 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
components: clippy
cache-key: clippy
cache: false
- name: Run clippy
run: |
@@ -79,46 +78,22 @@ jobs:
matrix:
include:
- task: check
runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
musl: false
- task: test
runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
musl: false
- task: check
runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
musl: true
- task: test
runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
musl: true
- task: check
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
musl: false
- task: test
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
musl: false
- task: check
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
musl: true
- task: test
runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
musl: true
runs-on: ${{ matrix.runner }}
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
@@ -134,10 +109,10 @@ jobs:
sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
target: ${{ matrix.target }}
cache-key: ${{ matrix.task }}-${{ matrix.target }}
cache: false
- name: Run ${{ matrix.task }} with all features
run: |
@@ -172,9 +147,9 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache-key: docs
cache: false
- name: Check docs
run: |
@@ -183,3 +158,6 @@ jobs:
--all-features \
--no-deps \
--locked
- name: Run doctests
run: cargo test --workspace --doc --all-features --locked
+99
View File
@@ -0,0 +1,99 @@
name: Publish irtt-server image
on:
push:
tags:
# This repo tags each crate independently (irtt-proto/vX, irtt-client/vX,
# ...); only the app package's tag corresponds to this image.
- "irtt-rs/v*"
workflow_dispatch:
env:
IMAGE_NAME: irtt-server
IMAGE_OWNER: lochnair
GITEA_REGISTRY: git.lochnair.net
GHCR_REGISTRY: ghcr.io
# Pinned so a floating "latest" release doesn't silently change the build.
CRANE_VERSION: v0.20.2
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Determine image tags
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
version="${GITHUB_REF_NAME#irtt-rs/v}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tags=${version},latest" >> "$GITHUB_OUTPUT"
else
# Manual dispatch off a branch: tag by commit only, never touch
# "latest", so an ad-hoc build can't clobber the last release.
version="sha-$(git rev-parse --short HEAD)"
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "tags=${version}" >> "$GITHUB_OUTPUT"
fi
# Cross-compilation is handled inside the Dockerfile via tonistiigi/xx
# (native builds, no emulation), but buildx still needs a driver that
# can assemble a multi-platform manifest list.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.GITEA_REGISTRY }}
# The automatic per-job token comes back unauthorized for package
# writes on this instance, so use a PAT (scope: write:package) instead.
# (Secret names can't start with GITEA_/GITHUB_, hence REGISTRY_*.)
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build tag list
id: tags
run: |
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
gitea_tags=""
for t in "${tag_list[@]}"; do
gitea_tags="${gitea_tags}${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t},"
done
echo "gitea=${gitea_tags%,}" >> "$GITHUB_OUTPUT"
# Gitea is the primary publish target: this is the only build. Every
# other registry gets an exact digest copy of this same image (see
# below), never a separate build, so the hash matches everywhere.
- name: Build and push to Gitea
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.gitea }}
- name: Install crane
run: |
mkdir -p "${HOME}/.local/bin"
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
| tar -xz -C "${HOME}/.local/bin" crane
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
- name: Duplicate image to GHCR (same digest, no rebuild)
run: |
source_ref="${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
for t in "${tag_list[@]}"; do
crane copy "${source_ref}" "${GHCR_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t}"
done
@@ -1,4 +1,4 @@
name: Proto Fuzz Smoke
name: Fuzz Smoke
on:
push:
@@ -16,6 +16,9 @@ env:
CARGO_TERM_COLOR: always
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
CARGO_FUZZ_VERSION: 0.13.1
CARGO_FUZZ_SHA256: 86edc9b8f20c29ff07c3e5fcd67bc3f0784b3b960a5428635060f20d50555461
FUZZ_TOOLCHAIN: nightly-x86_64-unknown-linux-gnu
FUZZ_TARGET: x86_64-unknown-linux-gnu
jobs:
fuzz-smoke:
@@ -30,24 +33,35 @@ jobs:
persist-credentials: false
- name: Install stable Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache-key: fuzz
cache: false
- 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
# the GNU-host nightly below. Building cargo-fuzz's own dependency
# tree under a nightly override can trip build scripts (e.g. rustix) that gate
# unstable internal attributes on detecting a nightly compiler.
run: rustup toolchain install nightly --profile minimal --no-self-update
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked --version "${CARGO_FUZZ_VERSION}"
run: |
set -euo pipefail
archive="cargo-fuzz-${CARGO_FUZZ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/rust-fuzz/cargo-fuzz/releases/download/${CARGO_FUZZ_VERSION}/${archive}" \
--output "$archive"
echo "${CARGO_FUZZ_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" cargo-fuzz
install -Dm755 cargo-fuzz "${RUNNER_TEMP}/bin/cargo-fuzz"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: Build all fuzz targets
run: cargo +nightly fuzz build
# libFuzzer sanitizers require dynamically linked GNU binaries; the
# runner's default Rust host can be static musl.
run: RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz build --target "${FUZZ_TARGET}"
working-directory: fuzz
- name: Run bounded fuzz smoke for every target
@@ -57,9 +71,9 @@ jobs:
# regression; this is not meant to be exhaustive fuzzing.
run: |
set -euo pipefail
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
echo "::group::fuzz smoke: ${target}"
cargo +nightly fuzz run "${target}" -- \
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
-max_total_time=10 \
-timeout=5
echo "::endgroup::"
@@ -30,15 +30,16 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache-key: interop-netem
cache: false
- name: Check host tools
- name: Check host tools and network administration access
run: |
ip -V
tc -V
go version
sudo -n true
- name: Install upstream irtt
run: |
@@ -62,14 +63,14 @@ jobs:
run: |
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
if [ -n "$latest_summary" ]; then
cat "$latest_summary" >> "$GITHUB_STEP_SUMMARY"
cat "$latest_summary" >> "$GITEA_STEP_SUMMARY"
else
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
echo "No netem interop github-step-summary.md found." >> "$GITEA_STEP_SUMMARY"
fi
- name: Upload netem interop artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: https://gitea.com/actions/gitea-upload-artifact@v7
with:
name: interop-netem
path: target/interop-netem
+52
View File
@@ -0,0 +1,52 @@
name: Semver
on:
workflow_dispatch:
push:
tags:
- "irtt-proto/v*.*.*"
- "irtt-client/v*.*.*"
- "irtt-server/v*.*.*"
- "irtt-stats/v*.*.*"
- "irtt-rs/v*.*.*"
permissions:
contents: read
env:
CARGO_SEMVER_CHECKS_VERSION: 0.50.0
CARGO_SEMVER_CHECKS_SHA256: 52a65dc88dc53fa8b57d6087954eb52cda149ca03bcfca78ce3fdecd23f893c4
jobs:
semver:
name: cargo-semver-checks
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
cache: false
# The CLI preserves the previous wrapper's `feature-group: all-features`
# behavior without its GitHub API/cache dependency.
- name: Install cargo-semver-checks
run: |
set -euo pipefail
archive="cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/${archive}" \
--output "$archive"
echo "${CARGO_SEMVER_CHECKS_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" cargo-semver-checks
install -Dm755 cargo-semver-checks "${RUNNER_TEMP}/bin/cargo-semver-checks"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: Check semver compatibility
run: cargo semver-checks check-release --all-features
+109
View File
@@ -0,0 +1,109 @@
name: Supply chain
on:
push:
branches:
- main
pull_request:
branches:
- main
schedule:
- cron: "41 2 * * 1"
workflow_dispatch:
permissions:
contents: read
env:
ACTIONLINT_VERSION: 1.7.7
CARGO_DENY_VERSION: 0.20.2
CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
jobs:
cargo-deny:
name: cargo-deny ${{ matrix.checks }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# Advisories can appear after code was merged. Keep this visible,
# but don't let a surprise upstream advisory nuke every unrelated PR.
- checks: advisories
continue-on-error: true
# These are policy choices in this repo. They should block PRs.
- checks: bans licenses sources
continue-on-error: false
continue-on-error: ${{ matrix.continue-on-error }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install cargo-deny
run: |
set -euo pipefail
archive="cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/${archive}" \
--output "$archive"
echo "${CARGO_DENY_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" --strip-components=1 \
"cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny"
install -Dm755 cargo-deny "${RUNNER_TEMP}/bin/cargo-deny"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: Run cargo-deny
run: >-
cargo deny --manifest-path Cargo.toml --all-features check
${{ matrix.checks }}
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install actionlint
run: |
set -euo pipefail
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl --fail --show-error --location --retry 3 \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" \
--output "$archive"
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --status -
tar -xzf "$archive" actionlint
install -Dm755 actionlint "${RUNNER_TEMP}/bin/actionlint"
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
- name: actionlint handwritten workflows
# actionlint validates GitHub Actions syntax. It cannot parse Gitea's
# required absolute action URL in interop-netem.yml, so that workflow
# is checked by the Gitea runner instead.
run: |
actionlint \
.gitea/workflows/ci.yml \
.gitea/workflows/docker-publish.yml \
.gitea/workflows/fuzz.yml \
.gitea/workflows/semver.yml \
.gitea/workflows/supply-chain.yml \
.github/workflows/actions-security.yml \
.github/workflows/ci-arm.yml \
.github/workflows/osv-scanner.yml \
.github/workflows/rust-clippy.yml
- name: actionlint generated release workflow
run: >-
actionlint -shellcheck= .github/workflows/irtt-rs-release.yml
+7 -29
View File
@@ -16,34 +16,6 @@ permissions:
contents: read
jobs:
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/irtt-rs-release.yml
shellcheck: false
zizmor:
name: zizmor
runs-on: ubuntu-latest
@@ -60,7 +32,13 @@ jobs:
with:
persist-credentials: false
# Zizmor's SARIF integration publishes GitHub Code Scanning results.
# It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set.
- name: Run zizmor
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
persona: regular
inputs: |
.github/workflows
.gitea/workflows
+80
View File
@@ -0,0 +1,80 @@
name: Rust ARM CI
on:
push:
branches:
- main
pull_request:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
verify:
name: cargo ${{ matrix.task }} (${{ matrix.target }})
# Gitea currently has only amd64 Linux runners; retain ARM coverage here.
strategy:
fail-fast: false
matrix:
include:
- task: check
target: aarch64-unknown-linux-gnu
musl: false
- task: test
target: aarch64-unknown-linux-gnu
musl: false
- task: check
target: aarch64-unknown-linux-musl
musl: true
- task: test
target: aarch64-unknown-linux-musl
musl: true
runs-on: ubuntu-24.04-arm
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install musl toolchain
if: matrix.musl
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends musl-tools
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
target: ${{ matrix.target }}
cache-key: ${{ matrix.task }}-${{ matrix.target }}
- name: Run ${{ matrix.task }} with all features
run: |
cargo ${{ matrix.task }} \
--workspace \
--all-targets \
--all-features \
--target ${{ matrix.target }} \
--locked
- name: Run ${{ matrix.task }} without default features
run: |
cargo ${{ matrix.task }} \
--workspace \
--all-targets \
--no-default-features \
--target ${{ matrix.target }} \
--locked
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with:
# Example of specifying custom arguments
scan-args: |-
@@ -40,7 +40,7 @@ jobs:
./
scan-pr:
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
with:
# Example of specifying custom arguments
scan-args: |-
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
with:
components: clippy
cache-key: clippy-sarif
@@ -60,7 +60,7 @@ jobs:
exit 0
- name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
if: always()
with:
sarif_file: rust-clippy-results.sarif
-31
View File
@@ -1,31 +0,0 @@
name: Semver
on:
workflow_dispatch:
push:
tags:
- "irtt-proto/v*.*.*"
- "irtt-client/v*.*.*"
- "irtt-server/v*.*.*"
- "irtt-stats/v*.*.*"
- "irtt-rs/v*.*.*"
permissions:
contents: read
jobs:
semver:
name: cargo-semver-checks
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Check semver compatibility
uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2
with:
feature-group: all-features
-79
View File
@@ -1,79 +0,0 @@
name: Supply chain
on:
push:
branches:
- main
pull_request:
branches:
- main
merge_group:
schedule:
- cron: "41 2 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
cargo-deny:
name: cargo-deny ${{ matrix.checks }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
# Advisories can appear after code was merged. Keep this visible,
# but don't let a surprise upstream advisory nuke every unrelated PR.
- checks: advisories
continue-on-error: true
# These are policy choices in this repo. They should block PRs.
- checks: bans licenses sources
continue-on-error: false
continue-on-error: ${{ matrix.continue-on-error }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2
with:
command: check
arguments: --all-features
command-arguments: ${{ matrix.checks }}
actionlint:
name: actionlint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: actionlint handwritten workflows
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: >-
.github/workflows/actions-security.yml,
.github/workflows/ci.yml,
.github/workflows/interop-netem.yml,
.github/workflows/osv-scanner.yml,
.github/workflows/rust-clippy.yml,
.github/workflows/semver.yml,
.github/workflows/supply-chain.yml
- name: actionlint generated release workflow
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8
with:
files: .github/workflows/irtt-rs-release.yml
shellcheck: false
+26 -23
View File
@@ -82,42 +82,44 @@ Tokio must remain optional for `irtt-client`; default builds must remain runtime
## Testing policy
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage.
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage. Tests are maintained repository code: every persistent test adds review, CI, refactoring, and maintenance cost, so it must justify that cost.
### Prefer behavior over internals
### Test at the owning layer
For client, managed-runtime, blocking, CLI, and TUI behavior, prefer normal-interface component/integration tests: real UDP/session exchange, emitted events/outcomes, negotiated params, multi-target lifecycle, process exit status, and user-visible diagnostics.
Prefer production interfaces and component/integration tests at the lowest architectural layer that owns the complete behavior. Higher-level or process-level is not inherently better.
One strong behavioral test is usually better than several tests of private helpers that reconstruct the same behavior.
- `irtt-proto`: wire formats, codecs, validation, malformed input, and protocol boundaries.
- `irtt-client`: client/session behavior, multi-target orchestration, burst/staggered pacing, scheduling, lifecycle, managed events, and target updates.
- `irtt-server`: server/session behavior and server runtime/orchestration.
- `irtt-stats`: statistics, loss/IPDV accounting, rolling windows, and retention bounds.
- `irtt-rs` / `irtt-app`: behavior introduced by the application layer itself, such as argument-to-config mapping, presentation/formatting, app-specific diagnostics/exit policy, and genuine process/signal behavior.
Do not add persistent tests merely because implementation code changed. Avoid tests that primarily:
Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them.
- assert private fields, cursors, queue shapes, or exact internal representation;
- mirror implementation steps line-for-line;
- exercise branches/helpers solely for coverage;
### Every persistent test must earn its place
Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation.
One strong behavioral test through a normal production interface is usually better than several tests of private helpers or reconstructed internals. Be especially skeptical of unit tests that depend on private functions, methods, structs, fields, or implementation sequence: harmless internal refactors should not require test rewrites.
Avoid tests that primarily:
- assert private representation or historical implementation details;
- mirror implementation steps or exercise branches solely for coverage;
- duplicate stronger coverage at another layer;
- pin incidental scheduling/ordering that is not contractual;
- pin exact human-readable wording unless that wording is itself contractual;
- test standard-library/dependency behavior;
- require production test-only observability for a fact naturally visible through packets/events/outcomes.
- use subprocess machinery or test-only observability when the owning crate exposes the behavior directly.
A harmless private refactor should not require widespread test rewrites unless a deliberately protected invariant changed.
If a component/integration test through the owning crate's normal API protects the same durable behavior, prefer it unless a focused unit test materially improves precision, diagnosis, or coverage of a hard-to-observe invariant.
### Unit tests where they earn it
Focused unit/table-driven tests are encouraged for stable pure or algorithmic behavior, especially:
- `irtt-proto` layouts, codecs, vectors, HMAC, parsing, malformed input, and boundaries;
- `irtt-stats` formulas, loss accounting, IPDV, rolling windows, and bounded state;
- hand-rolled data structures and subtle pure invariants;
- resource/liveness guarantees that cannot be observed reliably from a higher layer.
If a unit test and a normal-interface test prove the same durable behavior, prefer the normal-interface test unless the unit test materially improves precision or diagnosis.
Focused unit/table-driven tests are appropriate for stable pure or algorithmic behavior, especially protocol codecs/vectors, statistics formulas, hand-rolled data structures, and resource/liveness invariants that cannot be observed reliably from a higher-level production interface.
### Temporary implementation tests
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition.
Before finalizing, remove or consolidate them unless they independently protect a durable regression or invariant. A test being useful during development does not by itself justify permanent repository code.
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition. Remove or consolidate them before finalizing unless they independently protect a durable regression or invariant.
### Resource/liveness tests and hooks
@@ -171,6 +173,7 @@ Use the narrowest useful checks first. For substantive workspace changes, final
cargo fmt --all -- --check
cargo check --workspace --all-targets --all-features --locked
cargo test --workspace --all-targets --all-features --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
```
@@ -192,7 +195,7 @@ Report concisely:
1. what changed;
2. verification actually run and its result;
3. meaningful tests added/updated/consolidated/removed;
3. meaningful tests added/updated/consolidated/removed, with justification for each persistent test added or materially expanded;
4. remaining risks, ambiguities, or follow-ups.
Do not produce a giant changelog of incidental edits.
+80
View File
@@ -10,6 +10,12 @@ release. The format is loosely based on
## irtt-proto
### 0.5.1
#### Added
- Added a crate-specific README and runnable API doctests to make the published crate's protocol types and codecs easier to discover and use.
### 0.5.0
#### Added
@@ -28,6 +34,18 @@ release. The format is loosely based on
## irtt-client
### 0.5.2
#### Fixed
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
### 0.5.1
#### Added
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
### 0.5.0
#### Added
@@ -50,6 +68,24 @@ release. The format is loosely based on
## irtt-server
### 0.5.3
#### Added
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
### 0.5.2
#### Changed
- `parse_hex_pattern`'s chunking now uses the stable `slice::as_chunks` API instead of `chunks_exact`; no behavior change.
### 0.5.1
#### Added
- New public `address_family_available()` helper (re-exported from `set`), for probing whether an address family can actually bind locally rather than merely constructing a socket for it. Used by the `irtt-rs` server applet to distinguish a genuinely unsupported address family from an ordinary bind failure when falling back from its default dual-family bind.
### 0.5.0
#### Added
@@ -77,6 +113,12 @@ release. The format is loosely based on
## irtt-stats
### 0.5.1
#### Added
- Added a crate-specific README, a finite-versus-continuous statistics example, and runnable API doctests for the published crate.
### 0.5.0
#### Fixed
@@ -90,6 +132,44 @@ release. The format is loosely based on
## irtt-rs
### 0.7.1
#### Fixed
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
### 0.7.0
#### Added
- Client target arguments now support per-target HMAC configuration: append `@hmac=KEY` to override the global `--hmac` value for that target, or use `@hmac=` to explicitly disable HMAC for it. The target syntax supports context-aware escaping for literal delimiters.
- Continuous client runs can now take complete desired target sets from standard input with `--targets-stdin`. Each non-empty record atomically replaces the current set, `[]` selects an empty set, and EOF requests a graceful stop. The controller retains only the newest unapplied record under transient replacement backpressure, with explicit bounds on record size, desired targets, and live target generations.
#### Changed
- A peer-initiated close is now target-local in stdin-controlled continuous runs: the controller and other desired targets keep running, and a later desired set can start that target again.
### 0.6.2
#### Added
- Every `irtt-server` applet flag can now also be set via an `IRTT_SERVER_*` environment variable (`IRTT_SERVER_BIND`, `IRTT_SERVER_HMAC`, `IRTT_SERVER_MAX_SESSIONS`, `IRTT_SERVER_MAX_PACKET_LENGTH`, `IRTT_SERVER_MIN_INTERVAL`, `IRTT_SERVER_BURST`, `IRTT_SERVER_IDLE_TIMEOUT`, `IRTT_SERVER_MAX_DURATION`, `IRTT_SERVER_TIMESTAMP_ALLOWANCE`, `IRTT_SERVER_NO_DSCP`), for deployments that configure by environment (containers, orchestrators) instead of command-line flags. An explicit flag on the command line still overrides the corresponding variable.
- A `Dockerfile` and Gitea Actions workflow publishing a minimal, statically linked `irtt-server` container image (cross-compiled for `amd64`/`arm64` via `tonistiigi/xx`, no QEMU) to this project's Gitea and GHCR container registries.
#### Changed
- The bundled `ctrlc` dependency now enables its `termination` feature, so the applet also handles `SIGTERM` (not just `SIGINT`) for graceful shutdown.
### 0.6.1
#### Added
- The `irtt-server` applet now has sensible zero-argument defaults: with no `--bind`, it binds the wildcard IRTT port on both address families (`[::]:2112` then `0.0.0.0:2112`) instead of requiring an explicit address, on platforms where wildcard reply-source selection is supported. Any explicit `--bind` fully replaces the default pair rather than adding to it.
#### Changed
- If one of the two default listeners fails because its address family has no local support on the host (e.g. IPv6 administratively disabled on an otherwise IPv4-capable host), the server now falls back to serving just the surviving family instead of failing outright. Any other failure reason (port in use, permission denied, no safe wildcard reply-source path) still fails startup as it would for an explicit `--bind`.
### 0.6.0
#### Changed
Generated
+60 -60
View File
@@ -213,9 +213,9 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clap"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
@@ -223,9 +223,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.6"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
@@ -235,14 +235,14 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.4"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.5",
]
[[package]]
@@ -385,9 +385,9 @@ dependencies = [
[[package]]
name = "darling"
version = "0.24.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core",
"darling_macro",
@@ -395,26 +395,26 @@ dependencies = [
[[package]]
name = "darling_core"
version = "0.24.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.3",
"syn 3.0.5",
]
[[package]]
name = "darling_macro"
version = "0.24.0"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 3.0.3",
"syn 3.0.5",
]
[[package]]
@@ -496,9 +496,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.17.0"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "equivalent"
@@ -710,12 +710,12 @@ dependencies = [
"indoc",
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.5",
]
[[package]]
name = "irtt-client"
version = "0.5.0"
version = "0.5.2"
dependencies = [
"irtt-proto",
"irtt-server",
@@ -738,7 +738,7 @@ dependencies = [
[[package]]
name = "irtt-proto"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"hmac",
"md-5",
@@ -749,7 +749,7 @@ dependencies = [
[[package]]
name = "irtt-rs"
version = "0.6.0"
version = "0.7.1"
dependencies = [
"clap",
"crossterm",
@@ -764,7 +764,7 @@ dependencies = [
[[package]]
name = "irtt-server"
version = "0.5.0"
version = "0.5.3"
dependencies = [
"getrandom 0.4.3",
"irtt-proto",
@@ -776,7 +776,7 @@ dependencies = [
[[package]]
name = "irtt-stats"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"irtt-client",
"proptest",
@@ -805,9 +805,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
@@ -881,15 +881,15 @@ dependencies = [
[[package]]
name = "log"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.18.2"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
dependencies = [
"hashbrown 0.17.1",
]
@@ -943,9 +943,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "mio"
version = "1.2.2"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"log",
@@ -1118,9 +1118,9 @@ dependencies = [
[[package]]
name = "pest"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
dependencies = [
"memchr",
"ucd-trie",
@@ -1128,9 +1128,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
dependencies = [
"pest",
"pest_generator",
@@ -1138,9 +1138,9 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
dependencies = [
"pest",
"pest_meta",
@@ -1151,9 +1151,9 @@ dependencies = [
[[package]]
name = "pest_meta"
version = "2.9.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
dependencies = [
"pest",
]
@@ -1185,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.7",
"rand 0.8.8",
]
[[package]]
@@ -1294,9 +1294,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.7"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_core 0.6.4",
]
@@ -1569,7 +1569,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.5",
]
[[package]]
@@ -1628,9 +1628,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "socket2"
@@ -1705,9 +1705,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
@@ -1840,7 +1840,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.5",
]
[[package]]
@@ -1886,7 +1886,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.5",
]
[[package]]
@@ -1944,9 +1944,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.24.1"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"atomic",
"getrandom 0.4.3",
@@ -1995,9 +1995,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
@@ -2008,9 +2008,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2018,22 +2018,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.5",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
+2 -5
View File
@@ -15,22 +15,19 @@ license = "Apache-2.0"
rust-version = "1.88"
repository = "https://github.com/Lochnair/irtt-rs"
homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md"
[workspace.dependencies]
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.5.0", path = "crates/irtt-client" }
irtt-server = { version = "0.5.0", path = "crates/irtt-server" }
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = "symbols"
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
# Builds the irtt-server applet only, statically linked against musl, and
# ships it in a scratch image with no shell or libc.
#
# Cross-compiles for TARGETPLATFORM using tonistiigi/xx: the builder stage
# always runs natively on BUILDPLATFORM (no QEMU emulation) and xx-cargo
# picks the right musl target triple, sysroot, and linker for whatever
# platform buildx asked for.
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder
COPY --from=xx / /
RUN apk add --no-cache clang lld file
ARG TARGETPLATFORM
RUN xx-apk add --no-cache musl-dev
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
RUN xx-cargo build \
--release \
--locked \
-p irtt-rs \
--bin irtt-server \
--no-default-features \
--features server \
--target-dir /build/target \
&& xx-verify /build/target/$(xx-cargo --print-target-triple)/release/irtt-server \
&& cp /build/target/$(xx-cargo --print-target-triple)/release/irtt-server /irtt-server
FROM scratch
COPY --from=builder /irtt-server /irtt-server
ENTRYPOINT ["/irtt-server"]
+125 -308
View File
@@ -1,363 +1,180 @@
# irtt-rs
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server.
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server:
finite or continuous UDP latency probing, plus the server side those probes
run against.
It provides command-line applets and reusable Rust libraries for finite or continuous latency probing, plus the server side those probes can run against. It is intended for interactive diagnostics, scripting, monitoring, and integration into applications such as network autorate controllers.
This is not the upstream IRTT project. For the original implementation, protocol background, and broader documentation, see [heistp/irtt](https://github.com/heistp/irtt).
This is not the upstream IRTT project. For the original implementation,
protocol background, and broader documentation, see
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is
named `irtt-rs` rather than `irtt` to avoid conflicting with or being
mistaken for it.
## Features
* Finite and continuous probe sessions
* Concurrent probing of multiple targets
* Reusable client library with a structured event stream
* Human-readable table output
* CSV, TSV, and JSON Lines output for scripts and monitoring systems
* Selectable output columns
* Automatic final summaries for eligible table-output runs
* Concurrent probing of multiple targets, with staggered or burst pacing
* Reusable client library with a structured event stream (blocking, Tokio,
or managed)
* Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
monitoring systems, with selectable output columns
* Optional terminal UI
* Reusable Tokio-native server library
* UDP server applet with explicit bind and session policy options
* UDP server applet with sensible zero-argument defaults and explicit bind
and session-policy options
* Router-friendly multi-applet binary layout
The installed dispatcher is named `irtt-rs` rather than `irtt` to avoid conflicting with or being mistaken for upstream IRTT.
## Comparison with upstream IRTT
`irtt-rs` is an independent, compatible reimplementation, not a successor or
replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt)
remains the original implementation. Interoperability is a project goal; see
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and
[`docs/protocol/`](docs/protocol/) for the black-box interoperability
methodology and protocol baseline this project verifies against. Choose
between them based on the capabilities and deployment model you need.
The table below was checked against upstream release
[v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
This project's own interop testing and protocol baseline are currently
pinned to the prior release, v0.9.1; the wire protocol version number is
unchanged between the two, but v0.9.2 has not itself been exercised against
this project's interop harness.
| Capability | irtt-rs | upstream IRTT (v0.9.2) |
| --- | --- | --- |
| Wire protocol | Protocol version 1, verified against upstream v0.9.1 | Protocol version 1 (unchanged in v0.9.2) |
| Finite probing | Supported, default 10s | Supported, default 1m |
| Continuous/indefinite operation | Supported (`--duration 0`); still computes running statistics (see below) | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
| Statistics in continuous mode | Running count/min/max/mean/variance per target; exact medians unavailable; bounded IPDV tracking | None in streaming mode — raw/event output only, by design, to avoid unbounded memory |
| Multi-target probing (one invocation) | Supported, with labels and staggered/burst pacing | Not supported — one remote address per invocation |
| Terminal UI | `irtt-tui`, a live graph/dashboard applet | Not present |
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and a fixed-field raw mode; no CSV/TSV and no column selection |
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One combined Go package exposing both roles |
| Server: multiple listeners | `ServerSet` supervises several listeners in one process | `ListenAndServe` also serves multiple requested addresses |
| Server: wildcard reply-source | Default behavior on Linux/macOS/FreeBSD; refused elsewhere rather than served incorrectly | Opt-in via `--set-src-ip`, with a documented per-packet allocation cost |
| Server: zero-argument bind | Binds the wildcard address on both families by default, gated by the platform check above | Binds the wildcard address on both families by default; no documented platform gate |
| Prebuilt release binaries | Published for six target triples | None published; install via `go install` or manual build |
Upstream has capabilities this project does not implement: a `report`
subcommand for replaying saved JSON results, benchmarking and
timer/clock-diagnostic subcommands, client-side payload fill and
don't-fragment/wait-strategy/timer-algorithm controls, an operator-configurable
server-side fill policy (this project's server always honors a session's
negotiated fill request, with a fixed fallback pattern, but has no equivalent
to upstream's flags for restricting or overriding it), syslog logging, and
bundled systemd/OpenRC service files.
This project has capabilities upstream does not implement: multi-target
probing with labels, the terminal UI, CSV/TSV output with selectable
columns, a quantified and actively-warned memory-retention model for
long-running client sessions, and best-effort Linux kernel-timestamp
preference for its own send/receive endpoint.
## Installation
The build requires Rust 1.88 or newer.
From a local checkout:
Install the latest release from crates.io:
```sh
cargo install irtt-rs --locked
```
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
`irtt-tui`, and `irtt-server` binaries. 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 irtt-rs --locked --no-default-features --features client
```
For development, or to install directly from a checkout:
```sh
git clone https://github.com/Lochnair/irtt-rs.git
cd irtt-rs
cargo install --path crates/irtt-app
```
This installs:
* `irtt-rs`, the canonical multi-applet dispatcher
* `irtt-client`, the stream and text client
* `irtt-tui`, the terminal UI
* `irtt-server`, the UDP server
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-app --no-default-features --features client
cargo install --path crates/irtt-app --locked
```
## Quick start
Probe a server using the default settings:
Probe a server:
```sh
irtt-client netperf-eu.bufferbloat.net:2112
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
```
Set the test duration and probe interval:
```sh
irtt-client netperf-eu.bufferbloat.net:2112 \
--duration 30s \
--interval 100ms
```
Run continuously until interrupted:
Probe continuously until interrupted (`Ctrl-C`):
```sh
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
```
Use `Ctrl-C` to stop gracefully.
## Multiple targets
Probe several targets concurrently:
Watch the same session live in the terminal UI:
```sh
irtt-client host-a:2112 host-b:2112
irtt-tui netperf-eu.bufferbloat.net:2112
```
Every target argument accepts `[LABEL=]TARGET`. An optional `LABEL=` prefix assigns the logical
target name used in output and the TUI; without it, the target string itself is used. Assign
stable labels to targets:
Run a server with sensible defaults — no `--bind` needed:
```sh
irtt-client ams=ams.example.com:2112 sg=sg.example.com:2112
irtt-server
```
Labeled and unlabeled targets can be freely mixed in one argument list:
## Documentation
```sh
irtt-client host-a:2112 ams=ams.example.com:2112
```
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
formats and columns, measurement field definitions, finite/continuous
memory behavior, kernel timestamps.
* [`docs/irtt-tui.md`](docs/irtt-tui.md) — controls, continuous default,
retained history.
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
overrides, wildcard listener behavior, session policy, HMAC.
* [`docs/irtt-rs.md`](docs/irtt-rs.md) — the multicall dispatcher, applet
binary names, feature-gated builds.
* [docs.rs](https://docs.rs) for the library crates below.
* [`docs/protocol/`](docs/protocol/) and
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) for the wire
protocol specification and interoperability validation against upstream
`irtt`.
Multi-target sessions use staggered pacing by default. To send each target's probes together:
Every applet also documents its full option list with `--help`.
```sh
irtt-client host-a:2112 host-b:2112 --pacing burst
```
## Crates
Target labels are included in the default multi-target output.
| Crate | crates.io | What it is |
| --- | --- | --- |
| [`irtt-rs`](crates/irtt-app) | [crates.io](https://crates.io/crates/irtt-rs) | Application package: the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries |
| [`irtt-client`](crates/irtt-client) | [crates.io](https://crates.io/crates/irtt-client) | Reusable client/session library ([docs.rs](https://docs.rs/irtt-client)) |
| [`irtt-server`](crates/irtt-server) | [crates.io](https://crates.io/crates/irtt-server) | Reusable Tokio-native server library ([docs.rs](https://docs.rs/irtt-server)) |
| [`irtt-stats`](crates/irtt-stats) | [crates.io](https://crates.io/crates/irtt-stats) | Statistics aggregation over `irtt-client` events ([docs.rs](https://docs.rs/irtt-stats)) |
| [`irtt-proto`](crates/irtt-proto) | [crates.io](https://crates.io/crates/irtt-proto) | Low-level wire protocol encode/decode ([docs.rs](https://docs.rs/irtt-proto)) |
## Output formats
The CLI supports four event-row formats:
* `table`: human-readable terminal output
* `csv`: comma-separated output
* `tsv`: tab-separated output
* `jsonl`: one JSON object per event
Examples:
```sh
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-client <server> \
--format tsv \
--columns effective_rtt_us \
--header never
```
List all available columns with:
```sh
irtt-client --list-columns
```
Useful measurement fields include:
* `raw_rtt_us`: client-observed send-to-receive RTT
* `adjusted_rtt_us`: RTT adjusted for server processing when available
* `effective_rtt_us`: adjusted RTT when available, otherwise raw RTT
* `sd_us` and `rd_us`: send and receive one-way delay estimates
* `ipdv_us`: inter-packet delay variation
* `server_processing_us`: time spent processing the packet at the server
Adjusted RTT can be negative when server processing exceeds the measured raw RTT. One-way delay estimates can be negative because of clock skew between the client and server.
Default table output prints a final summary after completed finite runs and interrupted continuous runs when the run policy permits it. CSV, TSV, and JSON Lines output do not print this summary.
A peer closing a finite CLI or TUI run is accepted as a terminal outcome. In
continuous mode, peer closure exits nonzero unless the user requested
interruption, allowing a supervisor to restart the client.
## Terminal UI
When built with the `tui` feature, `irtt-tui` provides a live cumulative dashboard:
```sh
irtt-tui <server>
```
It runs continuously by default. A finite duration can be selected explicitly:
```sh
irtt-tui <server> --duration 30s
```
Multiple targets and pacing options work the same way as in `irtt-client`:
```sh
irtt-tui host-a:2112 host-b:2112 --pacing burst
```
Quit with `q` or `Ctrl-C`.
## Server
The server applet requires at least one explicit bind address:
```sh
irtt-server --bind 127.0.0.1:2112
```
The same applet is reachable through the dispatcher:
```sh
irtt-rs server --bind 192.0.2.10:2112
```
Repeat `--bind` to serve several addresses from one process, in the order given:
```sh
irtt-server \
--bind 0.0.0.0:2112 \
--bind [::]:2112
```
Every invocation runs through the same multi-listener path, so one address is an
ordinary set of one rather than a separate mode. The policy options below are
applied to every listener, but the listeners are otherwise independent: each has
its own sessions and tokens, so a session belongs to the address it was opened
on and `--max-sessions` bounds each listener rather than the process. Binding is
all or nothing — if any address cannot be bound, none are served — and a port of
`0` selects an unused port per listener. If a listener fails while running, the
others are shut down with it rather than leaving a service configured for two
families answering on one.
An explicit interface address works on every supported system. A wildcard bind
such as `--bind 0.0.0.0:2112` reads each request's local destination from packet
metadata and sends that request's reply from the same address, so a client on a
multi-homed host is answered from the endpoint it contacted. That is implemented
on Linux, macOS and FreeBSD; elsewhere a wildcard bind is refused rather than
served from a routing-table source address a client would discard. Wildcard
IPv4 and IPv6 listeners may share one port, as above.
Each bound endpoint is printed on startup once every listener is up, which also
resolves a port of `0`. `Ctrl-C` stops the server gracefully.
Session policy is set with options that map directly onto the server library's
configuration; anything left unset keeps the library default:
```sh
irtt-server \
--bind 192.0.2.10:2112 \
--max-sessions 512 \
--idle-timeout 30s
```
Two optional controls restrict what the server will provide a session, and both
are off unless asked for:
```sh
irtt-server \
--bind 192.0.2.10:2112 \
--timestamp-allowance single \
--no-dscp
```
`--timestamp-allowance` takes `dual` (the default, honoring every requested
placement), `single`, which provides at most one timestamp instant and answers a
request for both receive and send timestamps with the midpoint, or `none`, which
provides no timestamps. The requested clock source is never changed, so a single
instant is still reported once per requested clock. `--no-dscp` negotiates any
requested DSCP to zero, so clients are told their echo replies will be unmarked,
and they are sent unmarked.
To require authentication, pass the shared key both sides use:
```sh
irtt-server --bind 192.0.2.10:2112 --hmac secret
```
For the full option list:
```sh
irtt-server --help
```
## Library usage
`irtt-client` exposes the client session and event layer independently of CLI formatting and statistics.
Add it from a local checkout:
```toml
[dependencies]
irtt-client = { path = "crates/irtt-client", features = ["tokio"] }
```
Minimal managed-client example for a synchronous frontend:
```rust
use std::time::Duration;
use irtt_client::managed::{
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy,
ManagedTargetConfig, TargetId,
};
use irtt_client::ClientConfig;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = ManagedClientConfig {
client: ClientConfig {
duration: Some(Duration::from_secs(10)),
interval: Duration::from_secs(1),
..ClientConfig::default()
},
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
..ManagedClientConfig::default()
};
let targets = vec![ManagedTargetConfig::new(
TargetId::from("edge"),
"netperf-eu.bufferbloat.net:2112",
)];
let owner = BlockingManagedClient::start(config, targets)?;
let outcome = owner.join()?;
println!("session ended: {:?}", outcome.end_reason);
Ok(())
}
```
For presentation events, use `BlockingManagedClient::start_with_subscription`,
`ManagedEvent`, and `ManagedEventTryRecvError`, while consulting the durable
`handle.status()` and final outcome for authoritative state. Presentation
events are bounded and lossy, not reliable history. Dynamic desired-target
updates are available through `ManagedClientHandle::update_targets`.
`BlockingManagedClient` owns a dedicated Tokio runtime and thread for
synchronous callers. Callers that already own Tokio can drive
`ManagedClientTask` directly.
Each library crate has its own crates.io README describing what it's for and
when to use it instead of `irtt-client`/`irtt-server` directly.
## 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` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
`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-client --help
irtt-server --help
irtt-tui --help
```
| 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` |
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
## Development
Common verification commands:
```sh
cargo fmt --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo build -p irtt-rs --all-features --release
cargo fmt --all -- --check
cargo test --workspace --locked
cargo test --workspace --doc --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
```
## Project status
The client, event stream, machine-readable output, multi-target execution, local statistics, and optional TUI are implemented.
The server library and the server applet are implemented: open negotiation,
session state, echo processing, per-session rate limiting, idle expiry, the
maximum-duration close, HMAC authentication, the negotiated per-session reply
traffic class, which is applied on sockets that support it, server fill,
wildcard reply-source selection on Linux, macOS and FreeBSD, the optional
timestamp and DSCP restriction controls, and serving several listeners from one
process with independent sessions per listener.
Server replies fill their payload with the negotiated ServerFill mode: `none`,
which is zero-filled, `rand`, or `pattern:` with a repeating hexadecimal
pattern. Every valid descriptor is honored; a descriptor the server cannot
parse, and a client that expresses no fill preference at all, get the default
`pattern:69727474`.
See [`AGENTS.md`](AGENTS.md) for architecture, testing policy, and the
clean-room provenance boundary this project maintains against upstream
`irtt`.
+10 -4
View File
@@ -1,13 +1,13 @@
[package]
name = "irtt-rs"
version = "0.6.0"
version = "0.7.1"
description = "IRTT-compatible client, TUI, and server applications"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
@@ -47,9 +47,9 @@ server = ["dep:irtt-server", "dep:tokio"]
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
[dependencies]
clap = { version = "4.6.6", features = ["derive"] }
clap = { version = "4.6.7", features = ["derive", "env"] }
crossterm = { version = "0.29.0", optional = true }
ctrlc = "3.5.2"
ctrlc = { version = "3.5.2", features = ["termination"] }
irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true }
irtt-server = { workspace = true, optional = true }
@@ -60,3 +60,9 @@ tokio = { version = "1.53.1", default-features = false, features = ["rt", "time"
[dev-dependencies]
irtt-server.workspace = true
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = "symbols"
+33
View File
@@ -0,0 +1,33 @@
# irtt-rs
The IRTT-compatible client, TUI, and server applications, packaged as Cargo
package `irtt-rs`.
Installing this crate builds:
- `irtt-rs` — a multicall dispatcher binary that runs the client, TUI, or
server applet by subcommand.
- `irtt-client` — the dedicated client binary (feature `client`).
- `irtt-tui` — the interactive terminal UI (feature `tui`).
- `irtt-server` — the dedicated server binary (feature `server`).
All three features are enabled by default, so a plain `cargo install irtt-rs`
installs everything.
## This is an application, not a library
This package's library target is undocumented (`doc = false`) and exists only
to share code between its binaries. If you're looking for the client/session
or server API to embed in your own Rust program, use
[`irtt-client`](https://crates.io/crates/irtt-client) or
[`irtt-server`](https://crates.io/crates/irtt-server) instead.
## Usage
See the [project repository](https://github.com/Lochnair/irtt-rs) for
installation instructions, command-line usage, and configuration.
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
+2 -1
View File
@@ -22,7 +22,7 @@ pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match applet_basename(argv0) {
"irtt-client" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
@@ -199,6 +199,7 @@ mod tests {
detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server)
);
assert_eq!(detect_applet_from_argv0("irttd"), None);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
+33 -7
View File
@@ -11,15 +11,22 @@ pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. 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-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"
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
)]
pub targets: Vec<TargetArg>,
/// Read complete desired target sets from standard input in continuous mode.
#[arg(
long,
long_help = "Read one complete desired target set per stdin line in continuous mode. Commas separate targets and backslash escapes commas within a target. [] selects an empty desired set. Records are limited to 64 KiB. EOF stops the client gracefully and may discard a revision that has not yet been acknowledged as applied."
)]
pub targets_stdin: bool,
/// Managed group pacing for multi-target mode.
#[arg(
long,
@@ -34,7 +41,7 @@ pub struct ClientArgs {
default_value = "10s",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary only when interrupted."
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
)]
pub duration: Duration,
@@ -86,12 +93,16 @@ impl ClientArgs {
/// Targets are only required here. `--list-columns` returns before this is
/// called, so listing columns still needs no target.
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
if self.targets_stdin && !self.is_continuous() {
return Err("--targets-stdin requires --duration 0".to_owned());
}
prepare_managed_run(
&self.common,
self.duration,
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
stdin_controlled: self.targets_stdin,
},
)
}
@@ -159,14 +170,14 @@ mod tests {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None);
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.format, OutputFormat::Table);
assert_eq!(args.columns, None);
assert_eq!(args.header, HeaderMode::Auto);
assert!(!args.targets_stdin);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
@@ -254,8 +265,8 @@ mod tests {
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).is_err());
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
assert!(parse(&["label="]).unwrap().prepare().is_err());
}
#[test]
@@ -309,6 +320,21 @@ mod tests {
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
}
#[test]
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
let setup = args.prepare().unwrap();
assert!(setup.targets.is_empty());
assert!(setup.stdin_controlled);
let config = setup.managed_config();
assert_eq!(
config.completion,
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
);
assert_eq!(config.max_live_target_generations, 256);
}
#[test]
fn output_format_summary_policy_is_table_only() {
assert!(OutputFormat::Table.prints_summary());
+778 -10
View File
@@ -1,16 +1,16 @@
use std::collections::BTreeMap;
use std::{
collections::HashSet,
io::{self, Write},
sync::atomic::AtomicBool,
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
io::{self, BufRead, Write},
sync::{atomic::AtomicBool, Arc, Mutex},
thread,
time::Duration,
};
use irtt_client::{
managed::{
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
ManagedEventTryRecvError, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason,
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus,
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
},
ClientEvent,
};
@@ -20,13 +20,13 @@ use super::{
output::{EventRenderStats, OutputConfig},
};
use crate::shared::client::expected_probe_count;
use crate::shared::client::{
is_shutdown_requested,
expected_probe_count, is_shutdown_requested, parse_stdin_target_set,
session::{
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
},
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
};
use irtt_stats::{StatsCollector, StatsConfig};
@@ -41,6 +41,7 @@ const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
pub fn run_stream(
args: ClientArgs,
@@ -61,6 +62,9 @@ pub fn run_stream(
args.verbose,
)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
if setup.stdin_controlled {
return run_stdin_stream(setup, output_config, shutdown_requested);
}
let continuous = args.is_continuous();
let target_count = setup.target_count();
@@ -196,6 +200,561 @@ pub fn run_stream(
Ok(())
}
#[derive(Clone)]
struct StdinTargetSet {
revision: u64,
targets: Vec<ManagedTargetConfig>,
}
#[derive(Default)]
struct StdinMailboxState {
next_revision: u64,
latest: Option<StdinTargetSet>,
eof: bool,
fatal: Option<String>,
}
#[derive(Default)]
struct StdinMailbox {
state: Mutex<StdinMailboxState>,
}
impl StdinMailbox {
fn publish(&self, targets: Vec<ManagedTargetConfig>) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.next_revision = state.next_revision.saturating_add(1);
state.latest = Some(StdinTargetSet {
revision: state.next_revision,
targets,
});
}
fn finish(&self) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof = true;
}
fn fail(&self, error: String) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.fatal = Some(error);
state.latest = None;
}
fn latest(&self) -> Option<StdinTargetSet> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.clone()
}
fn clear_if_current(&self, revision: u64) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
{
state.latest = None;
}
}
fn is_current(&self, revision: u64) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.latest
.as_ref()
.is_some_and(|set| set.revision == revision)
}
fn fatal(&self) -> Option<String> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.fatal
.clone()
}
fn is_finished(&self) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.eof
}
}
fn read_stdin_record<R: BufRead>(
reader: &mut R,
buffer: &mut Vec<u8>,
) -> io::Result<Option<String>> {
buffer.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
if buffer.is_empty() {
return Ok(None);
}
if buffer.len() > MAX_STDIN_RECORD_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
if buffer.len() == MAX_STDIN_RECORD_BYTES + 1 {
if available.first() == Some(&b'\n') && buffer.last() == Some(&b'\r') {
reader.consume(1);
buffer.pop();
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
let before_newline = &available[..newline];
if buffer.len().saturating_add(before_newline.len()) > MAX_STDIN_RECORD_BYTES + 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
buffer.extend_from_slice(before_newline);
reader.consume(newline + 1);
if buffer.last() == Some(&b'\r') {
buffer.pop();
}
if buffer.len() > MAX_STDIN_RECORD_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
return std::str::from_utf8(buffer)
.map(str::to_owned)
.map(Some)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
}
let remaining = MAX_STDIN_RECORD_BYTES + 1 - buffer.len();
let take = available.len().min(remaining);
buffer.extend_from_slice(&available[..take]);
reader.consume(take);
if buffer.len() > MAX_STDIN_RECORD_BYTES && buffer.last() != Some(&b'\r') {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"stdin target record exceeds the maximum size",
));
}
}
}
fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
let mut line = 0_u64;
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
loop {
match read_stdin_record(reader, &mut record) {
Ok(Some(record)) => {
line = line.saturating_add(1);
if record.is_empty() {
continue;
}
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
Ok(targets) => {
mailbox.publish(targets.into_iter().map(|target| target.managed).collect())
}
Err(error) => {
mailbox.fail(format!("invalid --targets-stdin line {line}: {error}"));
return;
}
}
}
Ok(None) => {
mailbox.finish();
return;
}
Err(_) => {
mailbox.fail(format!("failed to read --targets-stdin line {}", line + 1));
return;
}
}
}
}
fn spawn_stdin_target_reader(mailbox: Arc<StdinMailbox>) -> io::Result<()> {
thread::Builder::new()
.name("irtt-targets-stdin".to_owned())
.spawn(move || {
let stdin = io::stdin();
read_stdin_target_sets(&mut stdin.lock(), &mailbox);
})
.map(|_| ())
}
#[derive(Clone)]
struct RetryAfterStatus {
revision: u64,
status: Arc<ManagedStatus>,
}
fn retry_waits_for_status_change(
retry_after: &mut Option<RetryAfterStatus>,
target_set: &StdinTargetSet,
status: &Arc<ManagedStatus>,
) -> bool {
let Some(retry) = retry_after else {
return false;
};
if retry.revision != target_set.revision {
*retry_after = None;
return false;
}
Arc::ptr_eq(&retry.status, status)
}
fn retry_after_capacity_rejection(
mailbox: &StdinMailbox,
target_set: &StdinTargetSet,
status: Arc<ManagedStatus>,
) -> Option<RetryAfterStatus> {
mailbox
.is_current(target_set.revision)
.then_some(RetryAfterStatus {
revision: target_set.revision,
status,
})
}
fn apply_latest_stdin_target_set(
handle: &ManagedClientHandle,
mailbox: &StdinMailbox,
retry_after: &mut Option<RetryAfterStatus>,
) -> Result<(), String> {
let Some(target_set) = mailbox.latest() else {
*retry_after = None;
return Ok(());
};
let status = handle.status();
if retry_waits_for_status_change(retry_after, &target_set, &status) {
return Ok(());
}
let receipt = handle
.update_targets(target_set.targets.clone())
.map_err(|_| "failed to submit --targets-stdin update".to_owned())?;
match receipt.blocking_wait() {
Ok(_) => {
mailbox.clear_if_current(target_set.revision);
*retry_after = None;
Ok(())
}
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
*retry_after = retry_after_capacity_rejection(mailbox, &target_set, status);
Ok(())
}
Err(_) => Err("--targets-stdin update was rejected".to_owned()),
}
}
struct BoundedTargetSet {
limit: usize,
order: VecDeque<TargetInstance>,
members: HashSet<TargetInstance>,
}
impl BoundedTargetSet {
fn new(limit: usize) -> Self {
Self {
limit,
order: VecDeque::with_capacity(limit),
members: HashSet::with_capacity(limit),
}
}
fn insert(&mut self, target: TargetInstance) -> bool {
if !self.members.insert(target.clone()) {
return false;
}
if self.order.len() == self.limit {
let evicted = self
.order
.pop_front()
.expect("bounded target order is nonempty");
self.members.remove(&evicted);
}
self.order.push_back(target);
true
}
}
fn process_stdin_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
) -> io::Result<()> {
match event {
ManagedEvent::TargetStateChanged { target, .. } => {
stats
.entry(target)
.or_insert_with(|| StatsCollector::new(stats_config(true)));
}
ManagedEvent::Client { target, event } => {
let collector = stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
print_events_with_stats(
stream_output,
std::slice::from_ref(&event),
Some(target.id.as_str()),
collector,
)?;
}
ManagedEvent::TargetFinished { outcome }
if terminal_targets.insert(outcome.target.clone()) =>
{
report_target_failure(&outcome);
}
_ => {}
}
Ok(())
}
fn drain_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<ManagedDrainState> {
drain_managed_events(events, dropped_events, |event| {
process_stdin_event(event, stream_output, stats, terminal_targets)
})
}
fn drain_final_stdin_events<W: Write>(
events: &mut ManagedEventSubscription,
stream_output: &mut StreamOutput<'_, W>,
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
terminal_targets: &mut BoundedTargetSet,
dropped_events: &mut u64,
) -> io::Result<()> {
loop {
match events.try_recv() {
Ok(event) => process_stdin_event(event, stream_output, stats, terminal_targets)?,
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
Err(ManagedEventTryRecvError::Lagged(count)) => {
*dropped_events = dropped_events.saturating_add(count);
}
}
}
Ok(())
}
fn reconcile_stdin_stats(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
final_summary_targets: Option<&BTreeSet<TargetInstance>>,
) {
let live = status
.targets
.iter()
.map(|target| target.target.clone())
.collect::<HashSet<_>>();
stats.retain(|target, _| {
live.contains(target)
|| final_summary_targets.is_some_and(|targets| targets.contains(target))
});
}
fn snapshot_stdin_summary_targets(
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
status: &ManagedStatus,
) -> BTreeSet<TargetInstance> {
let targets = status
.targets
.iter()
.map(|target| target.target.clone())
.collect::<BTreeSet<_>>();
for target in &targets {
stats
.entry(target.clone())
.or_insert_with(|| StatsCollector::new(stats_config(true)));
}
targets
}
#[derive(Clone)]
enum StdinStop {
Interrupted,
Eof,
Fatal(String),
}
fn stdin_stop_request(
shutdown_requested: &AtomicBool,
mailbox: &StdinMailbox,
) -> Option<StdinStop> {
if is_shutdown_requested(shutdown_requested) {
Some(StdinStop::Interrupted)
} else if let Some(error) = mailbox.fatal() {
Some(StdinStop::Fatal(error))
} else if mailbox.is_finished() {
Some(StdinStop::Eof)
} else {
None
}
}
fn run_stdin_stream(
setup: ManagedRunSetup,
output_config: OutputConfig,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
setup.managed_config(),
setup.managed_targets(),
)?;
let handle = owner.handle();
let mailbox = Arc::new(StdinMailbox::default());
spawn_stdin_target_reader(Arc::clone(&mailbox))?;
let mut stdout = io::LineWriter::new(io::stdout().lock());
let mut stream_output = StreamOutput {
config: output_config,
header_printed: false,
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
};
let mut stats = BTreeMap::new();
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
let mut retry_after = None;
let mut dropped_events = 0_u64;
let mut stop_requested = false;
let mut stop = None;
let mut summary_targets = None;
let mut subscription_closed = false;
loop {
let status = handle.status();
if stop.is_none() {
if let Some(requested_stop) = stdin_stop_request(shutdown_requested, &mailbox) {
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
stop = Some(requested_stop);
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
} else if let Err(error) =
apply_latest_stdin_target_set(&handle, &mailbox, &mut retry_after)
{
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
stop = Some(StdinStop::Fatal(error));
if request_managed_stop_once(&mut stop_requested) {
drop(handle.stop());
}
}
}
let drain_state = drain_stdin_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
reconcile_stdin_stats(&mut stats, &handle.status(), summary_targets.as_ref());
match drain_state {
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
ManagedDrainState::Closed => subscription_closed = true,
ManagedDrainState::BudgetExhausted => {}
}
if handle.status().final_outcome.is_some() || subscription_closed {
break;
}
}
let outcome = owner.join()?;
drain_final_stdin_events(
&mut events,
&mut stream_output,
&mut stats,
&mut terminal_targets,
&mut dropped_events,
)?;
if let Some(warning) = dropped_event_warning(dropped_events) {
eprintln!("{warning}");
}
for target in outcome.recent_target_outcomes.iter() {
if terminal_targets.insert(target.target.clone()) {
report_target_failure(target);
}
}
if outcome.discarded_target_outcomes != 0 {
eprintln!(
"irtt-rs: warning: {} final target outcomes were discarded",
outcome.discarded_target_outcomes
);
}
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
stream_output.print_final_summary = print_summary;
stream_output.show_running_only_summary_note = print_summary;
if let Some(summary_targets) = summary_targets {
let multi_target = summary_targets.len() > 1;
for target in summary_targets {
let stats = stats
.get(&target)
.expect("summary snapshot retains every selected target collector");
if multi_target && stream_output.config.prints_summary() {
writeln!(stream_output.out)?;
writeln!(
stream_output.out,
"target: {} (generation {})",
target.id, target.generation
)?;
}
stream_output.print_summary(stats)?;
}
}
stream_output.out.flush()?;
match stop {
Some(StdinStop::Fatal(error)) => Err(error.into()),
_ => match outcome.end_reason {
ManagedEndReason::DriverFailed(failure) => {
Err(format!("managed driver failed: {failure}").into())
}
_ => Ok(()),
},
}
}
fn process_event<W: Write>(
event: ManagedEvent,
stream_output: &mut StreamOutput<'_, W>,
@@ -384,9 +943,218 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
mod tests {
use super::*;
use irtt_client::managed::{
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId,
ManagedLifecycle, ManagedTargetFailure, ManagedTargetFailureKind,
ManagedTargetFailurePhase, ManagedTargetLifecycle, ManagedTargetStatus, TargetId,
};
use std::sync::Arc;
use std::{io::BufReader, io::Cursor, sync::Arc};
#[test]
fn stdin_record_reader_removes_only_line_framing() {
let mut reader = BufReader::with_capacity(2, Cursor::new(b" first \r\n\nthird\r"));
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some(" first ".to_owned())
);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some(String::new())
);
assert_eq!(
read_stdin_record(&mut reader, &mut buffer).unwrap(),
Some("third\r".to_owned())
);
assert_eq!(read_stdin_record(&mut reader, &mut buffer).unwrap(), None);
}
#[test]
fn stdin_record_reader_rejects_over_limit_unterminated_record() {
let input = vec![b'x'; MAX_STDIN_RECORD_BYTES + 1];
let mut reader = BufReader::with_capacity(17, Cursor::new(input));
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
let error = read_stdin_record(&mut reader, &mut buffer).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("maximum size"));
assert!(buffer.len() <= MAX_STDIN_RECORD_BYTES + 1);
}
#[test]
fn newer_mailbox_value_survives_an_earlier_submitted_acknowledgement() {
let mailbox = StdinMailbox::default();
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
let submitted = mailbox.latest().unwrap();
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
let pending = mailbox.latest().unwrap();
mailbox.clear_if_current(submitted.revision);
assert_eq!(mailbox.latest().unwrap().revision, pending.revision);
}
#[test]
fn rejected_update_retries_after_status_publishes_during_acknowledgement() {
let mailbox = StdinMailbox::default();
mailbox.publish(Vec::new());
let target_set = mailbox.latest().unwrap();
let saturated = Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]));
let capacity_freed = Arc::new(status_with_targets(&[]));
// Capacity becomes available after the transaction rejected but before
// its acknowledgement reaches the controller. The rejection path must
// retain the pre-submission status, not this later snapshot.
let mut retry_after =
retry_after_capacity_rejection(&mailbox, &target_set, Arc::clone(&saturated));
assert!(retry_waits_for_status_change(
&mut retry_after,
&target_set,
&saturated
));
assert!(!retry_waits_for_status_change(
&mut retry_after,
&target_set,
&capacity_freed
));
}
#[test]
fn later_desired_set_supersedes_a_capacity_rejected_candidate_before_retry() {
let mailbox = StdinMailbox::default();
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
let applied = mailbox.latest().unwrap();
mailbox.clear_if_current(applied.revision);
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
let rejected = mailbox.latest().unwrap();
let saturated = Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]));
let mut retry_after = retry_after_capacity_rejection(&mailbox, &rejected, saturated);
mailbox.publish(vec![ManagedTargetConfig::new("c", "127.0.0.1:2114")]);
let replacement = mailbox.latest().unwrap();
assert!(!retry_waits_for_status_change(
&mut retry_after,
&replacement,
&Arc::new(status_with_targets(&[TargetInstance {
id: TargetId::from("old"),
generation: 1,
}]))
));
assert!(retry_after.is_none());
assert_eq!(replacement.targets[0].id.as_str(), "c");
mailbox.clear_if_current(replacement.revision);
assert!(mailbox.latest().is_none());
}
#[test]
fn eof_and_shutdown_stop_without_waiting_for_the_stdin_reader() {
let mailbox = StdinMailbox::default();
let shutdown_requested = AtomicBool::new(false);
assert!(stdin_stop_request(&shutdown_requested, &mailbox).is_none());
mailbox.finish();
assert!(matches!(
stdin_stop_request(&shutdown_requested, &mailbox),
Some(StdinStop::Eof)
));
let mailbox = StdinMailbox::default();
shutdown_requested.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(matches!(
stdin_stop_request(&shutdown_requested, &mailbox),
Some(StdinStop::Interrupted)
));
}
fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus {
ManagedStatus {
lifecycle: ManagedLifecycle::Running,
stop_requested: false,
applied_command_sequence: 0,
desired_target_count: targets.len(),
connecting_target_count: 0,
opening_target_count: 0,
active_target_count: 0,
draining_target_count: 0,
closing_target_count: 0,
terminal_target_count: 0,
total_target_outcomes: 0,
successful_target_outcomes: 0,
failed_target_outcomes: 0,
peer_closed_target_outcomes: 0,
discarded_target_outcomes: 0,
targets: Arc::from(
targets
.iter()
.cloned()
.map(|target| ManagedTargetStatus {
target,
desired: true,
lifecycle: ManagedTargetLifecycle::Active,
server_addr: Arc::from("127.0.0.1:2112"),
remote: None,
})
.collect::<Vec<_>>()
.into_boxed_slice(),
),
recent_target_outcomes: Arc::from([]),
final_outcome: None,
}
}
#[test]
fn dynamic_accounting_prunes_retired_generations_and_retains_only_shutdown_snapshot() {
let retired = TargetInstance {
id: TargetId::from("edge"),
generation: 1,
};
let replacement = TargetInstance {
id: TargetId::from("edge"),
generation: 2,
};
let live_status = status_with_targets(std::slice::from_ref(&replacement));
let empty_status = status_with_targets(&[]);
let mut stats = BTreeMap::from([
(retired.clone(), StatsCollector::new(stats_config(true))),
(replacement.clone(), StatsCollector::new(stats_config(true))),
]);
reconcile_stdin_stats(&mut stats, &live_status, None);
assert_eq!(stats.keys().cloned().collect::<Vec<_>>(), [replacement]);
let summary_targets = snapshot_stdin_summary_targets(&mut stats, &live_status);
stats.insert(retired, StatsCollector::new(stats_config(true)));
reconcile_stdin_stats(&mut stats, &empty_status, Some(&summary_targets));
assert_eq!(
stats.keys().cloned().collect::<Vec<_>>(),
[TargetInstance {
id: TargetId::from("edge"),
generation: 2,
}]
);
}
#[test]
fn terminal_deduplication_is_bounded_under_target_churn() {
let mut terminal_targets = BoundedTargetSet::new(2);
let target = |generation| TargetInstance {
id: TargetId::from("edge"),
generation,
};
assert!(terminal_targets.insert(target(1)));
assert!(terminal_targets.insert(target(2)));
assert!(terminal_targets.insert(target(3)));
assert!(terminal_targets.insert(target(1)));
assert!(!terminal_targets.insert(target(3)));
}
fn long_finite_args() -> ClientArgs {
use clap::Parser;
+29 -3
View File
@@ -12,6 +12,7 @@
use std::time::Duration;
#[cfg(feature = "client")]
use irtt_client::SignedDuration;
/// Rendering used wherever a scalar is unavailable.
@@ -61,6 +62,7 @@ pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
}
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
#[cfg(feature = "client")]
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
value.map(format_ns_f64).unwrap_or_else(absent)
}
@@ -71,6 +73,7 @@ pub(crate) fn format_duration(value: Duration) -> String {
}
/// Format a signed duration as a nanosecond scalar, preserving its sign.
#[cfg(feature = "client")]
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
format_ns_i128(value.as_nanos())
}
@@ -88,6 +91,7 @@ pub(crate) fn format_percent(value: f64) -> String {
/// Format `value` as a percentage of `total`, capped at 100%.
///
/// A zero total has no percentage to report and renders as [`ABSENT`].
#[cfg(feature = "tui")]
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
if total == 0 {
ABSENT.to_owned()
@@ -97,11 +101,13 @@ pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
}
/// Format a scalar count.
#[cfg(feature = "tui")]
pub(crate) fn format_count(value: u64) -> String {
value.to_string()
}
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
#[cfg(feature = "tui")]
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
value.map(format_count).unwrap_or_else(absent)
}
@@ -152,6 +158,11 @@ mod tests {
fn durations_render_as_nanosecond_scalars() {
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
}
#[test]
#[cfg(feature = "client")]
fn signed_durations_render_as_nanosecond_scalars() {
assert_eq!(
format_signed_duration(SignedDuration::from_nanos(-1_500)),
"-1.5µs"
@@ -161,18 +172,33 @@ mod tests {
#[test]
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
assert_eq!(format_optional_ns_i128(None), ABSENT);
assert_eq!(format_optional_ns_f64(None), ABSENT);
assert_eq!(format_optional_count(None), ABSENT);
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
assert_eq!(format_percent(f64::NAN), ABSENT);
}
#[test]
#[cfg(feature = "client")]
fn absent_optional_ns_f64_renders_as_a_placeholder() {
assert_eq!(format_optional_ns_f64(None), ABSENT);
}
#[test]
#[cfg(feature = "tui")]
fn absent_counts_and_ratios_render_as_a_placeholder() {
assert_eq!(format_optional_count(None), ABSENT);
assert_eq!(format_percent_ratio(1, 0), ABSENT);
}
#[test]
fn percentages_and_counts_have_one_spelling() {
fn percentages_have_one_spelling() {
assert_eq!(format_percent(0.0), "0.00%");
assert_eq!(format_percent(12.345), "12.35%");
}
#[test]
#[cfg(feature = "tui")]
fn percent_ratios_and_counts_have_one_spelling() {
assert_eq!(format_percent_ratio(1, 4), "25.00%");
assert_eq!(format_percent_ratio(5, 4), "100.00%");
assert_eq!(format_count(42), "42");
+232 -17
View File
@@ -1,8 +1,14 @@
use std::{net::SocketAddr, time::Duration};
use std::{
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
use clap::{Parser, ValueEnum};
use irtt_server::{ServerConfig, TimestampAllowance};
/// The ordinary IRTT port, used to build the zero-argument default binds.
const DEFAULT_PORT: u16 = 2112;
// Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart.
@@ -10,15 +16,15 @@ use irtt_server::{ServerConfig, TimestampAllowance};
#[command(
name = "irtt-server",
about = "Minimal IRTT-compatible UDP server",
after_help = "Policy options left unset keep the irtt-server library defaults.\nRepeat --bind to serve multiple addresses in one process. All listeners use the same policy but keep independent session namespaces."
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
)]
pub struct ServerArgs {
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
#[arg(
long,
required = true,
value_name = "ADDR",
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports."
env = "IRTT_SERVER_BIND",
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nWith no --bind at all, the server binds the wildcard IRTT port on both address families, [::]:2112 then 0.0.0.0:2112, on platforms with wildcard reply-source support; elsewhere it fails and asks for an explicit address instead of guessing one. Any explicit --bind replaces that default pair rather than adding to it.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports.\n\nIRTT_SERVER_BIND sets the same thing for deployments that configure by environment (systemd, Docker, ...), and takes exactly one address, the same as one --bind. Leave it unset on ordinary hosts: the wildcard default above is already what you want there. A --bind on the command line overrides it entirely."
)]
pub bind: Vec<SocketAddr>,
@@ -27,7 +33,8 @@ pub struct ServerArgs {
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."
env = "IRTT_SERVER_HMAC",
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, or in the environment as IRTT_SERVER_HMAC."
)]
pub hmac: Option<String>,
@@ -35,6 +42,7 @@ pub struct ServerArgs {
#[arg(
long,
value_name = "COUNT",
env = "IRTT_SERVER_MAX_SESSIONS",
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
)]
pub max_sessions: Option<usize>,
@@ -43,16 +51,18 @@ pub struct ServerArgs {
#[arg(
long,
value_name = "BYTES",
env = "IRTT_SERVER_MAX_PACKET_LENGTH",
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
)]
pub max_packet_length: Option<usize>,
/// Floor on the negotiated send interval, and the allowance refill cadence.
/// Floor on the negotiated send interval.
#[arg(
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
long_help = "Floor on the send interval a session may negotiate, and the cadence its reply allowance refills at. The negotiated interval is still capped at a quarter of the idle timeout afterwards. Use 0 for no time-based throttling."
env = "IRTT_SERVER_MIN_INTERVAL",
long_help = "Floor on the send interval a session may negotiate. The negotiated interval is still capped at a quarter of the idle timeout afterwards, so a value above that cap is not what actually gets negotiated.\n\nA session's reply allowance refills at the shorter of this floor and the interval it actually negotiated. Ordinarily that is this floor itself; it is the shorter, negotiated value only where the idle-timeout cap above pulled the negotiated interval below it. Use 0 for no time-based throttling."
)]
pub min_interval: Option<Duration>,
@@ -60,6 +70,7 @@ pub struct ServerArgs {
#[arg(
long,
value_name = "COUNT",
env = "IRTT_SERVER_BURST",
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
)]
pub burst: Option<u32>,
@@ -69,6 +80,7 @@ pub struct ServerArgs {
long,
value_name = "DURATION",
value_parser = parse_duration_allow_zero,
env = "IRTT_SERVER_IDLE_TIMEOUT",
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
)]
pub idle_timeout: Option<Duration>,
@@ -78,6 +90,7 @@ pub struct ServerArgs {
long,
value_name = "DURATION",
value_parser = parse_positive_duration,
env = "IRTT_SERVER_MAX_DURATION",
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
)]
pub max_duration: Option<Duration>,
@@ -87,14 +100,28 @@ pub struct ServerArgs {
long,
value_name = "MODE",
value_enum,
env = "IRTT_SERVER_TIMESTAMP_ALLOWANCE",
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
)]
pub timestamp_allowance: Option<TimestampAllowanceArg>,
/// Refuse to provide requested DSCP marking.
//
// `FalseyValueParser` rather than clap's default `bool` parser for a flag,
// which is what makes IRTT_SERVER_NO_DSCP usable from the environment at
// all. A flag's default parser accepts only the literals `true` and
// `false`, so the ordinary container/orchestrator spelling
// `IRTT_SERVER_NO_DSCP=1` did not enable the flag — it failed argument
// parsing and the server refused to start. Falsey semantics are clap's own
// documented recipe for a boolean environment flag: `0`, `false`, `no`,
// `n`, `off` and empty are off, anything else is on, and an absent
// variable is off as before. The command-line flag is unaffected: it still
// takes no value and still means "on".
#[arg(
long,
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused."
env = "IRTT_SERVER_NO_DSCP",
value_parser = clap::builder::FalseyValueParser::new(),
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused.\n\nIRTT_SERVER_NO_DSCP sets the same thing from the environment: 0, false, no, n, off and an empty value leave marking enabled, and any other value disables it."
)]
pub no_dscp: bool,
}
@@ -124,6 +151,20 @@ impl From<TimestampAllowanceArg> for TimestampAllowance {
}
impl ServerArgs {
/// The addresses this invocation binds: the requested `--bind` list, or
/// the wildcard IRTT port on both address families if none was given.
#[must_use]
pub fn resolve_binds(&self) -> Vec<SocketAddr> {
if self.bind.is_empty() {
vec![
SocketAddr::from((Ipv6Addr::UNSPECIFIED, DEFAULT_PORT)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT)),
]
} else {
self.bind.clone()
}
}
/// Builds the server configuration this invocation asks for.
#[must_use]
pub fn server_config(&self) -> ServerConfig {
@@ -222,11 +263,14 @@ mod tests {
use std::net::{Ipv4Addr, Ipv6Addr};
use super::*;
use crate::cmd::server::env_lock::with_env_lock;
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
let mut argv = vec!["irtt-server"];
argv.extend_from_slice(args);
ServerArgs::try_parse_from(argv)
with_env_lock(|| {
let mut argv = vec!["irtt-server"];
argv.extend_from_slice(args);
ServerArgs::try_parse_from(argv)
})
}
fn bound(args: &[&str]) -> ServerArgs {
@@ -236,12 +280,7 @@ mod tests {
}
#[test]
fn bind_is_required_and_parses_both_families() {
assert!(
parse(&[]).is_err(),
"a server with no address serves nothing"
);
fn bind_is_optional_and_parses_both_families() {
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
@@ -255,6 +294,39 @@ mod tests {
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
}
#[test]
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
let args = parse(&[]).unwrap();
assert!(
args.bind.is_empty(),
"the empty list is the parsed representation of \"no explicit bind\""
);
assert_eq!(
args.resolve_binds(),
[
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
],
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
);
}
#[test]
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
assert_eq!(
args.resolve_binds(),
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
"one explicit bind means exactly that one listener, not that listener plus defaults"
);
}
#[test]
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
assert_eq!(args.resolve_binds(), args.bind);
}
#[test]
fn bind_is_repeatable_and_keeps_the_requested_order() {
let args = parse(&[
@@ -432,4 +504,147 @@ mod tests {
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
}
/// Runs `body` with the given environment variables set, restoring
/// whatever was there before (present or absent) once `body` returns.
/// Held under `with_env_lock` for the whole span, including `body`, so a
/// `parse` call elsewhere never observes a partially-applied environment.
fn with_env<T>(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
with_env_lock(|| {
let previous: Vec<(&str, Option<String>)> = vars
.iter()
.map(|(k, _)| (*k, std::env::var(k).ok()))
.collect();
for (k, v) in vars {
std::env::set_var(k, v);
}
let result = body();
for (k, v) in previous {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
result
})
}
#[test]
fn environment_variables_supply_policy_when_no_flag_is_given() {
with_env(
&[
("IRTT_SERVER_MAX_SESSIONS", "512"),
("IRTT_SERVER_MAX_PACKET_LENGTH", "1472"),
("IRTT_SERVER_MIN_INTERVAL", "20ms"),
("IRTT_SERVER_BURST", "3"),
("IRTT_SERVER_IDLE_TIMEOUT", "30s"),
("IRTT_SERVER_MAX_DURATION", "2m"),
("IRTT_SERVER_TIMESTAMP_ALLOWANCE", "single"),
("IRTT_SERVER_HMAC", "secret"),
("IRTT_SERVER_NO_DSCP", "true"),
],
|| {
let config = parse(&["--bind", "127.0.0.1:2112"])
.unwrap()
.server_config();
assert_eq!(config.max_sessions(), 512);
assert_eq!(config.max_packet_length(), 1472);
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
assert_eq!(config.burst_allowance(), 3);
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
assert!(!config.dscp_allowed());
},
);
}
/// `IRTT_SERVER_NO_DSCP` is the one policy variable whose value is a
/// boolean rather than a number, a duration or a string, and the
/// container/orchestrator deployments this whole environment surface
/// exists for spell booleans `1`/`0`. clap's default parser for a flag
/// accepts only the literals `true`/`false` and turns everything else
/// into a *parse failure*, so `IRTT_SERVER_NO_DSCP=1` used to stop the
/// server from starting at all rather than enabling the flag. Falsey
/// semantics are what keeps that from being a startup failure.
#[test]
fn the_no_dscp_environment_variable_accepts_ordinary_boolean_spellings() {
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
let config = parse(&["--bind", "127.0.0.1:2112"])
.expect("a truthy value must not fail argument parsing")
.server_config();
assert!(!config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
});
}
for value in ["0", "false", "no", "off", ""] {
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
let config = parse(&["--bind", "127.0.0.1:2112"])
.expect("a falsey value must not fail argument parsing")
.server_config();
assert!(config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
});
}
}
/// The command-line flag stays a flag: it takes no value, whatever the
/// environment parser accepts.
#[test]
fn the_no_dscp_flag_still_takes_no_command_line_value() {
assert!(parse(&["--bind", "127.0.0.1:2112", "--no-dscp=false"]).is_err());
assert!(!bound(&["--no-dscp"]).server_config().dscp_allowed());
}
#[test]
fn an_explicit_flag_overrides_the_environment() {
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
let config = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
.unwrap()
.server_config();
assert_eq!(
config.max_sessions(),
4,
"the command-line value wins over the environment"
);
});
}
#[test]
fn the_environment_wins_over_the_built_in_default() {
with_env(&[("IRTT_SERVER_BURST", "9")], || {
let config = bound(&[]).server_config();
assert_eq!(
config.burst_allowance(),
9,
"an unset flag still falls back to the environment before the library default"
);
});
}
#[test]
fn irtt_server_bind_supplies_a_single_address_from_the_environment() {
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
let args = parse(&[]).unwrap();
assert_eq!(args.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2113))]);
});
}
#[test]
fn an_explicit_bind_flag_overrides_irtt_server_bind() {
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
let args = parse(&["--bind", "[::1]:2112"]).unwrap();
assert_eq!(args.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
});
}
#[test]
fn leaving_irtt_server_bind_unset_keeps_the_wildcard_default() {
let args = parse(&[]).unwrap();
assert!(
args.bind.is_empty(),
"with no --bind and no IRTT_SERVER_BIND, resolve_binds still falls back to the wildcard pair"
);
}
}
@@ -0,0 +1,30 @@
//! Serializes every test in this module tree that touches process
//! environment variables.
//!
//! `ServerArgs` parsing reads `IRTT_SERVER_*` via `clap`'s `env` support, and
//! `std::env::set_var`/`remove_var` mutate genuinely process-wide state.
//! Every test that parses `ServerArgs` — whether or not it sets an env var
//! itself — must go through [`with_env_lock`], or a parse in one test thread
//! can observe a value another thread's test set only for the duration of
//! its own assertions. Reentrant per-thread, since a test that sets env vars
//! typically calls `parse` again from inside its own locked section.
use std::{cell::Cell, sync::Mutex};
static ENV_LOCK: Mutex<()> = Mutex::new(());
thread_local! {
static ENV_LOCK_HELD: Cell<bool> = const { Cell::new(false) };
}
pub(crate) fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
if ENV_LOCK_HELD.with(Cell::get) {
return f();
}
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
ENV_LOCK_HELD.with(|held| held.set(true));
let result = f();
ENV_LOCK_HELD.with(|held| held.set(false));
result
}
+2
View File
@@ -1,4 +1,6 @@
pub mod args;
#[cfg(test)]
pub(crate) mod env_lock;
pub mod run;
pub use args::ServerArgs;
+135 -11
View File
@@ -1,9 +1,10 @@
use std::{
net::SocketAddr,
sync::atomic::{AtomicBool, Ordering},
time::Duration,
};
use irtt_server::ServerSet;
use irtt_server::{address_family_available, ServerSet, ServerSetError};
use super::args::ServerArgs;
@@ -36,7 +37,29 @@ async fn serve(
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config();
let set = ServerSet::bind(args.bind, config).await?;
let binds = args.resolve_binds();
let is_implicit_default = args.bind.is_empty();
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
Ok(set) => set,
Err(ServerSetError::ListenerSetup { addr, source }) => {
let family_available = address_family_available(addr);
match implicit_default_fallback(is_implicit_default, family_available, &binds, addr) {
Some(remaining) => {
eprintln!(
"irtt-server: default listener {addr} unavailable ({source}); \
continuing with the remaining default listener"
);
// `remaining`'s lone survivor is bound exactly as it
// would be alone: the address family that just failed
// has no local support at all, so nothing on that family
// could be competing for the surviving listener's port.
ServerSet::bind(remaining, config).await?
}
None => return Err(ServerSetError::ListenerSetup { addr, source }.into()),
}
}
Err(err) => return Err(err.into()),
};
// Only once every listener is up. Announcing one address and then failing
// to bind the next would report a service that never started.
for addr in set.local_addrs() {
@@ -46,6 +69,37 @@ async fn serve(
Ok(())
}
/// Whether a listener-setup failure for `failed_addr` should be retried with
/// a reduced bind set instead of failing the whole invocation, and if so,
/// what to retry with.
///
/// Only the implicit zero-argument default gets this treatment, and only
/// when `failed_addr`'s own address family has no local support on this host
/// at all (`family_available` is `false`) — not merely because its specific
/// port was unavailable for some other reason: already in use, permission
/// denied, no safe wildcard reply-source path, or anything else
/// `ServerSet::bind` can fail with. Those are real configuration or
/// environment problems this invocation should fail on, not silently work
/// around by dropping a listener the operator asked for. An explicit
/// `--bind` list keeps the library's ordinary all-or-nothing behavior
/// regardless.
fn implicit_default_fallback(
is_implicit_default: bool,
family_available: bool,
original_binds: &[SocketAddr],
failed_addr: SocketAddr,
) -> Option<Vec<SocketAddr>> {
if !is_implicit_default || family_available || !original_binds.contains(&failed_addr) {
return None;
}
let remaining: Vec<SocketAddr> = original_binds
.iter()
.copied()
.filter(|&addr| addr != failed_addr)
.collect();
(!remaining.is_empty()).then_some(remaining)
}
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
while !shutdown_requested.load(Ordering::Relaxed) {
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
@@ -57,12 +111,15 @@ mod tests {
use clap::Parser;
use super::*;
use crate::cmd::server::env_lock::with_env_lock;
/// The ordinary invocation, and the one that keeps the single-listener case
/// on the same orchestration path as every other.
#[test]
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
let args = ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap();
let args = with_env_lock(|| {
ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap()
});
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
@@ -70,16 +127,83 @@ mod tests {
#[test]
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
let args = ServerArgs::try_parse_from([
"irtt-server",
"--bind",
"127.0.0.1:0",
"--bind",
"127.0.0.1:0",
])
.unwrap();
let args = with_env_lock(|| {
ServerArgs::try_parse_from([
"irtt-server",
"--bind",
"127.0.0.1:0",
"--bind",
"127.0.0.1:0",
])
.unwrap()
});
let shutdown_requested = AtomicBool::new(true);
run_server(args, &shutdown_requested).unwrap();
}
fn default_binds() -> Vec<SocketAddr> {
with_env_lock(|| {
ServerArgs::try_parse_from(["irtt-server"])
.unwrap()
.resolve_binds()
})
}
#[test]
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
let binds = default_binds();
let failed = binds[0];
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
assert_eq!(remaining, [binds[1]]);
}
#[test]
fn a_supported_familys_failure_never_falls_back() {
// The family itself works fine here; whatever made this specific
// bind fail (port in use, permission, ...) is a real problem this
// invocation should surface, not paper over.
let binds = default_binds();
assert_eq!(
implicit_default_fallback(true, true, &binds, binds[0]),
None,
"family_available: true means this is not a family-support problem"
);
}
#[test]
fn explicit_binds_never_fall_back() {
let binds = default_binds();
assert_eq!(
implicit_default_fallback(false, false, &binds, binds[0]),
None
);
}
#[test]
fn a_failure_outside_the_requested_set_does_not_fall_back() {
let binds = default_binds();
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
assert_eq!(
implicit_default_fallback(true, false, &binds, unrelated),
None,
"the failing address must actually be one this invocation asked for"
);
}
#[test]
fn losing_the_only_remaining_default_address_does_not_fall_back() {
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
assert_eq!(
implicit_default_fallback(true, false, &single, single[0]),
None,
"no address would be left to retry with"
);
}
}
+6 -6
View File
@@ -14,13 +14,13 @@ pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
pub struct TuiArgs {
/// Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
#[arg(
value_name = "TARGET",
num_args = 1..,
required = true,
value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL=. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example us=host-b.example"
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
)]
pub targets: Vec<TargetArg>,
@@ -58,6 +58,7 @@ impl TuiArgs {
TargetSelection {
targets: &self.targets,
pacing: self.pacing,
stdin_controlled: false,
},
)
}
@@ -107,8 +108,7 @@ mod tests {
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].label, None);
assert_eq!(args.targets[0].addr, "127.0.0.1:2112");
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
assert_eq!(args.pacing, GroupPacingArg::Staggered);
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
@@ -181,8 +181,8 @@ mod tests {
#[test]
fn invalid_labelled_target_syntax_is_rejected() {
assert!(parse(&["=127.0.0.1:2112"]).is_err());
assert!(parse(&["label="]).is_err());
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
assert!(parse(&["label="]).unwrap().prepare().is_err());
}
#[test]
+6 -3
View File
@@ -9,9 +9,12 @@ pub use args::{
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
};
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use prepare::{prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY};
pub use prepare::{
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
};
pub use session::is_shutdown_requested;
pub use targets::{
parse_target, prepare_managed_targets, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
TargetSpec,
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
PreparedTarget, TargetArg, TargetSpec,
};
+36 -11
View File
@@ -22,6 +22,9 @@ use super::{
/// Capacity of the lossy managed presentation event channel.
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
pub const STDIN_MAX_DESIRED_TARGETS: usize = 128;
pub const STDIN_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
/// Target and pacing arguments common to the client applets.
///
@@ -31,6 +34,7 @@ pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
pub struct TargetSelection<'a> {
pub targets: &'a [TargetArg],
pub pacing: GroupPacingArg,
pub stdin_controlled: bool,
}
/// Everything a managed run needs, prepared and validated together.
@@ -46,6 +50,8 @@ pub struct ManagedRunSetup {
pub client: ClientConfig,
/// Send coordination across active targets.
pub pacing: ManagedPacing,
/// Whether this stream may replace its desired set from stdin.
pub stdin_controlled: bool,
}
impl ManagedRunSetup {
@@ -70,7 +76,7 @@ impl ManagedRunSetup {
/// Managed driver configuration for this run.
pub fn managed_config(&self) -> ManagedClientConfig {
let target_count = self.target_count();
ManagedClientConfig {
let mut config = ManagedClientConfig {
client: self.client.clone(),
pacing: self.pacing,
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
@@ -78,7 +84,13 @@ impl ManagedRunSetup {
outcome_history_limit: target_count,
max_live_target_generations: target_count,
..ManagedClientConfig::default()
};
if self.stdin_controlled {
config.completion = ManagedCompletionPolicy::ExplicitStop;
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
}
config
}
}
@@ -91,12 +103,22 @@ pub fn prepare_managed_run(
duration: Duration,
selection: TargetSelection<'_>,
) -> Result<ManagedRunSetup, String> {
let specs = target_specs(selection.targets)?;
let specs = if selection.stdin_controlled {
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
return Err(format!(
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
));
}
super::targets::target_specs_with_empty(selection.targets, true)?
} else {
target_specs(selection.targets)?
};
let targets = prepare_managed_targets(specs)?;
Ok(ManagedRunSetup {
targets,
client: common.to_client_config(duration),
pacing: selection.pacing.into(),
stdin_controlled: selection.stdin_controlled,
})
}
@@ -119,17 +141,11 @@ mod tests {
}
fn unlabeled(addr: &str) -> TargetArg {
TargetArg {
label: None,
addr: addr.to_owned(),
}
TargetArg::new(addr)
}
fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
TargetArg::new(format!("{label}={addr}"))
}
fn prepare(
@@ -137,7 +153,15 @@ mod tests {
pacing: GroupPacingArg,
duration: Duration,
) -> Result<ManagedRunSetup, String> {
prepare_managed_run(&common(&[]), duration, TargetSelection { targets, pacing })
prepare_managed_run(
&common(&[]),
duration,
TargetSelection {
targets,
pacing,
stdin_controlled: false,
},
)
}
#[test]
@@ -212,6 +236,7 @@ mod tests {
TargetSelection {
targets: &targets,
pacing: GroupPacingArg::Staggered,
stdin_controlled: false,
},
)
.unwrap();
+395 -130
View File
@@ -1,26 +1,99 @@
use std::collections::HashSet;
use std::{collections::HashSet, fmt};
use clap::ValueEnum;
use irtt_client::managed::{ManagedPacing, ManagedTargetConfig, TargetId};
use irtt_client::{
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
ClientAuthConfig,
};
#[derive(Debug, Clone, PartialEq, Eq)]
/// One raw positional target captured by Clap.
///
/// Parsing happens during run preparation so invalid target syntax never makes
/// Clap include the original argument (which may carry an HMAC key) in an
/// error diagnostic.
#[derive(Clone, PartialEq, Eq)]
pub struct TargetArg {
pub label: Option<String>,
pub addr: String,
input: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
impl TargetArg {
pub fn new(input: impl Into<String>) -> Self {
Self {
input: input.into(),
}
}
}
impl fmt::Debug for TargetArg {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("TargetArg(<redacted>)")
}
}
/// Capture one positional target without exposing it through Clap diagnostics.
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
Ok(TargetArg::new(input))
}
#[derive(Clone, PartialEq, Eq)]
pub enum TargetAuth {
Inherit,
Override(Vec<u8>),
Disable,
}
impl fmt::Debug for TargetAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Inherit => f.write_str("Inherit"),
Self::Override(_) => f.write_str("Override(<redacted>)"),
Self::Disable => f.write_str("Disable"),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct TargetSpec {
pub label: String,
pub addr: String,
pub auth: TargetAuth,
}
#[derive(Debug, Clone)]
impl fmt::Debug for TargetSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TargetSpec")
.field("label", &self.label)
.field("addr", &self.addr)
.field("auth", &self.auth)
.finish()
}
}
#[derive(Clone)]
pub struct PreparedTarget {
pub label: String,
pub managed: ManagedTargetConfig,
}
impl fmt::Debug for PreparedTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PreparedTarget")
.field("label", &self.label)
.field("server_addr", &self.managed.server_addr)
.field(
"auth",
&match &self.managed.auth {
None => TargetAuth::Inherit,
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
TargetAuth::Override(Vec::new())
}
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
},
)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GroupPacingArg {
Staggered,
@@ -36,191 +109,383 @@ impl From<GroupPacingArg> for ManagedPacing {
}
}
/// Parse one positional target argument, as `TARGET` or `LABEL=TARGET`.
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
match input.split_once('=') {
None => Ok(TargetArg {
label: None,
addr: input.to_owned(),
}),
Some((label, addr)) => {
if label.is_empty() {
return Err("target label must not be empty".to_owned());
}
if addr.is_empty() {
return Err("target address must not be empty".to_owned());
}
Ok(TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetParseError {
EmptyLabel,
EmptyAddress,
InvalidEscape,
EmptyTarget,
}
impl fmt::Display for TargetParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::EmptyLabel => "target label must not be empty",
Self::EmptyAddress => "target address must not be empty",
Self::InvalidEscape => "invalid target escape sequence",
Self::EmptyTarget => "target must not be empty",
};
f.write_str(message)
}
}
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
let mut parts = Vec::new();
let mut start = 0;
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
parts.push(&input[start..index]);
start = index + ch.len_utf8();
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
parts.push(&input[start..]);
Ok(parts)
}
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn unescape(input: &str) -> Result<String, TargetParseError> {
let mut output = String::with_capacity(input.len());
let mut escaped = false;
for ch in input.chars() {
if escaped {
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
return Err(TargetParseError::InvalidEscape);
}
output.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else {
output.push(ch);
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(output)
}
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
let mut escaped = false;
for (index, ch) in input.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if input[index..].starts_with("@hmac=") {
return Ok(Some(index));
}
}
if escaped {
return Err(TargetParseError::InvalidEscape);
}
Ok(None)
}
fn parse_target_syntax(
input: &str,
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
if input.is_empty() {
return Err(TargetParseError::EmptyTarget);
}
let (main, auth) = match hmac_modifier(input)? {
Some(index) => {
let value = unescape(&input[index + "@hmac=".len()..])?;
let auth = if value.is_empty() {
TargetAuth::Disable
} else {
TargetAuth::Override(value.into_bytes())
};
(&input[..index], auth)
}
None => (input, TargetAuth::Inherit),
};
let (label, address) = match first_unescaped(main, '=')? {
None => (None, unescape(main)?),
Some(index) => {
let label = unescape(&main[..index])?;
let address = unescape(&main[index + '='.len_utf8()..])?;
(Some(label), address)
}
};
if label.as_deref().is_some_and(str::is_empty) {
return Err(TargetParseError::EmptyLabel);
}
if address.is_empty() {
return Err(TargetParseError::EmptyAddress);
}
Ok((label, address, auth))
}
pub(crate) fn target_specs_with_empty(
targets: &[TargetArg],
allow_empty: bool,
) -> Result<Vec<TargetSpec>, String> {
let mut specs = Vec::with_capacity(targets.len());
let mut unlabeled_counts = std::collections::HashMap::<&str, usize>::new();
for target in targets {
let label = match &target.label {
Some(label) => label.clone(),
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
for (index, target) in targets.iter().enumerate() {
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
let label = match explicit_label {
Some(label) => label,
None => {
let count = unlabeled_counts.entry(target.addr.as_str()).or_default();
let count = unlabeled_counts.entry(addr.clone()).or_default();
*count += 1;
if *count == 1 {
target.addr.clone()
addr.clone()
} else {
format!("{}#{}", target.addr, *count)
format!("{}#{}", addr, *count)
}
}
};
specs.push(TargetSpec {
label,
addr: target.addr.clone(),
});
specs.push(TargetSpec { label, addr, auth });
}
if specs.is_empty() {
if specs.is_empty() && !allow_empty {
return Err("at least one target is required unless --list-columns is set".to_owned());
}
let mut labels = HashSet::new();
for spec in &specs {
if !labels.insert(spec.label.clone()) {
return Err(format!("duplicate target label {:?}", spec.label));
return Err("duplicate target label".to_owned());
}
}
Ok(specs)
}
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
target_specs_with_empty(targets, false)
}
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
let mut targets = Vec::with_capacity(specs.len());
for spec in specs {
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
managed.auth = match spec.auth {
TargetAuth::Inherit => None,
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
hmac_key: Some(hmac_key),
}),
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
};
targets.push(PreparedTarget {
label: spec.label.clone(),
managed: ManagedTargetConfig::new(TargetId::from(spec.label), spec.addr),
label: spec.label,
managed,
});
}
Ok(targets)
}
/// Parse one complete stdin target set after its line terminator was removed.
///
/// Commas frame stdin elements only; positional target arguments do not use
/// this framing and may therefore contain raw commas.
pub fn parse_stdin_target_set(
record: &str,
maximum_targets: usize,
) -> Result<Vec<PreparedTarget>, String> {
if record == "[]" {
return Ok(Vec::new());
}
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
if elements.len() > maximum_targets {
return Err(format!(
"target set exceeds the {maximum_targets}-target limit"
));
}
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
prepare_managed_targets(target_specs_with_empty(&args, true)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn unlabeled(addr: &str) -> TargetArg {
TargetArg {
label: None,
addr: addr.to_owned(),
}
fn target(input: &str) -> TargetArg {
TargetArg::new(input)
}
fn labeled(label: &str, addr: &str) -> TargetArg {
TargetArg {
label: Some(label.to_owned()),
addr: addr.to_owned(),
}
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
}
#[test]
fn parse_target_without_equals_is_unlabeled() {
let target = parse_target("host.example").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "host.example");
let target = parse_target("host.example:2112").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "host.example:2112");
let target = parse_target("[::1]:2112").unwrap();
assert_eq!(target.label, None);
assert_eq!(target.addr, "[::1]:2112");
fn legacy_target_syntax_and_ipv6_remain_supported() {
let parsed = specs(&[
"host.example",
"host.example:2112",
"[::1]:2112",
"eu=host.example",
])
.unwrap();
assert_eq!(parsed[0].label, "host.example");
assert_eq!(parsed[1].label, "host.example:2112");
assert_eq!(parsed[2].label, "[::1]:2112");
assert_eq!(parsed[3].label, "eu");
assert!(parsed
.iter()
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
}
#[test]
fn parse_target_with_equals_splits_on_first() {
let target = parse_target("eu=host.example").unwrap();
assert_eq!(target.label, Some("eu".to_owned()));
assert_eq!(target.addr, "host.example");
let target = parse_target("eu=host.example:2112").unwrap();
assert_eq!(target.label, Some("eu".to_owned()));
assert_eq!(target.addr, "host.example:2112");
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
let parsed = specs(&[
"default=default.example",
"private=private.example@hmac=YWJjZA==",
"public=public.example@hmac=",
])
.unwrap();
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
}
#[test]
fn parse_target_rejects_empty_label_or_address() {
assert!(parse_target("=host.example").is_err());
assert!(parse_target("eu=").is_err());
fn target_delimiters_are_escaped_contextually() {
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
assert_eq!(parsed[0].label, "eu=west");
assert_eq!(parsed[0].addr, "host@hmac=literal");
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
}
#[test]
fn positional_commas_are_not_reserved() {
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
assert_eq!(parsed[0].addr, "host,region");
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
}
#[test]
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
let secret = "very-secret-value";
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
assert!(!error.contains(secret));
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
assert!(
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
);
}
#[test]
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-a:2112")];
let specs = target_specs(&targets).unwrap();
assert_eq!(specs[0].label, "host-a:2112");
assert_eq!(specs[1].label, "host-a:2112#2");
let targets = vec![
unlabeled("host-a:2112"),
labeled("host-a:2112", "host-b:2112"),
];
let err = target_specs(&targets).unwrap_err();
assert!(err.contains("duplicate target label"));
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
assert_eq!(parsed[0].label, "host-a:2112");
assert_eq!(parsed[1].label, "host-a:2112#2");
assert_eq!(
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
"duplicate target label"
);
}
#[test]
fn target_specs_preserve_argument_order_with_mixed_labels() {
let targets = vec![
unlabeled("local"),
labeled("eu", "eu.example"),
unlabeled("backup"),
labeled("us", "us.example"),
];
let specs = target_specs(&targets).unwrap();
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
assert_eq!(labels, ["local", "eu", "backup", "us"]);
let addrs: Vec<_> = specs.iter().map(|spec| spec.addr.as_str()).collect();
assert_eq!(addrs, ["local", "eu.example", "backup", "us.example"]);
}
#[test]
fn target_specs_count_unlabeled_occurrences_independently_of_explicit_labels() {
let targets = vec![unlabeled("foo"), labeled("eu", "foo"), unlabeled("foo")];
let specs = target_specs(&targets).unwrap();
let labels: Vec<_> = specs.iter().map(|spec| spec.label.as_str()).collect();
assert_eq!(labels, ["foo", "eu", "foo#2"]);
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
assert_eq!(
addresses,
["local", "eu.example", "foo", "us.example", "foo"]
);
}
#[test]
fn target_specs_reject_generated_suffix_collisions() {
let targets = vec![
unlabeled("host.example"),
unlabeled("host.example"),
labeled("host.example#2", "other.example"),
];
let err = target_specs(&targets).unwrap_err();
assert!(err.contains("duplicate target label"));
assert_eq!(
specs(&[
"host.example",
"host.example",
"host.example#2=other.example"
])
.unwrap_err(),
"duplicate target label"
);
}
#[test]
fn target_preparation_preserves_hostname_and_allows_duplicate_endpoints() {
let specs = vec![
TargetSpec {
label: "a".to_owned(),
addr: "example.test".to_owned(),
},
TargetSpec {
label: "b".to_owned(),
addr: "example.test".to_owned(),
},
];
let targets = prepare_managed_targets(specs).unwrap();
assert_eq!(targets[0].managed.server_addr, "example.test");
assert_eq!(targets[1].managed.server_addr, "example.test");
fn malformed_target_syntax_is_rejected_without_echoing_input() {
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
assert!(specs(&[input]).is_err(), "{input}");
}
}
#[test]
fn target_preparation_maps_auth_to_managed_config() {
let prepared =
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
assert_eq!(
prepared[0]
.managed
.auth
.as_ref()
.unwrap()
.hmac_key
.as_deref(),
Some(b"key".as_slice())
);
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
}
#[test]
fn target_preparation_preserves_duplicate_endpoints() {
let prepared =
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
assert_eq!(prepared[0].managed.server_addr, "example.test");
assert_eq!(prepared[1].managed.server_addr, "example.test");
}
#[test]
fn stdin_target_sets_frame_commas_without_trimming_payload() {
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
assert_eq!(
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
Some(b"x,y".as_slice())
);
assert_eq!(parsed[1].label, "b");
assert_eq!(
parse_stdin_target_set(" []", 128).unwrap()[0]
.managed
.server_addr,
" []"
);
assert_eq!(
parse_stdin_target_set("[] ", 128).unwrap()[0]
.managed
.server_addr,
"[] "
);
assert!(parse_stdin_target_set("", 128).is_err());
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
}
#[test]
fn stdin_empty_set_is_exactly_bracket_pair() {
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
}
}
-300
View File
@@ -1,300 +0,0 @@
use std::process::{Command, Output};
fn irtt_rs() -> Command {
Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
}
#[cfg(unix)]
fn irtt_rs_with_arg0(arg0: &str) -> Command {
use std::os::unix::process::CommandExt;
let mut command = irtt_rs();
command.arg0(arg0);
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));
text.push_str(&String::from_utf8_lossy(&output.stderr));
text
}
#[test]
fn canonical_help_shows_dispatcher_help() {
let output = irtt_rs().arg("--help").output().unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("IRTT-compatible multi-applet dispatcher"));
assert!(text.contains("client"));
assert!(text.contains("tui"));
assert!(text.contains("server"));
}
#[test]
fn canonical_without_applet_errors() {
let output = irtt_rs().output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("choose an applet"), "{text}");
assert!(text.contains("client"));
assert!(text.contains("tui"));
assert!(text.contains("server"));
}
#[cfg(feature = "client")]
#[test]
fn canonical_client_subcommand_dispatches_to_client() {
let output = irtt_rs().args(["client", "--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>"));
assert!(text.contains("--columns <COLUMNS>"));
}
#[cfg(not(feature = "client"))]
#[test]
fn canonical_client_subcommand_reports_unavailable_when_disabled() {
let output = irtt_rs().args(["client", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("client applet is not available; rebuild with the client feature"),
"{text}"
);
}
#[cfg(feature = "server")]
#[test]
fn canonical_server_subcommand_dispatches_to_server() {
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
assert!(text.contains("--bind <ADDR>"));
assert!(text.contains("--max-sessions <COUNT>"));
assert!(text.contains("--idle-timeout <DURATION>"));
assert!(text.contains("--timestamp-allowance <MODE>"));
assert!(text.contains("--no-dscp"));
}
#[cfg(not(feature = "server"))]
#[test]
fn canonical_server_subcommand_reports_unavailable_when_disabled() {
let output = irtt_rs().args(["server", "--help"]).output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("server applet is not available; rebuild with the server feature"),
"{text}"
);
}
#[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 dedicated_server_binary_always_enters_the_server_role() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_irtt-server"))
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
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-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>"));
assert!(text.contains("--columns <COLUMNS>"));
}
#[cfg(all(unix, not(feature = "client")))]
#[test]
fn client_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-client")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(
text.contains("client applet is not available; rebuild with the client feature"),
"{text}"
);
}
#[cfg(all(unix, feature = "tui"))]
#[test]
fn tui_applet_name_dispatches_to_tui_when_enabled() {
let output = irtt_rs_with_arg0("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(all(unix, not(feature = "tui")))]
#[test]
fn tui_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-tui")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("TUI applet is not available"), "{text}");
}
#[cfg(all(unix, feature = "server"))]
#[test]
fn server_applet_name_dispatches_to_server_when_enabled() {
let output = irtt_rs_with_arg0("irtt-server")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(output.status.success(), "{text}");
assert!(text.contains("Minimal IRTT-compatible UDP server"));
assert!(text.contains("--bind <ADDR>"));
}
#[cfg(all(unix, not(feature = "server")))]
#[test]
fn server_applet_name_reports_unavailable_when_disabled() {
let output = irtt_rs_with_arg0("irtt-server")
.arg("--help")
.output()
.unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("server applet is not available"), "{text}");
}
#[cfg(unix)]
#[test]
fn unknown_irtt_applet_name_errors() {
let output = irtt_rs_with_arg0("irtt-typo").output().unwrap();
let text = output_text(&output);
assert!(!output.status.success());
assert!(text.contains("unknown applet name 'irtt-typo'"), "{text}");
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}");
}
File diff suppressed because it is too large. Load diff
@@ -1,74 +0,0 @@
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::mpsc,
thread::{self, JoinHandle},
time::Duration,
};
use irtt_server::{Server, ServerConfig};
use tokio::sync::oneshot;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
pub struct InTreeServer {
pub addr: SocketAddr,
shutdown: Option<oneshot::Sender<()>>,
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
}
impl InTreeServer {
pub fn start() -> Self {
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let done = thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build in-tree test server runtime");
runtime.block_on(async move {
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let mut server = Server::bind(bind_addr, ServerConfig::default()).await?;
startup_tx
.send(server.local_addr()?)
.expect("test stopped waiting for in-tree server startup");
server
.run(async {
let _ = shutdown_rx.await;
})
.await
})
});
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
Ok(addr) => addr,
Err(error) => {
let _ = shutdown_tx.send(());
let result = done.join().expect("in-tree test server thread panicked");
panic!("in-tree test server failed before startup ({error}): {result:?}");
}
};
Self {
addr,
shutdown: Some(shutdown_tx),
done: Some(done),
}
}
fn stop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(done) = self.done.take() {
done.join()
.expect("in-tree test server thread panicked")
.expect("in-tree test server stopped with an error");
}
}
}
impl Drop for InTreeServer {
fn drop(&mut self) {
self.stop();
}
}
+22 -22
View File
@@ -26,30 +26,30 @@ configuration (`try_enable_tx_timestamping` restores it explicitly) and
timing falls back to userspace `sent_at` as it always has. TX capability
failure is never a public error and is never logged from the library.
### Correlation invariant: kernel ID == wire sequence
### Best-effort kernel-ID correlation
The client uses the kernel's automatic `OPT_ID` directly as the probe's wire
sequence number — no separate correlation map. This is safe because:
The client uses the kernel's automatic `OPT_ID` as the probe's wire-sequence
correlation ID — no separate correlation map. On the normal successful-send
path, both counters start at zero after Open and advance together. This is
not a permanent identity: a kernel ID can theoretically be consumed while a
send is being prepared and then survive a subsequent send failure, leaving
kernel IDs one or more steps ahead of `next_wire_seq`. Do not restore an
assumption that failed or nonblocking sends cannot cause that desynchrony.
- **A failed/nonblocking send never consumes an ID.** Linux increments the
per-socket ID counter (`sk->sk_tskey`) speculatively while building a
datagram and rolls the increment back (`atomic_dec`) on every error path
out of `__ip_append_data`/`__ip6_append_data`
(`net/ipv4/ip_output.c`, `net/ipv6/ip6_output.c`), guarded by a local
`hold_tskey` flag. A `WouldBlock` `try_send` therefore can never leave a
gap in, or otherwise advance, the ID the kernel will assign to the next
*successful* submission. (`include/net/sock.h`'s `_sock_tx_timestamp`
performs the same increment for the non-corked path.) Client-side, the
existing send transaction only calls `commit_probe_sent` — which is the
only thing that advances `next_wire_seq` — after a successful socket send;
a `WouldBlock` result never reaches it.
- **The two counters start together and advance together.** `wire_seq`
starts at 0 on every successful Open (`SessionMachine::commit_open` builds
a fresh `ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1
only via `commit_probe_sent`, which only runs after a confirmed successful
send — the same condition under which the kernel's counter advances by 1.
TX timestamping is enabled only after that same successful Open and before
any probe is sent, so both counters' zero points line up exactly.
The effect is confined to the optional timing enhancement. A kernel timestamp
whose ID cannot be matched to a pending or retained timed-out probe is
discarded, as is any implausible timestamp; `sent_at` remains the ordinary
userspace fallback. **After every failed probe submission, disable kernel-ID
correlation for the rest of that session.** A gap means an otherwise plausible
later ID can name the wrong probe, so subsequent error-queue timestamps must
be discarded rather than attached by ID. This is safe because:
- **Normal successful sends track together.** `wire_seq` starts at 0 on every
successful Open (`SessionMachine::commit_open` builds a fresh
`ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via
`commit_probe_sent`, after a confirmed successful socket send. TX
timestamping is enabled after that same successful Open and before any probe
is sent, so the normal zero points line up.
- **One socket, one session, no reopen.** `SessionMachine`'s state machine
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
+20 -3
View File
@@ -1,13 +1,13 @@
[package]
name = "irtt-client"
version = "0.5.0"
version = "0.5.2"
description = "IRTT-compatible Rust client library"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["network-programming"]
@@ -27,8 +27,25 @@ tokio = ["dep:tokio"]
[package.metadata.docs.rs]
all-features = true
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
[[example]]
name = "blocking_client"
doc-scrape-examples = true
[[example]]
name = "tokio_client"
required-features = ["tokio"]
doc-scrape-examples = true
[[example]]
name = "managed_client"
required-features = ["tokio"]
doc-scrape-examples = true
[dev-dependencies]
irtt-server.workspace = true
# Keep the packaged client testable against the latest published server while
# workspace tests continue to exercise the local source through this path.
irtt-server = { version = "0.5.1", path = "../irtt-server" }
proptest = "1.11.0"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
+37
View File
@@ -0,0 +1,37 @@
# irtt-client
Reusable client/session library for IRTT-compatible round-trip-time probing:
socket lifecycle, open negotiation, probe send/receive, loss/duplicate/late
classification, and a typed event stream.
## Looking for the CLI?
If you just want to run probes from the command line, use the
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
which installs the `irtt-client` binary. This crate is the library the
binaries are built on.
## API tiers
- `Client` — runtime-free blocking adapter. No Tokio dependency; this is the
default build.
- `AsyncClient` (feature `tokio`) — low-level Tokio adapter for callers that
own a runtime and drive readiness directly.
- `managed` (feature `tokio`) — a unified managed driver: `ManagedClientTask`
/ `ManagedClientHandle` for multi-target orchestration under a Tokio
runtime, and `BlockingManagedClient` for synchronous callers, which owns its
own dedicated current-thread runtime.
Tokio stays optional: the default build has no runtime and no Tokio
dependency at all.
See `examples/` in the repository for runnable examples of each tier.
## Documentation
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
@@ -0,0 +1,110 @@
//! Runtime-free blocking client example.
//!
//! Opens a session against a local IRTT-compatible server, sends a few
//! probes, and prints the events observed. Uses only [`Client`], which has no
//! Tokio dependency and works in a default (`--no-default-features`) build.
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example blocking_client
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open_timeouts` for a
//! fast demonstration; a real caller would normally keep the default retry
//! schedule.
use std::time::{Duration, Instant};
use irtt_client::{Client, ClientConfig, ClientEvent};
const RUN_DURATION: Duration = Duration::from_secs(2);
fn main() {
let config = ClientConfig {
server_addr: "127.0.0.1:2112".to_owned(),
duration: Some(RUN_DURATION),
interval: Duration::from_millis(200),
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
open_timeouts: vec![Duration::from_millis(300)],
socket_config: irtt_client::SocketConfig {
recv_timeout: Some(Duration::from_millis(100)),
..Default::default()
},
..Default::default()
};
let mut client = match Client::connect(config) {
Ok(client) => client,
Err(err) => {
eprintln!("failed to prepare client socket: {err}");
return;
}
};
match client.open() {
Ok(outcome) => println!("session opened: {outcome:?}"),
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
}
// The run itself lasts `duration`, but a lost probe isn't classified as
// `EchoLoss` until `probe_timeout()` after it was sent; wait at least
// that much past the run's own end so a loss near the end can still be
// observed and printed before this example gives up and closes.
let deadline = Instant::now() + RUN_DURATION + client.probe_timeout();
while Instant::now() < deadline && !client.is_run_complete() {
// send_probe is caller-paced: only call it once its own schedule
// says a probe is due, rather than as fast as the loop spins.
if client
.next_send_deadline()
.is_some_and(|send_at| Instant::now() >= send_at)
{
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry silently until the
// safety deadline instead of reporting it.
match client.send_probe() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("send failed: {err}");
break;
}
}
}
match client.recv_once() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("receive failed: {err}");
break;
}
}
match client.poll_timeouts() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("timeout polling failed: {err}");
break;
}
}
}
match client.close() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
}
}
fn print_event(event: &ClientEvent) {
match event {
ClientEvent::EchoReply { seq, rtt, .. } => {
println!("seq {seq}: rtt {:?}", rtt.effective);
}
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
other => println!("{other:?}"),
}
}
@@ -0,0 +1,73 @@
//! Managed multi-target client example (`tokio` feature).
//!
//! [`BlockingManagedClient`] runs one or more targets under a dedicated
//! current-thread Tokio runtime and exposes a synchronous handle, so a caller
//! that does not want to touch Tokio directly can still get managed
//! multi-target orchestration, retries, and a lossy event stream.
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example managed_client --features tokio
//! ```
//!
//! This example does not wait for the managed run to finish naturally: it
//! requests a stop after a fixed short window so it exits promptly even
//! without a reachable server.
use std::time::Duration;
use irtt_client::managed::{
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
ManagedTargetConfig,
};
use irtt_client::ClientConfig;
fn main() {
let config = ManagedClientConfig {
client: ClientConfig {
duration: None,
interval: Duration::from_millis(200),
open_timeouts: vec![Duration::from_millis(300)],
..Default::default()
},
completion: ManagedCompletionPolicy::ExplicitStop,
..Default::default()
};
let targets = vec![ManagedTargetConfig::new("local", "127.0.0.1:2112")];
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(config, targets)
{
Ok(started) => started,
Err(err) => {
eprintln!("failed to start managed client: {err}");
return;
}
};
let stop_after = Duration::from_secs(1);
let deadline = std::time::Instant::now() + stop_after;
while std::time::Instant::now() < deadline {
match events.try_recv() {
Ok(event) => print_event(&event),
Err(_) => std::thread::sleep(Duration::from_millis(50)),
}
}
// `stop()` requests the stop synchronously; the returned receipt is only
// useful for awaiting durable acknowledgement, which this blocking
// example does not need.
std::mem::drop(owner.handle().stop());
match owner.join() {
Ok(outcome) => println!("managed run ended: {:?}", outcome.end_reason),
Err(err) => eprintln!("managed worker did not join cleanly: {err}"),
}
}
fn print_event(event: &ManagedEvent) {
match event {
ManagedEvent::Client { target, event } => println!("{}: {event:?}", target.id),
other => println!("{other:?}"),
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Low-level Tokio client example (`tokio` feature).
//!
//! Drives [`AsyncClient`] directly on a caller-owned runtime. This is the
//! adapter to reach for when you already have a Tokio runtime and want to
//! await sends/receives yourself, rather than use the higher-level `managed`
//! driver (see the `managed_client` example).
//!
//! Run a server first, then run this example:
//!
//! ```text
//! cargo run -p irtt-rs --bin irtt-server --features server
//! cargo run -p irtt-client --example tokio_client --features tokio
//! ```
//!
//! Without a reachable server this exits quickly with an open-timeout error
//! instead of hanging, because the example shortens `open_timeouts` for a
//! fast demonstration.
use std::time::{Duration, Instant};
use irtt_client::{AsyncClient, ClientConfig, ClientEvent};
/// Upper bound on how long the receive wait can go without also calling
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
/// probe's own timeout deadline, so this example polls on a short fixed
/// cadence instead of waiting for the full `probe_timeout()` from whenever
/// this loop last ran — that would let poll_timeouts() classify a lost
/// probe (and its dependent statistics/session state) far later than it
/// actually became lost.
const TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
fn main() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.expect("failed to build Tokio runtime");
runtime.block_on(run());
}
async fn run() {
let config = ClientConfig {
server_addr: "127.0.0.1:2112".to_owned(),
duration: Some(Duration::from_secs(2)),
interval: Duration::from_millis(200),
// A single short attempt keeps this example fast when no server is
// reachable; production callers should generally keep the default.
open_timeouts: vec![Duration::from_millis(300)],
..Default::default()
};
let mut client = match AsyncClient::connect(config).await {
Ok(client) => client,
Err(err) => {
eprintln!("failed to prepare client socket: {err}");
return;
}
};
match client.open().await {
Ok(outcome) => println!("session opened: {outcome:?}"),
Err(err) => {
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
return;
}
}
while !client.is_run_complete() {
// Bound the receive wait by the next send/timeout deadline, rather
// than awaiting recv() unconditionally: a lost or rate-limited reply
// must not stall pacing or timeout classification indefinitely.
let wake_at = client
.next_send_deadline()
.into_iter()
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
.min()
.expect("the fixed poll-interval deadline is always present");
let recv_result = tokio::select! {
events = client.recv() => Some(events),
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => None,
};
if let Some(result) = recv_result {
match result {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("receive failed: {err}");
break;
}
}
}
if client
.next_send_deadline()
.is_some_and(|send_at| Instant::now() >= send_at)
{
// A failed send retains its prepared probe without advancing the
// schedule, so a persistent error (e.g. the interface going
// down) would otherwise make this loop retry forever instead of
// reporting it.
match client.send_probe().await {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("send failed: {err}");
break;
}
}
}
match client.poll_timeouts() {
Ok(events) => events.iter().for_each(print_event),
Err(err) => {
eprintln!("timeout polling failed: {err}");
break;
}
}
}
match client.close().await {
Ok(events) => events.iter().for_each(print_event),
Err(err) => eprintln!("close failed: {err}"),
}
}
fn print_event(event: &ClientEvent) {
match event {
ClientEvent::EchoReply { seq, rtt, .. } => {
println!("seq {seq}: rtt {:?}", rtt.effective);
}
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
other => println!("{other:?}"),
}
}
+33 -2
View File
@@ -242,6 +242,31 @@ impl AsyncClientTestHooks {
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
/// methods are polled by the caller and require a current Tokio runtime with I/O
/// and time enabled.
///
/// # Example
///
/// Use this low-level adapter when the application already owns Tokio and wants
/// to drive the session lifecycle itself. This example type-checks without
/// contacting a server.
///
/// ```no_run
/// use irtt_client::{AsyncClient, ClientConfig};
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = AsyncClient::connect(ClientConfig::default()).await?;
/// let outcome = client.open().await?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe().await?;
/// println!("sent: {sent:?}");
/// let received = client.recv().await?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close().await?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct AsyncClient {
socket: tokio::net::UdpSocket,
@@ -608,8 +633,14 @@ impl AsyncClient {
// No sent_at measurement, no machine commit, no schedule
// commit: the transaction is fully discarded and retried
// with a fresh send_anchor on the next loop iteration.
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
self.machine.invalidate_kernel_tx_correlation();
continue;
}
Err(error) => {
self.machine.invalidate_kernel_tx_correlation();
return Poll::Ready(Err(ClientError::Socket(error)));
}
};
#[cfg(not(test))]
+39 -6
View File
@@ -103,6 +103,31 @@ use crate::{
/// session, send probes, receive replies, poll timeouts, and close. Callers
/// that do not need to own this loop can use the unified managed API in
/// [`crate::managed`] when the `tokio` feature is enabled.
///
/// # Example
///
/// `Client` is the runtime-free adapter. The caller drives receives and timeout
/// polling between sends; see the standalone example for a complete loop.
/// This example type-checks without contacting a server:
///
/// ```no_run
/// use irtt_client::{Client, ClientConfig};
///
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = Client::connect(ClientConfig::default())?;
/// let outcome = client.open()?;
/// println!("opened: {outcome:?}");
/// let sent = client.send_probe()?;
/// println!("sent: {sent:?}");
/// let received = client.recv_once()?;
/// println!("received: {received:?}");
/// let timed_out = client.poll_timeouts()?;
/// println!("timeouts: {timed_out:?}");
/// let closed = client.close()?;
/// println!("closed: {closed:?}");
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct Client {
runtime: SessionMachine,
@@ -163,12 +188,13 @@ impl Client {
///
/// On success, returns the negotiated open outcome and transitions the
/// client into either an open probe session or completed no-test state.
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed, unrelated,
/// or unauthenticated datagrams are ignored until the current attempt's
/// absolute deadline, so one attempt may consume several datagrams without
/// Open attempts use [`ClientConfig::open_timeouts`]. Malformed or
/// unrelated datagrams are ignored until the current attempt's absolute
/// deadline, so one attempt may consume several datagrams without
/// retransmitting. Silence or ignored traffic eventually produces
/// [`ClientError::OpenTimeout`], while authenticated incompatibility remains
/// terminal.
/// [`ClientError::OpenTimeout`]. A structurally recognizable malformed or
/// incompatible Open reply from the connected peer is terminal; when HMAC
/// is configured, that recognition additionally requires authentication.
///
/// When a trusted reply allocates a token but later negotiation or socket
/// preparation fails, the client sends a best-effort cleanup close and
@@ -523,11 +549,18 @@ impl Client {
.unwrap_or_else(Instant::now);
#[cfg(test)]
if fail_send {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(io::Error::other(
"injected probe send failure",
)));
}
let bytes = socket.send(&prepared.bytes)?;
let bytes = match socket.send(&prepared.bytes) {
Ok(bytes) => bytes,
Err(error) => {
runtime.invalidate_kernel_tx_correlation();
return Err(ClientError::Socket(error));
}
};
#[cfg(not(test))]
let send_finished_at = Instant::now();
#[cfg(test)]
+48
View File
@@ -46,6 +46,31 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// request and the local client behavior used to drive the UDP socket. Values
/// that are negotiated by the server are available after opening the session
/// through [`NegotiatedParams`](crate::NegotiatedParams).
///
/// # Example
///
/// Configure a finite run with larger probes and accept documented server
/// restrictions. Use `duration: None` for continuous mode, or
/// [`RunMode::NoTest`] for negotiation without probes.
///
/// ```
/// use std::time::Duration;
///
/// use irtt_client::{ClientConfig, NegotiationPolicy};
///
/// let config = ClientConfig {
/// server_addr: "example.net:2112".into(),
/// duration: Some(Duration::from_secs(10)),
/// interval: Duration::from_millis(250),
/// length: 1200,
/// negotiation_policy: NegotiationPolicy::Loose,
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.duration, Some(Duration::from_secs(10)));
/// assert_eq!(config.interval, Duration::from_millis(250));
/// assert_eq!(config.length, 1200);
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
/// Remote server name or address.
@@ -176,6 +201,29 @@ impl Default for ClientConfig {
/// These settings affect how the client binds, resolves, and receives from the
/// socket. They do not change the IRTT protocol parameters negotiated with the
/// server.
///
/// # Example
///
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_client::{ClientConfig, SocketConfig};
///
/// let config = ClientConfig {
/// socket_config: SocketConfig {
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
/// ttl: Some(32),
/// ipv4_only: true,
/// ..SocketConfig::default()
/// },
/// ..ClientConfig::default()
/// };
///
/// assert_eq!(config.socket_config.ttl, Some(32));
/// assert!(config.socket_config.ipv4_only);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket.
+29 -1
View File
@@ -260,6 +260,19 @@ pub struct RttSample {
/// processing time or comparing wall-clock timestamps from different hosts. It
/// should not be used to represent raw elapsed wall-clock or monotonic time,
/// which is non-negative and represented by [`Duration`].
///
/// # Example
///
/// Signed timing values retain a negative result instead of clamping clock
/// skew or server processing adjustments to zero:
///
/// ```
/// use irtt_client::SignedDuration;
///
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
/// assert!(adjusted_rtt.is_negative());
/// assert_eq!(adjusted_rtt.as_micros(), -250);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration {
ns: i128,
@@ -351,7 +364,22 @@ pub struct OneWayDelaySample {
/// Client-to-server delay.
///
/// This is computed from the client send wall time and the server receive
/// wall time when both are available.
/// wall time when both are available. The client send endpoint is the
/// correlated Linux kernel TX timestamp for this send when one is
/// available and plausible for this send, and the userspace post-send wall
/// time otherwise. The server receive endpoint is whatever wall time the
/// peer reported; against this crate's own Tokio server runtime on Linux
/// (the `irtt-server` applet), that is its kernel receive timestamp when
/// one is available and plausible, and its userspace receive wall time
/// otherwise. Against another IRTT-compatible peer, or an application
/// driving `irtt_server::ServerCore::handle_datagram` directly, the
/// client has no way to know which clock source produced it — the public
/// `ServerCore` API never supplies a kernel timestamp of its own accord.
/// `RttSample::raw` is unaffected and remains a userspace
/// monotonic measurement, but `adjusted` and `effective` can still shift:
/// when the session negotiated wall-clock timing rather than monotonic,
/// server processing time is derived from this same server receive
/// endpoint.
pub client_to_server: Option<SignedDuration>,
/// Server-to-client delay.
///
+13 -4
View File
@@ -13,10 +13,19 @@
//! also signed when the required wall-clock timestamps are available; negative
//! values usually indicate client/server clock skew.
//!
//! [`Client`] is the runtime-free low-level blocking adapter. With the
//! `tokio` feature, `AsyncClient` provides the corresponding Tokio adapter;
//! [`managed`] contains the unified managed task/handle implementation,
//! including the dedicated-runtime blocking frontend for synchronous callers.
//! [`Client`] is the runtime-free low-level blocking adapter and requires no
//! Tokio dependency at all; it is what a caller without a runtime, or that
//! wants full control over its own event loop, should start from. With the
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
//! adapter for a caller that owns a runtime and drives socket readiness
//! itself. [`managed`] sits above both: `ManagedClientTask` /
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
//! feature) wraps that in a synchronous owner backed by its own dedicated
//! current-thread runtime for callers that want managed multi-target
//! behavior without touching Tokio themselves.
//!
//! See `examples/` in the repository for a runnable example of each tier.
//!
#![forbid(unsafe_code)]
@@ -250,15 +250,11 @@ fn dynamic_update_reaches_background_worker() {
.unwrap();
let handle = owner.handle();
let acknowledgement = test_runtime().block_on(async {
timeout(
Duration::from_secs(2),
handle.update_targets(vec![target(&server)]).unwrap(),
)
.await
let acknowledgement = handle
.update_targets(vec![target(&server)])
.unwrap()
.unwrap()
});
.blocking_wait()
.unwrap();
assert_eq!(acknowledgement.sequence, 1);
server.wait_probe();
test_runtime().block_on(async {
+89 -2
View File
@@ -44,6 +44,42 @@ const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
/// Entry point for constructing a unified Tokio managed task.
///
/// # Example
///
/// Construct the task and its separate control handle, subscribe before
/// starting the task, then run it under the caller's Tokio runtime. A reachable
/// server is required at runtime, so this example only type-checks.
///
/// ```no_run
/// use irtt_client::{ClientConfig, ClientEvent};
/// use irtt_client::managed::{
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
/// };
///
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
/// let config = ManagedClientConfig {
/// client: ClientConfig {
/// duration: Some(std::time::Duration::from_secs(10)),
/// ..ClientConfig::default()
/// },
/// ..ManagedClientConfig::default()
/// };
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
/// let (task, handle) = ManagedClient::task(config, targets)?;
/// let mut events = handle.subscribe()?;
/// let driver = tokio::spawn(task);
///
/// while let Ok(event) = events.recv().await {
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
/// println!("received a probe reply");
/// }
/// }
/// let outcome = driver.await?;
/// println!("run ended: {:?}", outcome.end_reason);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Default)]
pub struct ManagedClient;
@@ -236,7 +272,21 @@ impl ManagedClientHandle {
ManagedStopReceipt::new(Arc::clone(&self.stop))
}
/// Submit one complete desired target set without awaiting queue capacity.
/// Submit the complete desired target set, replacing any previous one.
///
/// `targets` is not a delta: any live target whose id is missing from
/// `targets` is stopped and removed. A target that is still present with an
/// unchanged configuration continues undisturbed — it is not restarted and
/// does not get a new generation. A target present with a *changed*
/// configuration, or whose current generation already reached a terminal
/// state (e.g. failed, completed, or was closed by the peer), is treated as
/// a fresh start: it is retired and a new generation is created for it, even
/// if the configuration is byte-for-byte identical to the one that just
/// finished.
///
/// This call enqueues the update without waiting for queue capacity; await
/// the returned [`ManagedCommandReceipt`] to observe whether — and how — it
/// was applied.
pub fn update_targets(
&self,
targets: Vec<ManagedTargetConfig>,
@@ -287,6 +337,19 @@ pub struct ManagedCommandReceipt {
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
}
impl ManagedCommandReceipt {
/// Block until the target-set transaction is applied or rejected.
///
/// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
/// must not be called from an asynchronous runtime; use the [`Future`]
/// implementation there instead.
pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
self.receiver
.blocking_recv()
.unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
}
}
impl Future for ManagedCommandReceipt {
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
@@ -1586,6 +1649,30 @@ impl ManagedClientTask {
self.send_gate = self.send_gate.filter(|gate| *gate > now);
}
/// The next stagger gate after a send was accepted at `accepted_at`.
///
/// The gate advances on its **own** cadence rather than being re-anchored
/// on every observed accept time. Anchoring on `accepted_at` folds each
/// send's wakeup latency into the next slot and then into every slot after
/// it, so the gate — and with it the whole group's send cadence — drifts
/// later without bound while the per-target schedules stay on their
/// absolute grid. The drift is not hypothetical: it shows up as a
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
/// interval, as probe slots the schedule then skips.
///
/// `accepted_at` is still the anchor in the two cases where continuing the
/// old cadence would be wrong: there is no previous gate, or the pacer has
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
/// stranded in the past from releasing a burst of catch-up sends once the
/// group starts moving again.
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
let base = match self.send_gate {
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
_ => accepted_at,
};
base.checked_add(spacing)
}
fn record_stagger_acceptance(
&mut self,
result: SendResult,
@@ -1599,7 +1686,7 @@ impl ManagedClientTask {
return;
};
self.last_stagger_send = Some(accepted_at);
self.send_gate = accepted_at.checked_add(spacing);
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
#[cfg(test)]
if let Some(observations) = &self.stagger_observations {
observations.lock().unwrap().push((_active, spacing));
+103
View File
@@ -1576,6 +1576,37 @@ fn authenticated_peer_close_outcome() {
assert!(!has_close(&records));
}
#[test]
fn peer_close_leaves_other_explicitly_managed_targets_running() {
let closing = start_server(ServerBehavior::PeerClose, None);
let continuing = start_server(ServerBehavior::Echo, None);
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
managed.client.duration = None;
let (task, handle) = ManagedClient::task(
managed,
vec![
target("closing", closing.addr),
target("continuing", continuing.addr),
],
)
.unwrap();
let runtime = runtime();
let mut task = Box::pin(task);
drive_task_until(&runtime, &mut task, |task| {
handle.status().peer_closed_target_outcomes == 1
&& task.targets[1].counters.packets_sent != 0
});
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
let stop = handle.stop();
runtime.block_on(task);
runtime.block_on(stop);
closing.finish();
assert!(!probes(&continuing.finish()).is_empty());
}
#[test]
fn peer_close_during_stop_drain_remains_authoritative() {
let key = b"managed-drain-peer-close".to_vec();
@@ -1864,6 +1895,78 @@ fn stagger_gate_tracks_active_membership() {
second.finish();
}
/// The stagger gate must not fold each send's observed wakeup latency into
/// every later slot.
///
/// The gate is the binding constraint on when the *group* may send next (see
/// `next_deadline`), while each target's own schedule stays on an absolute
/// grid anchored at its open. Re-anchoring the gate on every observed accept
/// time therefore made the whole group's cadence drift later without bound:
/// `EchoSent::timer_error` grew monotonically, and once the drift exceeded one
/// interval `skip_missed_probe_slots_at` started dropping probe slots
/// outright. Measured before this was fixed, a single-target 20 s run at a
/// 50 ms interval sent 381 probes instead of 400.
///
/// This asserts the invariant directly rather than through a real-time run,
/// because reproducing accumulation behaviorally needs seconds of wall clock
/// and a jitter assumption. Two properties, both durable:
///
/// - while the pacer keeps up, N accepted sends advance the gate by exactly
/// N spacings from the first anchor, whatever lateness each send is
/// observed with;
/// - a gate the pacer has fallen a full spacing behind re-anchors on the
/// accept time instead, so a stranded gate cannot release a burst of
/// catch-up sends.
#[test]
fn the_stagger_gate_advances_by_one_spacing_per_send_without_accumulating_lateness() {
// No socket is involved: the gate is driven directly and the task is never
// polled, so an unreachable address is all a valid target set needs here.
let (mut task, _) = ManagedClient::task(
config(ManagedPacing::Staggered),
vec![target("only", "127.0.0.1:9".parse().unwrap())],
)
.unwrap();
let spacing = Duration::from_millis(50);
let start = Instant::now();
let lateness = Duration::from_millis(3);
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
start,
);
assert_eq!(task.send_gate, Some(start + spacing), "first send anchors");
// Every later send is observed `lateness` after the gate that released it.
// The gate must still advance one spacing per send from `start`, not
// `spacing + lateness`.
for slot in 1..=8_u32 {
let released_at = start + spacing * slot;
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
released_at + lateness,
);
assert_eq!(
task.send_gate,
Some(start + spacing * (slot + 1)),
"slot {slot} accumulated observed lateness into the gate"
);
}
// A full spacing behind is no longer the same cadence, so the gate
// re-anchors rather than staying stranded in the past.
let stalled_gate = task.send_gate.expect("the gate is set");
let resumed_at = stalled_gate + spacing;
task.record_stagger_acceptance(
SendResult::Ready { accepted: true },
Some((1, spacing)),
resumed_at,
);
assert_eq!(task.send_gate, Some(resumed_at + spacing));
}
#[test]
fn timeout_discovery_inspects_at_most_one_budget_of_targets() {
let target_count = TIMEOUT_WORK_BUDGET + 1;
+2 -1
View File
@@ -39,7 +39,8 @@ pub(crate) struct PendingProbe {
/// eligible only for upstream one-way delay after local plausibility
/// validation against `tx_not_before_wall` (see `compute_one_way`'s
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for
/// upstream one-way delay when this is absent or implausible.
/// upstream one-way delay when this is absent, implausible, or kernel-ID
/// correlation was invalidated by a failed probe submission.
pub kernel_tx_timestamp: Option<SystemTime>,
}
+11 -8
View File
@@ -12,14 +12,17 @@
//! After a successful Open, the client adapter best-effort upgrades the
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
//! `SOF_TIMESTAMPING_OPT_ID` counter the kernel assigns to each successfully
//! submitted datagram is used directly as the probe's wire sequence number
//! (see the client crate's `AGENTS.md` for the correlation invariant this
//! relies on). [`drain_tx_timestamps`] performs a small bounded, nonblocking
//! read of `MSG_ERRQUEUE` so the adapter can opportunistically collect those
//! timestamps without ever waiting for one. The observed kernel TX wall time
//! is retained as metadata only; it is not consumed by any measurement in
//! this change.
//! `SOF_TIMESTAMPING_OPT_ID` counter normally tracks the probe wire sequence
//! after successful sends, so it is used as a best-effort correlation ID.
//! A kernel ID can theoretically be consumed by a send that later fails,
//! desynchronizing that correlation. [`drain_tx_timestamps`] discards
//! unmatched or implausible records; the userspace `sent_at` timestamp remains
//! the fallback. After a probe send failure, correlation is disabled for that
//! session so later IDs cannot be misattributed. It performs a small bounded,
//! nonblocking read of
//! `MSG_ERRQUEUE` so the adapter can opportunistically collect timestamps
//! without ever waiting for one. The observed kernel TX wall time is retained
//! as metadata only; it is not consumed by any measurement in this change.
use std::{
io::{self, IoSliceMut},
@@ -624,22 +624,14 @@ mod tests {
));
}
// NOTE: a live test proving that a failed nonblocking `sendmsg` does not
// consume an automatic OPT_ID is not included here. Forcing a reliable,
// non-flaky `WouldBlock` on a connected loopback UDP socket (e.g. via
// socket-buffer saturation) has no deterministic OS-level mechanism
// available in this environment, and the repository's testing policy
// explicitly forbids faking coverage with sleep/saturation-based
// flakiness. That safety is instead established from Linux kernel
// source: `_sock_tx_timestamp` (include/net/sock.h) and
// `__ip_append_data`/`__ip6_append_data` (net/ipv4/ip_output.c,
// net/ipv6/ip6_output.c) increment `sk->sk_tskey` speculatively before
// a datagram is fully built and explicitly `atomic_dec` it back out on
// every error path (tracked via a local `hold_tskey` flag), so a failed
// send can never leave a gap in — or otherwise advance — the ID space
// the kernel hands out to successfully submitted datagrams. See the
// client crate's `AGENTS.md` for the full citation. The client-side
// half of this invariant (a `WouldBlock` `try_send` never advances the
// local wire-sequence counter used as the correlation ID) is covered by
// a deterministic test in the adapter module that injects `WouldBlock`.
// NOTE: There is no live test for whether a failed nonblocking `sendmsg`
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
// on a connected loopback UDP socket (for example through socket-buffer
// saturation) has no deterministic OS-level mechanism in this environment,
// and the repository's testing policy forbids sleep/saturation-based
// flakiness. The correlation is intentionally best effort: a kernel ID can
// theoretically be consumed by a send that later fails. The deterministic
// adapter test covers only the local half: an injected `WouldBlock` does
// not advance the local wire-sequence counter. Any later unmatched kernel
// timestamp is discarded and the userspace `sent_at` remains the fallback.
}
+43 -5
View File
@@ -60,6 +60,7 @@ struct ActiveSession {
pending: PendingMap,
timed_out: TimedOutMap,
completed: CompletedSet,
kernel_tx_correlation_valid: bool,
}
#[derive(Debug)]
@@ -423,15 +424,27 @@ impl SessionMachine {
}
}
/// Disable Linux kernel TX timestamp correlation after a probe send fails.
///
/// A failed submission can consume a kernel `SOF_TIMESTAMPING_OPT_ID`
/// without advancing the wire sequence. Existing associated timestamps
/// remain valid observations; later error-queue timestamps cannot be
/// associated safely and are ignored for the rest of this session.
pub(crate) fn invalidate_kernel_tx_correlation(&mut self) {
if let MachineState::Open(session) = &mut self.state {
session.kernel_tx_correlation_valid = false;
}
}
/// Record an observed Linux kernel TX timestamp for `wire_seq`, the
/// automatic `SOF_TIMESTAMPING_OPT_ID` the kernel assigned when the
/// datagram was submitted.
///
/// `wire_seq` doubles as that correlation ID: both counters start at
/// zero for a session's first timestamped send and advance by exactly
/// one only on a confirmed successful submission, so they stay
/// identical for the life of one open session (see the client crate's
/// `AGENTS.md` for the full invariant).
/// `wire_seq` normally matches that correlation ID after successful
/// sends. This is best effort rather than an invariant: a kernel ID can
/// theoretically be consumed by a send that later fails, leaving the
/// counters desynchronized. An unmatched or implausible ID is discarded;
/// the probe's userspace `sent_at` remains the fallback.
///
/// Updates a still-pending or already-timed-out probe in place. Never
/// resurrects a completed, evicted, or unknown probe, and never touches
@@ -443,6 +456,9 @@ impl SessionMachine {
let MachineState::Open(session) = &mut self.state else {
return;
};
if !session.kernel_tx_correlation_valid {
return;
}
if let Some(probe) = session.pending.get_mut(wire_seq) {
probe.kernel_tx_timestamp.get_or_insert(timestamp);
return;
@@ -636,6 +652,7 @@ impl SessionMachine {
pending: PendingMap::new(self.config.max_pending_probes),
timed_out: TimedOutMap::new(self.config.max_pending_probes),
completed: CompletedSet::new(self.config.max_pending_probes),
kernel_tx_correlation_valid: true,
}));
let event = ClientEvent::SessionStarted {
@@ -1231,6 +1248,7 @@ mod tests {
pending: PendingMap::new(max_pending_probes),
timed_out: TimedOutMap::new(max_pending_probes),
completed: CompletedSet::new(max_pending_probes),
kernel_tx_correlation_valid: true,
}));
machine
}
@@ -2873,6 +2891,25 @@ mod tests {
);
}
#[test]
fn invalidated_kernel_tx_correlation_uses_userspace_send_time() {
let mono = Instant::now();
let mut machine = upstream_probe(send_wall_ms(1_000), mono);
// A failed send can consume a kernel ID without advancing wire_seq.
// A later timestamp whose ID happens to match a pending wire sequence
// must not be associated after that gap.
machine.invalidate_kernel_tx_correlation();
machine.record_kernel_tx_timestamp(0, send_wall_ms(1_005));
let events = upstream_reply(&mut machine, 1_020, send_wall_ms(1_040), mono);
assert_eq!(
reply_one_way(&events).unwrap().client_to_server,
Some(SignedDuration::from_nanos(20_000_000))
);
}
#[test]
fn upstream_one_way_delay_falls_back_without_kernel_tx() {
let mono = Instant::now();
@@ -3195,6 +3232,7 @@ mod tests {
pending: PendingMap::new(MAX_PENDING_PROBES),
timed_out: TimedOutMap::new(MAX_PENDING_PROBES),
completed: CompletedSet::new(MAX_PENDING_PROBES),
kernel_tx_correlation_valid: true,
}));
(machine, params)
}
+2 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "irtt-proto"
version = "0.5.0"
version = "0.5.1"
description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "protocol"]
categories = ["network-programming"]
+30
View File
@@ -0,0 +1,30 @@
# irtt-proto
Low-level wire encoding and decoding for the IRTT-compatible protocol: request
and reply structs, flags, negotiable parameters, varint/zigzag primitives, and
HMAC-MD5 packet authentication. No sockets, no runtime, no session state.
## Who should use this
Most applications should not depend on this crate directly. Use
[`irtt-client`](https://docs.rs/irtt-client) to run client sessions or
[`irtt-server`](https://docs.rs/irtt-server) to run a server; both build on
`irtt-proto` and provide session lifecycle, negotiation, and validated
configuration on top of it.
Reach for `irtt-proto` directly only if you are implementing another
IRTT-compatible client or server from scratch and need the raw packet layer.
Field values mirror the wire format closely rather than expressing policy:
`Params::decode` rejects malformed input, but direct construction plus
`Params::encode` performs no additional validation — callers are responsible
for validating values before sending them.
## Documentation
Full API documentation is on [docs.rs/irtt-proto](https://docs.rs/irtt-proto).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
+44
View File
@@ -60,6 +60,50 @@ pub struct TimestampFields {
pub send_mono: Option<i64>,
}
/// Encodes an ECHO reply.
///
/// `params` supplies the negotiated layout and length, exactly as on the
/// request side, and every optional field the layout requires must be present
/// in `reply`: a `Some` where the layout has no field, or a `None` where it has
/// one, is an error. `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC`
/// and signs the finished datagram.
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`] or [`ProtoError::UnexpectedFlag`] when
/// the reply does not carry exactly the packet-type flags this encoder
/// requires, [`ProtoError::MissingField`] or [`ProtoError::UnexpectedField`]
/// when `reply`'s optional fields do not match the negotiated layout, and
/// [`ProtoError::PayloadTooLarge`] when the payload does not fit the negotiated
/// length.
///
/// # Example
///
/// ```
/// use irtt_proto::{
/// decode_echo_reply, encode_echo_reply, EchoReply, Params, ReceivedStats,
/// TimestampFields, FLAG_REPLY,
/// };
///
/// // `received_stats` negotiates a receive counter; four payload bytes.
/// let params = Params {
/// length: 24,
/// received_stats: ReceivedStats::Count,
/// ..Params::default()
/// };
/// let reply = EchoReply {
/// flags: FLAG_REPLY,
/// token: 0x1234_5678_9abc_def0,
/// sequence: 7,
/// recv_count: Some(3),
/// recv_window: None,
/// timestamps: TimestampFields::default(),
/// payload: vec![1, 2, 3, 4],
/// };
///
/// let packet = encode_echo_reply(&reply, &params, None).unwrap();
/// assert_eq!(decode_echo_reply(&packet, &params, None).unwrap(), reply);
/// ```
pub fn encode_echo_reply(
reply: &EchoReply,
params: &Params,
+17
View File
@@ -68,6 +68,23 @@ pub fn verify_hmac(key: &[u8], packet: &[u8], hmac_offset: usize) -> Result<()>
/// [`ProtoError::InvalidHmacOffset`] when the field is truncated, and
/// [`ProtoError::BadHmac`] when the MAC does not match. The packet is never
/// modified.
///
/// # Example
///
/// ```
/// use irtt_proto::{encode_request, verify_packet_hmac, Params, RequestToEncode};
///
/// // Synthetic example key — use a real shared secret in production.
/// let key = b"example key";
/// let params = Params::with_protocol_defaults();
/// let packet = encode_request(
/// RequestToEncode::Open { params: &params, no_test: false },
/// Some(key),
/// ).unwrap();
///
/// verify_packet_hmac(key, &packet).unwrap();
/// assert!(verify_packet_hmac(b"wrong key", &packet).is_err());
/// ```
pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
let envelope = envelope::decode_structural(packet)?;
if !envelope.hmac_present {
+14
View File
@@ -135,6 +135,20 @@ pub fn echo_header_len(hmac: bool, params: &Params) -> usize {
/// A ceiling on what a negotiated length may legitimately *be* — an MTU, a
/// resource bound, a maximum packet size — is deliberately not here. That is
/// server and runtime policy, and it belongs where the negotiation happens.
///
/// # Example
///
/// ```
/// use irtt_proto::{echo_packet_len, Params};
///
/// // The default layout's mandatory field block is 16 bytes (header, token,
/// // sequence), so a smaller requested length is floored up to it.
/// assert_eq!(echo_packet_len(false, &Params::default()).unwrap(), 16);
/// assert_eq!(
/// echo_packet_len(false, &Params { length: 40, ..Params::default() }).unwrap(),
/// 40,
/// );
/// ```
pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
let header_len = echo_header_len(hmac, params);
let requested = if params.length <= 0 {
+37
View File
@@ -6,6 +6,10 @@
//! crates such as `irtt-client` are responsible for session behavior and
//! user-facing validation.
//!
//! Most applications should depend on `irtt-client` or `irtt-server` instead
//! of this crate directly; use `irtt-proto` only when implementing another
//! IRTT-compatible client or server that needs the raw packet layer itself.
//!
//! [`Params::decode`] rejects malformed or incompatible incoming parameter
//! values, including invalid enum discriminants, malformed UTF-8, and oversized
//! `server_fill` values. Direct construction followed by [`Params::encode`]
@@ -38,6 +42,39 @@
//! which reply kind to expect, and the two need genuinely different semantic
//! context.
//!
//! # Example
//!
//! The normal path is directional: a sender encodes with [`encode_request`], a
//! receiver classifies with [`decode_request`], and authentication is a
//! separate step once the applicable key is known.
//!
//! ```
//! use irtt_proto::{
//! decode_request, encode_request, verify_packet_hmac, DecodedRequestKind, Params,
//! RequestToEncode,
//! };
//!
//! // Sender side: encode an authenticated open request.
//! let params = Params::with_protocol_defaults();
//! let key = b"example key";
//! let packet = encode_request(
//! RequestToEncode::Open { params: &params, no_test: false },
//! Some(key),
//! ).unwrap();
//!
//! // Receiver side: classify structurally, before any key is known.
//! let request = decode_request(&packet).unwrap();
//! assert!(request.hmac_present);
//! let DecodedRequestKind::Open { no_test, params: encoded } = request.kind else {
//! panic!("expected an open request");
//! };
//! assert!(!no_test);
//! assert_eq!(Params::decode(encoded).unwrap(), params);
//!
//! // Authentication is a separate step, once the applicable key is known.
//! verify_packet_hmac(key, &packet).unwrap();
//! ```
//!
#![forbid(unsafe_code)]
pub mod echo;
+40
View File
@@ -13,6 +13,34 @@ pub struct OpenReply {
pub params: Params,
}
/// Encodes an open reply.
///
/// `hmac_key` is authoritative: `Some(key)` sets `FLAG_HMAC` and signs the
/// finished datagram, while `None` produces an unauthenticated reply and clears
/// any caller-supplied `FLAG_HMAC`. A zero token is rejected unless the reply
/// also carries `FLAG_CLOSE` (a rejected open).
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`] when the reply lacks `FLAG_OPEN` or
/// `FLAG_REPLY`, and [`ProtoError::ZeroToken`] for a zero token without
/// `FLAG_CLOSE`.
///
/// # Example
///
/// ```
/// use irtt_proto::{
/// decode_open_reply, encode_open_reply, OpenReply, Params, FLAG_OPEN, FLAG_REPLY,
/// };
///
/// let reply = OpenReply {
/// flags: FLAG_OPEN | FLAG_REPLY,
/// token: 0x1234_5678_9abc_def0,
/// params: Params::with_protocol_defaults(),
/// };
/// let packet = encode_open_reply(&reply, None).unwrap();
/// assert_eq!(decode_open_reply(&packet, None).unwrap(), reply);
/// ```
pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
let params = reply.params.encode();
let mut out = envelope::begin_checked(
@@ -30,6 +58,18 @@ pub fn encode_open_reply(reply: &OpenReply, hmac_key: Option<&[u8]>) -> Result<V
envelope::finish(out, hmac_key)
}
/// Decodes an open reply.
///
/// Requires `FLAG_OPEN` and `FLAG_REPLY`, and — when `hmac_key` is supplied —
/// verifies the authentication field. The decoded [`Params`] are validated:
/// malformed or incompatible parameter values are rejected here.
///
/// # Errors
///
/// Returns [`ProtoError::MissingFlag`], [`ProtoError::HmacPresenceMismatch`],
/// [`ProtoError::BadHmac`], [`ProtoError::PacketTooShort`],
/// [`ProtoError::ZeroToken`], or a parameter decode error. See
/// [`encode_open_reply`] for a round-trip example.
pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenReply> {
let envelope = envelope::decode(
packet,
+37
View File
@@ -16,6 +16,29 @@ pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// [`clock`](Params::clock) is [`Clock::Unspecified`], which is what an open
/// request with an empty parameter payload means. It is not a set of sensible
/// client settings; a client builds its request from its own configuration.
///
/// # Example
///
/// Construct a `Params`, encode it, and decode it back:
///
/// ```
/// use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt};
///
/// let params = Params {
/// protocol_version: 1,
/// duration_ns: 3_000_000_000,
/// interval_ns: 1_000_000_000,
/// length: 1472,
/// received_stats: ReceivedStats::Both,
/// stamp_at: StampAt::Both,
/// clock: Clock::Both,
/// server_fill: Some(ServerFill { value: "rand".to_owned() }),
/// ..Params::default()
/// };
///
/// let encoded = params.encode();
/// assert_eq!(Params::decode(&encoded).unwrap(), params);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params {
pub protocol_version: i64,
@@ -88,6 +111,20 @@ impl Params {
/// Presence means the tag appeared at least once. Repeated known tags keep
/// last-value-wins, and unknown tags remain ignored and untracked.
///
/// # Example
///
/// ```
/// use irtt_proto::Params;
///
/// // Wire bytes: tag 2 (duration) carrying an explicit zero. Interval
/// // (tag 3) is absent, so both fields decode to zero but only one is
/// // reported present.
/// let decoded = Params::decode_with_presence(&[0x02, 0x00]).unwrap();
/// assert_eq!(decoded.params.duration_ns, 0);
/// assert!(decoded.presence.duration_ns);
/// assert!(!decoded.presence.interval_ns);
/// ```
///
/// [`decode`]: Params::decode
pub fn decode_with_presence(input: &[u8]) -> Result<DecodedParams> {
let mut params = Self::default();
+35
View File
@@ -88,6 +88,26 @@ pub enum DecodedRequestKind<'a> {
/// the negotiated length is positive and wider than `usize`. A *negative*
/// negotiated length is not an error: see [`echo_packet_len`], which floors it
/// at the mandatory field block.
///
/// # Example
///
/// ```
/// use irtt_proto::{encode_request, Params, RequestToEncode};
///
/// // An unauthenticated ECHO probe. `params` supplies the negotiated length,
/// // which reserves four payload bytes beyond the 16-byte echo header.
/// let params = Params { length: 20, ..Params::default() };
/// let packet = encode_request(
/// RequestToEncode::Echo {
/// token: 0x1234_5678_9abc_def0,
/// sequence: 7,
/// params: &params,
/// payload: &[1, 2, 3, 4],
/// },
/// None,
/// ).unwrap();
/// assert_eq!(packet.len(), 20);
/// ```
pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) -> Result<Vec<u8>> {
match request {
RequestToEncode::Open { params, no_test } => encode_open(params, no_test, hmac_key),
@@ -138,6 +158,21 @@ pub fn encode_request(request: RequestToEncode<'_>, hmac_key: Option<&[u8]>) ->
/// Returns [`ProtoError::PacketTooShort`], [`ProtoError::BadMagic`],
/// [`ProtoError::ReservedFlags`], or [`ProtoError::UnexpectedFlag`] for
/// `FLAG_REPLY`.
///
/// # Example
///
/// ```
/// use irtt_proto::{decode_request, encode_request, DecodedRequestKind, RequestToEncode};
///
/// let packet = encode_request(
/// RequestToEncode::Close { token: 0x1234_5678_9abc_def0 },
/// None,
/// ).unwrap();
///
/// let request = decode_request(&packet).unwrap();
/// assert!(!request.hmac_present);
/// assert_eq!(request.kind, DecodedRequestKind::Close { token: 0x1234_5678_9abc_def0 });
/// ```
pub fn decode_request(packet: &[u8]) -> Result<DecodedRequest<'_>> {
let envelope = envelope::decode_structural(packet)?;
if has(envelope.flags, FLAG_REPLY) {
+16
View File
@@ -1,3 +1,19 @@
//! Variable-length integer encoding for IRTT wire values.
//!
//! Tags use an unsigned base-128 varint ([`encode_uvarint`]), and signed
//! integers (such as parameter values) use the same encoding after a zigzag
//! transform ([`encode_varint`]), which keeps small negative numbers compact.
//!
//! # Example
//!
//! ```
//! use irtt_proto::varint::{decode_varint, encode_varint};
//!
//! let mut out = Vec::new();
//! encode_varint(1_000_000_000, &mut out);
//! assert_eq!(decode_varint(&out).unwrap(), (1_000_000_000, out.len()));
//! ```
use crate::{ProtoError, Result};
pub fn encode_uvarint(mut value: u64, out: &mut Vec<u8>) {
+8 -7
View File
@@ -500,13 +500,14 @@ session policy lives.
unmarked reply into a *dropped* one, because an unappliable marking stops the
send. Verify a platform before extending that list.
- A genuinely dual-stack `[::]` listener still emits **unmarked** IPv4-mapped
replies, and this is known. It is not fixed by choosing the option per reply,
for the macOS reason above, and it predates wildcard source selection. Leave it
until it is worth its own slice, and do not "fix" it by weakening the rule that
an unappliable marking stops the send. The crate forbids unsafe code;
there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery. Keep the
target lists identical to the pinned `socket2` version's own gates — check
its source rather than copying another crate's older matrix.
replies on Linux. When reply DSCP/traffic-class marking for IPv4 peers
matters, configure the normal same-port `[::]:PORT` plus `0.0.0.0:PORT`
listener pair instead; `ServerSet` makes the IPv6 half IPv6-only for that
pair. Do not force `IPV6_V6ONLY` on every lone IPv6 listener, and do not
weaken the rule that an unappliable marking stops the send. The crate forbids
unsafe code; there is no raw `setsockopt` and no `sendmsg`/`cmsg` machinery.
Keep the target lists identical to the pinned `socket2` version's own gates
— check its source rather than copying another crate's older matrix.
## Rate, lifetime and server-initiated close
+12 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "irtt-server"
version = "0.5.0"
version = "0.5.3"
description = "IRTT-compatible Rust server library"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "server"]
categories = ["network-programming"]
@@ -23,3 +23,13 @@ tokio = { version = "1.53.1", default-features = false, features = ["rt", "net",
# source-selection path here; elsewhere a wildcard bind is refused.
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))'.dependencies]
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"] }
[package.metadata.docs.rs]
all-features = true
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
[[example]]
name = "basic_server"
[[example]]
name = "multi_listener"
+41
View File
@@ -0,0 +1,41 @@
# irtt-server
Reusable, Tokio-native library for running an IRTT-compatible UDP server:
open negotiation, the bounded session table, echo processing, per-session
rate limiting, idle and max-duration expiry, and reply construction.
## Looking for the CLI?
If you just want to run a server from the command line, use the
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
which installs the `irtt-server` binary. This crate is the library that
binary is built on.
## Structure
- `Server` — the one-listener primitive: one UDP socket, one deterministic
`ServerCore`, one sequential receive/reply loop.
- `ServerSet` — supervises one or more `Server`s as a single service: binds
them all or none, runs each in its own task, and fans one shutdown out to
all of them.
Unlike `irtt-client`, Tokio is not optional here — this crate is intentionally
Tokio-native, with no blocking or alternate-runtime variant.
A wildcard listener (`0.0.0.0` / `[::]`) recovers each request's local
destination and replies from it, so a client on a second local address is
answered from the address it actually contacted. That path is implemented
for Linux, macOS, and FreeBSD; a wildcard bind is refused at construction on
other targets rather than served incorrectly. Explicit-address listeners are
unaffected everywhere.
See `examples/` in the repository for runnable examples.
## Documentation
Full API documentation is on [docs.rs/irtt-server](https://docs.rs/irtt-server).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
@@ -0,0 +1,42 @@
//! Basic single-listener server example.
//!
//! Binds one [`Server`] on an ephemeral loopback port, serves it for a short
//! fixed window, and shuts down. A real deployment would normally bind a
//! fixed port and run until an external shutdown signal.
//!
//! Run with:
//!
//! ```text
//! cargo run -p irtt-server --example basic_server
//! ```
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use irtt_server::{Server, ServerConfig};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let config = ServerConfig::default();
let mut server = match Server::bind(bind_addr, config).await {
Ok(server) => server,
Err(err) => {
eprintln!("failed to bind server: {err}");
return;
}
};
println!(
"listening on {}",
server
.local_addr()
.expect("bound socket has a local address")
);
let result = server.run(tokio::time::sleep(Duration::from_secs(2))).await;
if let Err(err) = result {
eprintln!("server stopped with an error: {err}");
}
}
@@ -0,0 +1,43 @@
//! Multi-listener example using [`ServerSet`].
//!
//! `ServerSet` binds several independent [`Server`] listeners as one service:
//! all-or-nothing bind, one shutdown fanned out to every listener, and the
//! whole set fails if any listener does. Each listener still gets its own
//! socket, session table, and tokens — a token issued by one is unknown at
//! the other. This is what the `irtt-rs` server CLI always runs through, even
//! for a single bind.
//!
//! Run with:
//!
//! ```text
//! cargo run -p irtt-server --example multi_listener
//! ```
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use irtt_server::{ServerConfig, ServerSet};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let addrs = [
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
];
let config = ServerConfig::default();
let set = match ServerSet::bind(addrs, config).await {
Ok(set) => set,
Err(err) => {
eprintln!("failed to bind listener set: {err}");
return;
}
};
println!("listening on {:?}", set.local_addrs());
let result = set.run(tokio::time::sleep(Duration::from_secs(2))).await;
if let Err(err) = result {
eprintln!("listener set stopped with an error: {err}");
}
}
+22
View File
@@ -98,6 +98,28 @@ pub enum TimestampAllowance {
/// [`max_packet_length`](Self::max_packet_length) like every other reply, and
/// `none` is zero-filled here rather than left as residue. An operator
/// restriction can be added if a real use case turns up.
///
/// # Example
///
/// Configure a modest per-listener session budget and restrict timestamp
/// placement while leaving DSCP marking disabled:
///
/// ```
/// use std::time::Duration;
///
/// use irtt_server::{ServerConfig, TimestampAllowance};
///
/// let config = ServerConfig::default()
/// .with_max_sessions(128)
/// .with_idle_timeout(Duration::from_secs(30))
/// .with_timestamp_allowance(TimestampAllowance::Single)
/// .with_dscp_allowed(false);
///
/// assert_eq!(config.max_sessions(), 128);
/// assert_eq!(config.idle_timeout(), Duration::from_secs(30));
/// assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
/// assert!(!config.dscp_allowed());
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig {
hmac_key: Option<Vec<u8>>,
+26
View File
@@ -47,6 +47,32 @@ use crate::{
/// a transport abstraction, and there is no blocking or alternate-runtime
/// counterpart.
///
/// # Example
///
/// The core can be driven directly with a normally encoded open request. It
/// returns a reply to send and records the new session without performing I/O:
///
/// ```
/// use std::net::{Ipv4Addr, SocketAddr};
///
/// use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode};
/// use irtt_server::{ServerConfig, ServerCore};
///
/// let peer = SocketAddr::from((Ipv4Addr::LOCALHOST, 40_000));
/// let mut core = ServerCore::new(ServerConfig::default());
/// let params = Params::with_protocol_defaults();
/// let request = encode_request(
/// RequestToEncode::Open { params: &params, no_test: false },
/// None,
/// )
/// .unwrap();
///
/// let reply = core.handle_datagram(peer, &request).unwrap().unwrap();
/// let open = decode_open_reply(reply.bytes(), None).unwrap();
/// assert_ne!(open.token, 0);
/// assert_eq!(core.session_count(), 1);
/// ```
///
/// # Scope
///
/// The core implements open handling, session creation and negotiation,
+3 -1
View File
@@ -115,7 +115,9 @@ fn parse_hex_pattern(body: &str) -> Option<Vec<u8>> {
return None;
}
digits
.chunks_exact(2)
.as_chunks::<2>()
.0
.iter()
.map(|pair| Some(hex_digit(pair[0])? << 4 | hex_digit(pair[1])?))
.collect()
}
+4 -1
View File
@@ -32,6 +32,9 @@
//! [`ServerConfig`] — `max_sessions` included — applies **per listener**, not
//! per process.
//!
//! See `examples/` in the repository for a runnable single-listener and
//! multi-listener example.
//!
//! # Rejection is silence
//!
//! The protocol defines no error reply, no reset and no NACK. Malformed,
@@ -129,4 +132,4 @@ pub use config::{
pub use core::{OutboundDatagram, ServerCore};
pub use error::ServerError;
pub use runtime::{Server, ServerRuntimeError};
pub use set::{ServerSet, ServerSetError};
pub use set::{address_family_available, ServerSet, ServerSetError};
+22
View File
@@ -53,6 +53,28 @@ const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(1);
/// [`ServerRuntimeError::WildcardSourceSelectionUnsupported`] — rather than
/// served by a listener whose replies a client on a second address would
/// silently discard. Explicit-address listeners are unaffected everywhere.
///
/// # Example
///
/// Bind one listener, inspect the resolved endpoint, and let the caller decide
/// when serving ends. This example type-checks without opening a socket:
///
/// ```no_run
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
///
/// use irtt_server::{Server, ServerConfig};
///
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// let mut server = Server::bind(
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// ServerConfig::default(),
/// )
/// .await?;
/// println!("listening on {}", server.local_addr()?);
/// server.run(tokio::time::sleep(Duration::from_secs(60))).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct Server {
socket: UdpSocket,
+65
View File
@@ -57,6 +57,29 @@ use crate::{Server, ServerConfig, ServerRuntimeError};
/// whole set: a service configured for IPv4 and IPv6 must not quietly continue
/// as IPv4 only. Every listener task is joined before `run` returns, so no task
/// outlives it.
///
/// # Example
///
/// Bind independent listeners, inspect their resolved addresses, then serve
/// them under one caller-owned shutdown future. This example type-checks
/// without binding sockets:
///
/// ```no_run
/// use std::{net::{Ipv4Addr, SocketAddr}, time::Duration};
///
/// use irtt_server::{ServerConfig, ServerSet};
///
/// # async fn serve() -> Result<(), Box<dyn std::error::Error>> {
/// let addrs = [
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
/// ];
/// let set = ServerSet::bind(addrs, ServerConfig::default()).await?;
/// println!("listening on {:?}", set.local_addrs());
/// set.run(tokio::time::sleep(Duration::from_secs(60))).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug)]
pub struct ServerSet {
servers: Vec<Server>,
@@ -91,6 +114,14 @@ impl ServerSet {
/// An IPv4-mapped address such as `[::ffff:0.0.0.0]` is an IPv4 listener
/// here, not an IPv6 one, matching how the runtime already reads it.
///
/// A lone explicit dual-stack `[::]:PORT` listener is not the recommended
/// configuration when reply DSCP/traffic-class marking for IPv4 peers
/// matters: Linux serves those peers as IPv4-mapped and cannot retain the
/// negotiated marking on their replies. Request the normal same-port
/// `[::]:PORT` and `0.0.0.0:PORT` pair instead. This method makes the IPv6
/// half IPv6-only for that pair only; a lone IPv6 listener retains its
/// normal socket behavior.
///
/// # Errors
///
/// Returns [`ServerSetError::NoListeners`] if `addrs` is empty — a set
@@ -218,6 +249,40 @@ impl ServerSet {
}
}
/// Whether `addr`'s address family has any local support on this host at
/// all, independent of whether this particular address or port can be
/// bound.
///
/// A [`ServerSetError::ListenerSetup`] failure can mean many different
/// things — the port is already in use, permission is denied, this target
/// has no safe wildcard reply-source path (see [`ServerRuntimeError`]) — and
/// only one of them, the address family itself having no local support at
/// all (for example IPv6 disabled on the host), is safe for a caller to work
/// around by silently trying a different address instead. This checks only
/// that, by actually binding the wildcard address of `addr`'s family on an
/// ephemeral port: a bare socket of an unsupported family can still
/// construct successfully on some systems (Linux administratively disables
/// IPv6 per-interface, so `AF_INET6` sockets keep working while every bind
/// fails with `EADDRNOTAVAIL`), so construction alone is not a reliable
/// signal — only a real bind attempt is.
#[must_use]
pub fn address_family_available(addr: SocketAddr) -> bool {
let wildcard = if addr.is_ipv6() {
SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0))
} else {
SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0))
};
let domain = if addr.is_ipv6() {
Domain::IPV6
} else {
Domain::IPV4
};
let Ok(socket) = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP)) else {
return false;
};
socket.bind(&SockAddr::from(wildcard)).is_ok()
}
/// Binds one listener, pre-configuring the socket where the bind itself depends
/// on it.
///
+5 -5
View File
@@ -32,11 +32,11 @@
//! instant, is served normally with no timestamp. Nothing about which datagrams
//! reach [`ServerCore`](crate::ServerCore) depends on it.
//!
//! **Nothing consumes it yet.** It is captured here so a follow-up change can
//! decide how a kernel-observed arrival time enters the protocol core; today
//! the core still samples its own receive instant and no protocol timing is
//! affected. It is a software wall-clock reading only — not a NIC hardware
//! timestamp, not monotonic, and not a transmit timestamp.
//! [`ServerCore`](crate::ServerCore) prefers it over its own receive sample
//! when reporting a request's receive instant, falling back to that sample
//! when no plausible kernel reading is available; see
//! `preferred_receive_wall_ns`. It is a software wall-clock reading only —
//! not a NIC hardware timestamp, not monotonic, and not a transmit timestamp.
use std::{io, net::SocketAddr, time::SystemTime};
+5 -2
View File
@@ -1,18 +1,21 @@
[package]
name = "irtt-stats"
version = "0.5.0"
version = "0.5.1"
description = "Statistics aggregation for irtt-client events"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
readme = "README.md"
keywords = ["irtt", "networking", "latency", "statistics"]
categories = ["network-programming"]
[dependencies]
irtt-client.workspace = true
[[example]]
name = "finite_vs_continuous"
[dev-dependencies]
proptest = "1.11.0"
+38
View File
@@ -0,0 +1,38 @@
# irtt-stats
Statistics aggregation over [`irtt-client`](https://crates.io/crates/irtt-client)
events: loss/duplicate/late accounting, RTT and one-way-delay timing, and
inter-packet delay variation (IPDV), as cumulative and rolling snapshots.
## Relationship to irtt-client
This crate does not open sessions or drive sockets. Feed it the `ClientEvent`
stream produced by an `irtt-client` `Client`, `AsyncClient`, or managed
session via `StatsCollector::process`, and read back a `Snapshot`.
A `StatsCollector` has one sequence/IPDV namespace and one packet count, both
scoped to a single target. A managed session covering multiple targets needs
one collector per target — as the `irtt-rs` CLI does — since a target's
sequence numbers start over at zero and feeding two targets into one
collector would pair up unrelated probes.
## Retention modes
`StatsConfig::finite()` retains exact timing samples for its cumulative
snapshot, so every metric with samples reports an exact median there —
retention grows with the probe count. `StatsConfig::continuous()` keeps
bounded running statistics instead (no exact median) plus a bounded
4096-entry adjacent-sequence IPDV store per target, for long-running or
unbounded sessions. Rolling-window snapshots are always running-only and
report no medians, whichever `StatsConfig` produced them.
See `examples/` in the repository for a runnable comparison of both modes.
## Documentation
Full API documentation is on [docs.rs/irtt-stats](https://docs.rs/irtt-stats).
## Project
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
implementation of an IRTT-compatible protocol stack.
@@ -0,0 +1,102 @@
//! Contrasts finite and continuous retention modes.
//!
//! Feeds the same synthetic stream of `irtt-client` events into two
//! collectors — one configured with [`StatsConfig::finite`], one with
//! [`StatsConfig::continuous`] — and prints how their cumulative snapshots
//! differ. This uses synthetic events rather than a live session so the
//! example runs deterministically with no network or server involved.
//!
//! Run with:
//!
//! ```text
//! cargo run -p irtt-stats --example finite_vs_continuous
//! ```
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant, SystemTime};
use irtt_client::{ClientEvent, ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use irtt_stats::{StatsCollector, StatsConfig};
fn main() {
let mut finite = StatsCollector::new(StatsConfig::finite());
let mut continuous = StatsCollector::new(StatsConfig::continuous());
for event in synthetic_probe_stream(10) {
finite.process(&event);
continuous.process(&event);
}
let finite_snapshot = finite.snapshot();
let continuous_snapshot = continuous.snapshot();
println!(
"finite: replies={}, rtt median_ns={:?}",
finite_snapshot.events.echo_replies, finite_snapshot.rtt.primary.median_ns
);
println!(
"continuous: replies={}, rtt median_ns={:?}",
continuous_snapshot.events.echo_replies, continuous_snapshot.rtt.primary.median_ns
);
println!(
"continuous mode keeps no exact samples, so its median is always None, \
while finite mode's median is available once it has samples."
);
}
/// Builds a short synthetic stream of `EchoSent`/`EchoReply` pairs, plus one
/// loss, standing in for what a real `irtt-client` session would emit.
fn synthetic_probe_stream(count: u32) -> Vec<ClientEvent> {
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
let mut events = Vec::new();
for seq in 0..count {
let sent_at = ClientTimestamp {
wall: SystemTime::now(),
mono: Instant::now(),
};
events.push(ClientEvent::EchoSent {
seq,
remote,
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(50),
timer_error: Duration::from_micros(20),
});
if seq == count - 1 {
// The last probe times out instead of receiving a reply.
events.push(ClientEvent::EchoLoss {
seq,
sent_at,
timeout_at: sent_at.mono + Duration::from_secs(1),
});
continue;
}
let rtt = Duration::from_millis(10 + u64::from(seq));
let received_at = ClientTimestamp {
wall: sent_at.wall + rtt,
mono: sent_at.mono + rtt,
};
events.push(ClientEvent::EchoReply {
seq,
remote,
sent_at,
received_at,
rtt: RttSample {
raw: rtt,
adjusted: None,
effective: SignedDuration::from_duration(rtt),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
events
}
+265 -2
View File
@@ -1,7 +1,24 @@
//! Statistics aggregation for `irtt-client` events.
//!
//! The crate consumes `irtt-client` events and produces cumulative or rolling
//! snapshots for reporting and integration code.
//! [`StatsCollector`] consumes [`irtt_client::ClientEvent`] values through
//! [`StatsCollector::process`] and produces a cumulative [`Snapshot`], plus
//! optional count- and time-based rolling snapshots, for reporting and
//! integration code.
//!
//! # Retention modes
//!
//! [`StatsConfig`] selects between two retention modes via [`SampleMode`].
//! [`StatsConfig::finite`] ([`SampleMode::Exact`]) retains an exact sample per
//! timing metric, so every metric reports an exact median once it has
//! samples; retention grows with the number of probes processed. This is the
//! default and suits a run with a known, bounded duration.
//! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only
//! running statistics — no exact median — and bounds its adjacent-sequence
//! IPDV tracking, so memory stays bounded for a long-running or unbounded
//! session. Rolling snapshots always use running statistics regardless of
//! this setting.
//!
//! See `examples/` in the repository for a runnable comparison of both modes.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
@@ -49,6 +66,44 @@ impl StatsConfig {
/// them reports a median in the cumulative snapshot once it has samples,
/// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent
/// replies can still complete IPDV pairs.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::finite());
/// for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
/// collector.process(&sent(seq as u32, seq as u64 * 10, send_call_us));
/// }
///
/// let snapshot = collector.snapshot();
/// // Finite mode retains exact samples, so the send-call metric reports an
/// // exact median once it has samples: the middle of the sorted
/// // 10, 20, 30 µs values, not their mean.
/// assert_eq!(snapshot.send_call.count, 3);
/// assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
/// ```
pub fn finite() -> Self {
Self {
samples: SampleMode::Exact,
@@ -63,6 +118,44 @@ impl StatsConfig {
/// Continuous mode uses running statistics, retains no exact samples so no
/// timing metric reports a median, and bounds adjacent-sequence IPDV
/// tracking for long-running sessions.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::continuous());
/// for seq in 0..3 {
/// collector.process(&sent(seq, u64::from(seq) * 10, 10));
/// }
///
/// let snapshot = collector.snapshot();
/// // Continuous mode still tracks running statistics — count, mean, min,
/// // and max — but retains no exact samples, so it reports no median.
/// assert_eq!(snapshot.send_call.count, 3);
/// assert!(snapshot.send_call.mean_ns > 0.0);
/// assert_eq!(snapshot.send_call.median_ns, None);
/// ```
pub fn continuous() -> Self {
Self {
samples: SampleMode::RunningOnly,
@@ -118,6 +211,34 @@ impl StatsConfig {
/// The result is deterministic and computed with saturating arithmetic, so
/// an enormous `probe_count` saturates at [`u64::MAX`] rather than
/// wrapping or panicking.
///
/// # Example
///
/// ```
/// use irtt_stats::StatsConfig;
///
/// // Finite retention grows with the probe count...
/// let finite = StatsConfig::finite();
/// assert!(
/// finite.estimated_retained_bytes(1_000_000)
/// > finite.estimated_retained_bytes(1_000)
/// );
///
/// // ...while continuous retention is bounded, so a far longer run is no
/// // more expensive.
/// let continuous = StatsConfig::continuous();
/// assert_eq!(
/// continuous.estimated_retained_bytes(1_000_000),
/// continuous.estimated_retained_bytes(10_000_000),
/// );
/// assert!(
/// continuous.estimated_retained_bytes(1_000_000)
/// < finite.estimated_retained_bytes(1_000_000)
/// );
///
/// // Enormous probe counts saturate rather than wrapping or panicking.
/// assert_eq!(finite.estimated_retained_bytes(u64::MAX), u64::MAX);
/// ```
pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 {
retention::estimated_retained_bytes(self, probe_count)
}
@@ -175,6 +296,60 @@ pub enum SampleMode {
///
/// A collector maintains cumulative statistics and, when configured, rolling
/// windows. Rolling snapshots are recomputed from retained normalized events.
///
/// # Example
///
/// Feed a short stream of `irtt-client` events to a finite-mode collector and
/// read back a cumulative snapshot:
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp, RttSample, SignedDuration};
/// #
/// # /// A synthetic reply; a real session delivers these as `ClientEvent`s.
/// # fn reply(seq: u32, rtt_ms: u64) -> ClientEvent {
/// # let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(u64::from(seq) * 10),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(u64::from(seq) * 10),
/// # };
/// # let raw = Duration::from_millis(rtt_ms);
/// # ClientEvent::EchoReply {
/// # seq,
/// # remote,
/// # sent_at,
/// # received_at: ClientTimestamp {
/// # mono: sent_at.mono + raw,
/// # wall: sent_at.wall + raw,
/// # },
/// # rtt: RttSample {
/// # raw,
/// # adjusted: None,
/// # effective: SignedDuration::from_duration(raw),
/// # },
/// # server_timing: None,
/// # one_way: None,
/// # received_stats: None,
/// # bytes: 64,
/// # packet_meta: irtt_client::PacketMeta::default(),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig::finite());
/// for (seq, rtt_ms) in [10_u64, 30, 20].into_iter().enumerate() {
/// collector.process(&reply(seq as u32, rtt_ms));
/// }
///
/// let snapshot = collector.snapshot();
/// assert_eq!(snapshot.events.echo_replies, 3);
/// assert_eq!(snapshot.rtt.primary.count, 3);
/// // Finite mode retains exact samples, so the round-trip metric reports an
/// // exact median: the middle of the sorted 10, 20, 30 ms samples.
/// assert_eq!(snapshot.rtt.primary.median_ns, Some(20_000_000.0));
/// ```
pub struct StatsCollector {
cumulative: CoreStats,
rolling: RollingEvents,
@@ -219,6 +394,53 @@ impl StatsCollector {
/// running statistics only, whatever [`StatsConfig::samples`] is set to,
/// so its timing metrics report no median. A rolling window is a bounded
/// recent view rather than the run's retained history.
///
/// # Example
///
/// ```
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::{Duration, Instant, SystemTime};
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig {
/// rolling_count: Some(2),
/// ..StatsConfig::finite()
/// });
/// collector.process(&sent(0, 0, 10));
/// collector.process(&sent(1, 10, 10));
/// collector.process(&sent(2, 20, 10));
///
/// // The window keeps only the last two normalized events...
/// let rolling = collector.rolling_count().unwrap();
/// assert_eq!(rolling.packets.packets_sent, 2);
/// // ...and is recomputed with running statistics, so it reports no median
/// // even though the collector retains exact samples for its cumulative
/// // snapshot.
/// assert_eq!(rolling.send_call.median_ns, None);
///
/// // The cumulative snapshot still sees every event.
/// let cumulative = collector.snapshot();
/// assert_eq!(cumulative.packets.packets_sent, 3);
/// assert_eq!(cumulative.send_call.median_ns, Some(10_000.0));
/// ```
pub fn rolling_count(&self) -> Option<Snapshot> {
self.rolling.count_snapshot()
}
@@ -227,6 +449,47 @@ impl StatsCollector {
///
/// Like [`StatsCollector::rolling_count`], this is recomputed with running
/// statistics only and reports no medians.
///
/// # Example
///
/// ```
/// use std::time::Duration;
///
/// use irtt_stats::{StatsCollector, StatsConfig};
///
/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
/// # use std::time::Instant;
/// # use irtt_client::{ClientEvent, ClientTimestamp};
/// #
/// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
/// # let sent_at = ClientTimestamp {
/// # mono: Instant::now() + Duration::from_millis(at_ms),
/// # wall: std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
/// # };
/// # ClientEvent::EchoSent {
/// # seq,
/// # remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
/// # scheduled_at: sent_at.mono,
/// # sent_at,
/// # bytes: 64,
/// # send_call: Duration::from_micros(send_call_us),
/// # timer_error: Duration::from_micros(2),
/// # }
/// # }
/// #
/// let mut collector = StatsCollector::new(StatsConfig {
/// rolling_time: Some(Duration::from_millis(15)),
/// ..StatsConfig::finite()
/// });
/// collector.process(&sent(0, 0, 10));
/// collector.process(&sent(1, 10, 10));
/// collector.process(&sent(2, 30, 10));
///
/// // A 15 ms window anchored at the latest event (30 ms) retains only the
/// // events from the last 15 ms — here just the final send.
/// let rolling = collector.rolling_time().unwrap();
/// assert_eq!(rolling.packets.packets_sent, 1);
/// ```
pub fn rolling_time(&self) -> Option<Snapshot> {
self.rolling.time_snapshot()
}
-20
View File
@@ -13,26 +13,6 @@ use irtt_stats::{
EventStatsUpdate, IpdvPairUpdate, LateReplyMode, SampleMode, StatsCollector, StatsConfig,
};
#[test]
fn exact_mode_reports_a_send_call_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());
for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
collector.process(&sent_with_timings(
seq as u32,
ts(seq as u64 * 10),
send_call_us,
2,
));
}
let snapshot = collector.snapshot();
assert_eq!(snapshot.send_call.count, 3);
assert_eq!(snapshot.send_call.min_ns, Some(10_000));
assert_eq!(snapshot.send_call.max_ns, Some(30_000));
// Sorted 10, 20, 30 us: the median is the middle sample, not the mean.
assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
}
#[test]
fn exact_mode_reports_a_timer_error_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());
+1 -1
View File
@@ -139,5 +139,5 @@ scenario contains the existing comparison harness output under
`netem-summary.json` files. Server stdout/stderr are captured at the run root as
`server.stdout` and `server.stderr`.
On macOS, `ip netns` and `tc netem` are unavailable; use the GitHub Actions
On macOS, `ip netns` and `tc netem` are unavailable; use the Gitea Actions
workflow for netem coverage.
+302
View File
@@ -0,0 +1,302 @@
# irtt-client
## NAME
irtt-client - IRTT-compatible stream client
## SYNOPSIS
`irtt-client` [*OPTIONS*] [*[LABEL=]TARGET[@hmac=KEY]*]...
`irtt-client` `--list-columns`
## DESCRIPTION
`irtt-client` probes one or more IRTT-compatible servers over UDP and prints
per-probe measurement events as they arrive, plus a final summary for
eligible runs.
## TARGETS
Every target argument accepts `[LABEL=]TARGET[@hmac=KEY]`. An optional
`LABEL=` prefix assigns the logical target name used in output; without it,
the target string itself is used as the label. Labeled and unlabeled targets
can be freely mixed in one argument list. Prefer explicit labels for
long-running dynamic target sets: the label is the stable target identity
across replacements.
```sh
irtt-client host-a:2112
irtt-client eu=host-a:2112 us=host-b:2112
irtt-client host-a:2112 eu=host-b:2112
irtt-client --hmac default-secret eu=host-a:2112@hmac=eu-secret public=host-b:2112@hmac=
```
Targets without `@hmac=` inherit the global `--hmac` value. `@hmac=KEY`
overrides it for that target, and `@hmac=` explicitly disables HMAC for that
target. The modifier uses `@`, not a shell command separator; quote an argument
if its key needs shell quoting.
Target syntax supports these backslash escapes in labels, addresses, and HMAC
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
label or address when it would otherwise begin `@hmac=`; escaping the first
`=` is how an unlabeled target or a label can contain that character. An `=` in
an HMAC value, including Base64 padding, does not need escaping. Positional
target arguments may contain a raw comma; stdin target sets have the additional
`\,` escape described below.
Shells process unquoted backslashes and characters such as semicolons before
the client sees them. Single-quote a target when it contains parser escapes,
for example `'west\=edge=host\@hmac=literal@hmac=a\;b\\c'`, so the
backslashes reach `irtt-client` unchanged.
Multiple targets are probed concurrently. By default, a multi-target session
uses **staggered** pacing, spacing active targets across the probe interval;
pass `--pacing burst` to send one probe to every active target back-to-back
once per interval instead. Final per-target summaries print in the order
targets were supplied on the command line, not the order in which they
finish or their labels sort alphabetically.
## DYNAMIC TARGET SETS FROM STANDARD INPUT
`--targets-stdin` is available only with continuous mode (`--duration 0`).
Positional targets remain an initial desired set, and may be omitted to
start empty:
~~~sh
irtt-client --duration 0 --targets-stdin
irtt-client --duration 0 --targets-stdin ams=ams.example:2112
~~~
Each non-empty stdin record declares the complete desired target set, rather
than a delta. A later record replaces an earlier set: targets absent from it
are retired, unchanged target configurations retain their generation, and a
changed address or HMAC setting creates a fresh generation. Under transient
live-generation backpressure, only the latest unapplied desired set is kept.
Commas frame targets in stdin records; escape a literal comma within one
target as `\,`. Target parsing inside each element is otherwise the same as
for positional arguments, including the other escapes above, `@hmac=KEY`, and
`@hmac=`. Use a persistent producer or keep a pipe/FIFO writer open while the
client is being controlled. For example, in one terminal:
~~~sh
mkfifo /tmp/irtt-targets
irtt-client --duration 0 --targets-stdin --hmac default-secret \
< /tmp/irtt-targets
~~~
Then open the FIFO once from another terminal and keep that descriptor open
while publishing complete desired sets:
~~~sh
exec 3> /tmp/irtt-targets
printf '%s\n' 'ams=ams.example:2112,sg=sg.example:2112@hmac=sg-secret' >&3
printf '%s\n' 'sg=sg.example:2112@hmac=sg-secret,nyc=nyc.example:2112' >&3
# Publish more sets as needed, then send EOF and stop the client:
exec 3>&-
~~~
The exact record `[]` selects an empty desired set. Empty records are
ignored; whitespace is otherwise payload, not trimming. EOF is immediate loss
or closure of the control plane and requests a graceful stop. A desired-state
revision that was read but has not yet been acknowledged as applied may be
discarded when EOF is observed; EOF does not wait for a capacity-rejected
revision to become applicable.
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
desired set to 128 targets and retains at most 256 live target generations
while replacements drain.
## FINITE VERSUS CONTINUOUS OPERATION
`--duration` controls the run length:
- Any nonzero duration (default `10s`) is a **finite** run: probing stops
after that long, and a final summary is always printed for table output
(see [OUTPUT](#output) below — CSV, TSV, and JSON Lines never print one).
- `--duration 0` is **continuous**: the client runs until interrupted
(`Ctrl-C`). For table output, a final summary is printed only if the run
was interrupted. Stdin-controlled mode is the exception: EOF also requests
a graceful stop and prints the final retained summary. An uninterrupted
continuous run that exits for another reason does not get one.
See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
distinction also determines what statistics can be computed and how much
memory a long run retains.
## OUTPUT
### `--format FORMAT`
One of four event-row formats, default `table`:
- `table`: human-readable terminal output; the only format that prints a
final summary.
- `csv`: comma-separated output.
- `tsv`: tab-separated output.
- `jsonl`: one JSON object per event.
CSV, TSV, and JSON Lines default to **all** columns for structured export;
`table` defaults to a compact column set and hides `echo_sent` rows. An
explicit `--columns` selection other than `default` shows all event rows,
including `echo_sent`; `--columns default` is equivalent to omitting the
option and keeps `echo_sent` hidden for table output.
### `--columns COLUMNS` / `-c COLUMNS`
Comma-separated event row columns, or the special values `default` (the
format's default set) or `all` (every column). List every available column,
its meaning, and its aliases with:
```sh
irtt-client --list-columns
```
(`--list-columns` does not require a target.)
### `--header auto|always|never`
Header policy for `table`, `csv`, and `tsv` output (default `auto`). JSON
Lines never prints a header line.
### `--verbose`
Includes extra fields in table output and final summaries. Has no effect on
CSV, TSV, or JSON Lines output either way — those formats show their default
all-column set or an explicit `--columns` selection exactly as given,
regardless of `--verbose`.
### Examples
```sh
irtt-client <server> --format table
irtt-client <server> --format jsonl
irtt-client <server> --format csv --columns event,seq,remote,effective_rtt_us
```
A stream containing only effective RTT values in microseconds:
```sh
irtt-client <server> --format tsv --columns effective_rtt_us --header never
```
## MEASUREMENT FIELDS
Useful measurement columns:
- `raw_rtt_us`: client-observed send-to-receive RTT. Always userspace-timed
and unaffected by kernel timestamp preference (below).
- `adjusted_rtt_us`: RTT adjusted for server processing when available. Can
be negative when server processing exceeds the measured raw RTT.
- `effective_rtt_us`: `adjusted_rtt_us` when available, otherwise
`raw_rtt_us`.
- `rd_us` / `sd_us` (selectable with the longer aliases `receive_delay_us` /
`send_delay_us`, but always emitted under the short names): one-way delay
estimates. Can be negative because of clock skew between client and
server.
- `ipdv_us`: inter-packet delay variation.
- `server_processing_us`: time spent processing the packet at the server.
- `kernel_rx_ns`: kernel receive timestamp, in Unix nanoseconds, when
captured (see below).
### Kernel timestamps (Linux)
On Linux, one-way delay estimates prefer a kernel-captured timestamp over
userspace timing for this client's own send/receive endpoint, where the
platform and socket support it, reducing the effect of scheduling and
queuing delay. The peer's reported endpoint gets the same preference when it
is the `irtt-server` applet running on Linux; against another
IRTT-compatible server, whatever wall-clock value it reports is used as-is.
This is best-effort: an unsupported or implausible kernel timestamp falls
back to userspace timing. `raw_rtt_us` is unaffected either way;
`adjusted_rtt_us`/`effective_rtt_us` can still shift slightly, since server
processing time is derived from the same server receive endpoint.
## MEASUREMENTS AND MEMORY
Finite and continuous modes make a different tradeoff between exact
statistics and bounded memory.
**Finite mode** retains exact timing samples (and adjacent-sequence IPDV
state) so the final summary can report exact medians. Retained memory
therefore grows with the number of probes sent, at a small constant cost per
probe (on the order of a few hundred bytes of retained sample state per
probe per target, before allocator overhead) — a very long finite run at a
short interval is the case to plan capacity for. The client warns on stderr
when its own estimate of this retained state crosses roughly 128 MiB, and
again more strongly past roughly 512 MiB and 1 GiB; treat those numbers as
practical guidance, not a promised RSS ceiling, since allocator and OS
overhead sit outside the estimate.
**Continuous mode** (`--duration 0`) does not retain a growing sample
history: running statistics keep count/min/max/mean/variance only, so exact
medians are unavailable. It still keeps a bounded adjacent-sequence IPDV
store, capped at 4,096 sequences per target, so nearby replies can still
form IPDV pairs. Retained statistics state therefore approaches a fixed
per-target bound rather than growing with elapsed run time.
Reply classification (independent of the statistics choice above) is also
bounded per target: each of the pending, timed-out, and completed/duplicate
sequence stores has its own 4,096-entry limit — not one shared total across
the three. A full pending store makes the client fail and drain that target
as resource-exhausted rather than wait for capacity to free. Timed-out and
completed state evict their oldest entries, so a sufficiently old late reply
can still be seen and counted, but no longer has retained send state for
measurements such as RTT.
Overall, retained state scales with the **number of configured targets**
and these fixed per-target limits, not with total probes sent in continuous
mode. Output is written as events arrive rather than accumulated; the
managed event stream feeding it is itself bounded and lossy (16,384 events),
so a sufficiently slow consumer can miss events, in which case the client
reports that resulting output/statistics may be incomplete.
For capacity planning on constrained devices (for example an OpenWrt
router): a finite run's statistics memory is roughly proportional to
`targets × probes_sent`, while a continuous run's statistics memory
approaches a fixed multiple of `targets` regardless of how long it runs.
## PEER CLOSE AND EXIT BEHAVIOR
A peer closing a finite run is accepted as a terminal, successful outcome.
In continuous mode, an uninterrupted peer closure is treated as an error —
the client exits nonzero — so a process supervisor can restart it; an
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
mode it was in.
With `--targets-stdin`, peer closure is target-local: other desired targets
and the stdin controller continue running. Including that target in a later
complete set starts a fresh generation.
## EXIT STATUS
`0` on success (including a finite run's ordinary completion, and an
interrupted run of either mode). Nonzero if the managed driver failed, if an
uninterrupted continuous run ended by peer closure, or if the run was not
interrupted and no target completed successfully. An interrupted run never
fails on the last of these conditions — for example, interrupting a
continuous run whose only target never opened still exits `0`.
## SERVER FILL REQUEST
`--sfill DESCRIPTOR` requests the server's echo-reply payload fill: `none`
(zero-filled), `rand` (random bytes), or `pattern:HEX` (a repeating
hexadecimal pattern). See `irtt-server(1)` SERVER FILL for what the server
does with an unset or unparseable request.
## EXAMPLES
```sh
irtt-client netperf-eu.bufferbloat.net:2112
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
irtt-client host-a:2112 host-b:2112 --pacing burst
```
## SEE ALSO
`irtt-tui(1)`, `irtt-server(1)`, `irtt-rs(1)`, and `irtt-client --help` for
the full option list, including HMAC, clock/timestamp negotiation, DSCP, and
TTL flags.
+64
View File
@@ -0,0 +1,64 @@
# irtt-rs
## NAME
irtt-rs - IRTT-compatible multi-applet dispatcher
## SYNOPSIS
`irtt-rs` `client` [*OPTIONS*] [*[LABEL=]TARGET*]...
`irtt-rs` `tui` [*OPTIONS*] *[LABEL=]TARGET*...
`irtt-rs` `server` [*OPTIONS*]
## DESCRIPTION
`irtt-rs` is a multicall dispatcher: one binary that runs the client, TUI,
or server applet depending on the subcommand given, or on the name it was
invoked/copied as. It exists for router-friendly single-binary deployment;
it does not add behavior beyond what `irtt-client(1)`, `irtt-tui(1)`, and
`irtt-server(1)` already provide.
Each subcommand behaves exactly like its dedicated binary — `irtt-rs client
...` is equivalent to running `irtt-client ...` directly. See those manuals
for target syntax, options, output, and exit behavior.
## APPLET BINARY NAMES
The build also produces dedicated binaries: `irtt-client`, `irtt-tui`, and
`irtt-server`. A dedicated binary always runs its own role, regardless of
what it is invoked or copied as — it does not consult argv0 dispatch logic.
`irtt-rs` itself inspects argv0 first: if invoked (or symlinked/copied) under
one of the recognized applet names — `irtt-client`, `irtt-tui`, or
`irtt-server` — it runs that applet directly, without needing a subcommand.
Any other name starting with `irtt-` (other than `irtt-rs` itself) is
rejected as an unknown applet name before subcommand parsing is even
attempted — `irtt-custom server` is a hard error, not a fallthrough. Only
`irtt-rs` itself, or a name that doesn't start with `irtt-` at all, falls
through to ordinary `client`/`tui`/`server` subcommand parsing.
## FEATURE-DEPENDENT APPLET AVAILABILITY
Each applet is gated by a Cargo feature on the `irtt-rs` package: `client`,
`tui`, `server`. All three are in the default feature set. A build with a
feature disabled still produces the `irtt-rs` binary, but that applet is
unavailable at runtime — running it (by subcommand or by binary name)
reports which feature to rebuild with, and `irtt-rs --help` lists which
applets the current build actually contains.
```sh
cargo install irtt-rs --locked --no-default-features --features client
```
## RELATIONSHIP TO irtt-client, irtt-tui, irtt-server
`irtt-rs` is packaging and dispatch only. All probing, display, and server
behavior lives in `irtt-client(1)`, `irtt-tui(1)`, and `irtt-server(1)` —
consult those manuals for anything observable about running a probe or
serving one.
## SEE ALSO
`irtt-client(1)`, `irtt-tui(1)`, `irtt-server(1)`.
+300
View File
@@ -0,0 +1,300 @@
# irtt-server
## NAME
irtt-server - IRTT-compatible UDP server
## SYNOPSIS
`irtt-server` [`--bind` *ADDR*]... [*OPTIONS*]
`irtt-rs server` [`--bind` *ADDR*]... [*OPTIONS*]
## DESCRIPTION
`irtt-server` answers IRTT echo requests over UDP. It is thin orchestration
over the reusable `irtt-server` library: one current-thread Tokio runtime,
one or more listeners, and the library's own session/policy logic.
Run with no arguments for the ordinary case:
```sh
irtt-server
```
With no `--bind`, the server listens on the standard IRTT port (2112) on the
wildcard address for both address families, in this fixed order:
1. `[::]:2112`
2. `0.0.0.0:2112`
This is available wherever the platform supports safe wildcard reply-source
selection (see [Wildcard binds](#wildcard-binds) below). Any explicit
`--bind` **replaces** this default pair entirely rather than adding to it —
`irtt-server --bind 127.0.0.1:2112` binds only that one address.
If one of the two default addresses cannot be bound because its address
family itself has no local support at all — IPv6 disabled on an otherwise
IPv4-capable host, for example — the server falls back to serving just the
other one instead of failing outright, and says so on startup. Any other
reason a default listener fails — the port already in use, permission
denied, no safe wildcard reply-source path — is not a family-support
problem and is not silently worked around; the invocation fails as it would
for an explicit `--bind`. This fallback applies only to the implicit default
pair: an explicit `--bind` list always keeps the ordinary all-or-nothing
behavior described below.
## OPTIONS
### `--bind ADDR`
Local UDP address to bind, as `ADDR:PORT`, for example `127.0.0.1:2112` or
`[::1]:2112`. Host names are not resolved. Repeatable.
Repeat the option to serve several addresses from one process, in the order
given. Every listener applies the same policy options, but each keeps its
own sessions and tokens, so a session belongs to the address it was opened
on. Binding is all-or-nothing: if any address cannot be bound, none are
served.
A port of `0` selects an unused port per listener, so two such binds get two
different ports.
With no `--bind` at all, see [Description](#description) above for the
default wildcard pair and the address-family fallback it allows.
When reply DSCP/traffic-class marking for IPv4 peers matters, do not configure
one explicit dual-stack `[::]:PORT` listener by itself. On Linux, IPv4 peers
served through that socket are IPv4-mapped and their replies cannot retain the
negotiated marking. Configure the ordinary paired `[::]:PORT` and
`0.0.0.0:PORT` listeners instead; the IPv6 listener is made IPv6-only for that
same-port pair, so each family uses the correct socket option. This is a
configuration workaround, not a reason to force `IPV6_V6ONLY` for every lone
IPv6 listener.
### `--hmac KEY`
HMAC key, taken as the UTF-8 bytes of this argument. 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.
### `--max-sessions COUNT`
Maximum number of simultaneously live sessions **per listener**, not per
process. Once a listener's table is full, a session-creating open to it is
dropped silently; nothing is evicted. Zero refuses every session-creating
open.
### `--max-packet-length BYTES`
Maximum echo datagram size a session may negotiate, in bytes. A longer
requested length is reduced during negotiation, an open whose mandatory
field block would not fit is refused, and inbound echo requests are admitted
against the same limit. This is a resource bound, not an MTU.
### `--min-interval DURATION`
Floor on the send interval a session may negotiate. The negotiated interval
is still capped at a quarter of the idle timeout afterwards, so a
`--min-interval` above that cap is not the value actually negotiated.
A session's reply allowance refills at the shorter of this configured floor
and the interval it actually negotiated. Ordinarily that is the configured
floor itself, since the negotiated interval is normally at least as long —
a 10ms `--min-interval` against a 1s negotiated interval refills every
10ms. The two differ only when the idle-timeout cap above pulled the
negotiated interval below the configured floor, in which case the shorter,
negotiated value is what the allowance actually refills at: a server must
not enforce a cadence it did not advertise to the client. Use `0` for no
time-based throttling.
### `--burst COUNT`
Echo replies one session may have answered before its allowance has to
refill. Use `0` for no allowance at all, which rate-limits every echo
request whatever the interval is.
### `--idle-timeout DURATION`
Release a session after this long without a served or rate-limited echo
request. The deadline runs from the open, and release is silent. Use `0` to
expire a session at the next evaluation; it is not a way to disable expiry.
### `--max-duration DURATION`
Maximum test duration a session may negotiate. A longer request is reduced
to it, and a continuous request is answered with it. Omit this flag for no
maximum; a maximum of zero cannot be expressed, because a negotiated
duration of zero means continuous.
### `--timestamp-allowance MODE`
How many timestamps this server will provide: `dual` (default, honors a
request for send, receive, both, or midpoint timestamps), `single`
(provides at most one timestamp instant; a request for both is negotiated to
midpoint), or `none` (no timestamps at all). The requested clock source is
never changed, only which instants are reported, so a single instant is
still reported once per requested clock.
### `--no-dscp`
Refuse to provide requested traffic-class marking. Any requested DSCP is
negotiated to zero, so the client is told its echo replies will be unmarked,
and they are sent unmarked. The session is not refused.
Durations everywhere in this command accept an integer followed by a unit:
`ms`, `s`, or `m`.
## ENVIRONMENT
Every option above can also be supplied through an environment variable, for
deployments that configure by environment rather than by command line
(containers, orchestrators, unit files). A flag given on the command line
overrides the corresponding variable entirely; an unset variable leaves the
built-in default in place.
| Variable | Option |
| --- | --- |
| `IRTT_SERVER_BIND` | `--bind`, one address only |
| `IRTT_SERVER_HMAC` | `--hmac` |
| `IRTT_SERVER_MAX_SESSIONS` | `--max-sessions` |
| `IRTT_SERVER_MAX_PACKET_LENGTH` | `--max-packet-length` |
| `IRTT_SERVER_MIN_INTERVAL` | `--min-interval` |
| `IRTT_SERVER_BURST` | `--burst` |
| `IRTT_SERVER_IDLE_TIMEOUT` | `--idle-timeout` |
| `IRTT_SERVER_MAX_DURATION` | `--max-duration` |
| `IRTT_SERVER_TIMESTAMP_ALLOWANCE` | `--timestamp-allowance` |
| `IRTT_SERVER_NO_DSCP` | `--no-dscp` |
`IRTT_SERVER_BIND` takes exactly one address, the same as a single `--bind`;
there is no way to request several listeners from the environment. Serving
more than one address needs repeated `--bind` options.
`IRTT_SERVER_NO_DSCP` is the only boolean. `0`, `false`, `no`, `n`, `off` and
an empty value leave traffic-class marking enabled; any other value disables
it, and an unset variable leaves it enabled.
## OUTPUT
Each bound endpoint is printed on startup, once every listener is up, which
also resolves a port of `0`:
```
irtt-server: listening on [::]:2112
irtt-server: listening on 0.0.0.0:2112
```
`Ctrl-C` stops the server gracefully.
## MULTIPLE LISTENERS
Every invocation runs through the same multi-listener path, so one address —
whether from the default pair or a single explicit `--bind` — is an ordinary
set of one rather than a separate mode. The policy options above apply to
every listener, but listeners are otherwise independent: each has its own
sessions and tokens, and `--max-sessions` bounds each listener rather than
the process. If any listener fails while running, the others are shut down
with it rather than leaving a service configured for two families answering
on one.
Wildcard IPv4 and IPv6 listeners may share one port, as the default pair
does.
## SERVER FILL
Echo replies fill their payload according to the negotiated `ServerFill`
mode, which a client requests with `irtt-client`'s `--sfill` (see
`irtt-client(1)`):
- `none`: zero-filled.
- `rand`: random bytes.
- `pattern:HEX`: the given repeating hexadecimal pattern.
Every valid descriptor is honored. A descriptor the server cannot parse, and
a client that expresses no fill preference at all, both get the default
`pattern:69727474`, i.e. the repeating bytes `69 72 74 74` (`irtt`).
## WILDCARD BINDS
An explicit interface address works on every supported system. A wildcard
bind such as `0.0.0.0:2112` or `[::]:2112` reads each request's local
destination from packet metadata and sends that request's reply from the
same address, so a client on a multi-homed host is answered from the
endpoint it actually contacted.
That reply-source selection is implemented on **Linux, macOS, and FreeBSD**.
Elsewhere, a wildcard bind is refused at construction rather than served
from a routing-table-selected source address a client would discard — this
also applies to the zero-argument default, which will fail with a clear
error asking for an explicit `--bind` on such a platform. This correctness
rule is deliberate and is not weakened by the zero-argument default.
## EXAMPLES
Run with the default wildcard listeners on the standard port:
```sh
irtt-server
```
Bind a single explicit address:
```sh
irtt-server --bind 127.0.0.1:2112
```
Through the `irtt-rs` dispatcher:
```sh
irtt-rs server --bind 192.0.2.10:2112
```
Serve two explicit addresses from one process:
```sh
irtt-server \
--bind 0.0.0.0:2112 \
--bind [::]:2112
```
Apply session policy:
```sh
irtt-server \
--bind 192.0.2.10:2112 \
--max-sessions 512 \
--idle-timeout 30s
```
Restrict optional capabilities:
```sh
irtt-server \
--bind 192.0.2.10:2112 \
--timestamp-allowance single \
--no-dscp
```
Require authentication:
```sh
irtt-server --bind 192.0.2.10:2112 --hmac secret
```
## EXIT STATUS
`0` on a clean shutdown (`Ctrl-C`), nonzero if binding fails or a listener
fails while running.
## LIMITATIONS
- No hostname resolution, interface expansion, daemonization, or
configuration file — binds are explicit addresses only.
- Wildcard reply-source selection is unavailable outside Linux, macOS, and
FreeBSD; such platforms must bind explicit addresses.
## SEE ALSO
`irtt-client(1)`, `irtt-tui(1)`, `irtt-rs(1)`, and `irtt-server --help` for
the full option list.
+91
View File
@@ -0,0 +1,91 @@
# irtt-tui
## NAME
irtt-tui - IRTT-compatible terminal UI client
## SYNOPSIS
`irtt-tui` [*OPTIONS*] *[LABEL=]TARGET*...
## DESCRIPTION
`irtt-tui` is a live dashboard over the same probing engine as
`irtt-client(1)`: a graph and status view instead of a printed event stream.
At least one target is required.
Target syntax, multiple targets, and pacing are identical to
`irtt-client(1)` — see that manual for `[LABEL=]TARGET` syntax and the
`--pacing staggered|burst` option. The full set of negotiation flags
(`--interval`, `--length`, `--hmac`, `--clock`, `--tstamp`, `--stats`,
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
well; consult that manual for their meaning.
## CONTINUOUS DEFAULT
Unlike `irtt-client`, **the TUI defaults to continuous mode**
(`--duration 0`): it runs until you quit. Pass an explicit `--duration` for
a finite run:
```sh
irtt-tui <server> --duration 30s
```
## CONTROLS
| Key | Action |
| --- | --- |
| `q`, `Ctrl-C` | Quit |
| `p` | Toggle display pause (probing continues; only redraws stop) |
| `g` | Toggle Graph / Dashboard view |
| `m` | Cycle graph metric |
| `r` | Clear visible graph history |
| `←` / `→` | Pan graph |
| `PageUp` / `PageDown` | Page-pan graph |
| `Home` / `End` | Jump to oldest / jump to live |
| `+` / `=` | Zoom graph in |
| `-` | Zoom graph out |
| `0` | Reset graph window and zoom |
## RETAINED HISTORY
The TUI keeps its own bounded presentation state on top of the statistics
retention described in `irtt-client(1)`:
- Up to 100,000 graph samples per target.
- Up to 80 recent status/log messages.
`r` clears the currently visible graph history and returns the viewport to
live; it does not change the 100,000-sample cap itself. These are
presentation bounds on top of the client's own statistics retention (see
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
total memory is dominated by whichever of the two is larger for your target
count.
A **finite** `--duration` TUI run retains exact statistics samples exactly as
`irtt-client(1)` does in finite mode, so its retained state grows with the
probe count rather than approaching a bound. Unlike `irtt-client`, the TUI
prints **no** memory warning when that estimate gets large: it owns the
alternate screen, and a stderr warning there would be invisible or would
corrupt the display. Size a long finite TUI run from the figures in
`irtt-client(1)` MEASUREMENTS AND MEMORY, or use the continuous default.
## EXIT BEHAVIOR
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
exit status follows the same peer-close and driver-failure rules as
`irtt-client(1)`.
## EXAMPLES
```sh
irtt-tui host.example
irtt-tui eu=host.example
irtt-tui eu=host-a.example us=host-b.example --pacing burst
irtt-tui host.example --duration 30s
```
## SEE ALSO
`irtt-client(1)` for target syntax, pacing, negotiation flags, and
finite/continuous memory behavior; `irtt-server(1)`; `irtt-rs(1)`.
+133 -6
View File
@@ -8,6 +8,18 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
@@ -19,9 +31,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.3"
version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -35,6 +47,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cmov"
version = "0.5.4"
@@ -79,9 +97,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "getrandom"
@@ -126,10 +144,24 @@ dependencies = [
name = "irtt-proto-fuzz"
version = "0.0.0"
dependencies = [
"arbitrary",
"irtt-proto",
"irtt-server",
"libfuzzer-sys",
]
[[package]]
name = "irtt-server"
version = "0.5.2"
dependencies = [
"getrandom",
"irtt-proto",
"nix",
"socket2",
"thiserror",
"tokio",
]
[[package]]
name = "jobserver"
version = "0.1.35"
@@ -166,6 +198,45 @@ dependencies = [
"digest",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -196,6 +267,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -204,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
@@ -233,6 +314,31 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "typenum"
version = "1.20.1"
@@ -244,3 +350,24 @@ name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+12 -1
View File
@@ -8,11 +8,15 @@ edition = "2021"
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
arbitrary = "1.4.2"
libfuzzer-sys = "0.4.13"
[dependencies.irtt-proto]
path = "../crates/irtt-proto"
[dependencies.irtt-server]
path = "../crates/irtt-server"
# Use independent workspace for fuzzers
[workspace]
members = ["."]
@@ -51,3 +55,10 @@ path = "fuzz_targets/decode_varint.rs"
test = false
doc = false
bench = false
[[bin]]
name = "server_core_datagrams"
path = "fuzz_targets/server_core_datagrams.rs"
test = false
doc = false
bench = false
+9 -6
View File
@@ -1,6 +1,7 @@
# `irtt-proto` fuzzing
# `irtt-rs` fuzzing
Coverage-guided fuzzing of `irtt-proto`'s wire decoders via
Coverage-guided fuzzing of `irtt-proto` wire decoders and `irtt-server`'s public
`ServerCore::handle_datagram` API via
[`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz)/libFuzzer.
This project is intentionally isolated from the normal stable/MSRV
@@ -22,14 +23,16 @@ of the root workspace, requires nightly Rust, and is not built by
| `decode_echo_reply` | `decode_echo_reply` against a deterministic, bounded `Params` matrix, unauthenticated and HMAC-authenticated |
| `decode_params` | `Params::decode` and `Params::decode_with_presence`, plus an encode/decode round trip |
| `decode_varint` | `varint::decode_uvarint`/`decode_varint` and their round trip through `encode_uvarint`/`encode_varint` |
| `server_core_datagrams` | A bounded mix of raw datagrams and production-encoded Open/Echo/Close requests against one `ServerCore`; it varies IPv4/IPv6 peers, session configuration, and bounded (up to 128-byte) HMAC keys, authenticates keyed requests, reuses issued tokens, and asserts the session cap |
The invariant every target asserts is: **arbitrary bytes must never panic**.
Returning `Err` is normal and expected; a successful decode is also normal.
Every target bounds its input to 128 KiB (`MAX_INPUT_LEN` in each harness) so
libFuzzer spends its time exploring protocol structure rather than
allocating/copying megabytes — production IRTT datagrams are UDP-sized, well
under this bound.
Every target bounds its input (`MAX_INPUT_LEN` in each harness) so libFuzzer
spends its time exploring protocol structure rather than allocating/copying
megabytes. `server_core_datagrams` additionally caps its sequence length and
each individual datagram and configures the core to the same packet cap, so one
libFuzzer input cannot become a tiny DoS benchmark.
## Running
+186
View File
@@ -0,0 +1,186 @@
#![no_main]
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
time::Duration,
};
use arbitrary::Unstructured;
use irtt_proto::{decode_open_reply, encode_request, Params, RequestToEncode};
use irtt_server::{ServerConfig, ServerCore};
use libfuzzer_sys::fuzz_target;
/// Bounds one libFuzzer input as well as the work it can ask the core to do.
/// These are deliberately much smaller than a production UDP payload: the
/// target is stateful parser/session exploration, not packet-size throughput.
const MAX_INPUT_LEN: usize = 32 * 1024;
const MAX_DATAGRAMS: usize = 16;
const MAX_DATAGRAM_LEN: usize = 2 * 1024;
const MAX_SESSIONS: u8 = 8;
const MAX_HMAC_KEY_LEN: usize = 128;
#[derive(Clone)]
struct FuzzSession {
token: u64,
peer: SocketAddr,
params: Params,
}
fuzz_target!(|data: &[u8]| {
if data.len() > MAX_INPUT_LEN {
return;
}
let mut input = Unstructured::new(data);
let Ok(mut config) = config(&mut input) else {
return;
};
let hmac_key = match input.arbitrary::<bool>() {
Ok(true) => {
let Ok(length) = input.int_in_range(1..=MAX_HMAC_KEY_LEN) else {
return;
};
input.bytes(length).ok()
}
Ok(false) => None,
Err(_) => return,
};
if let Some(key) = hmac_key {
config = config.with_hmac_key(key);
}
let mut core = ServerCore::new(config);
let mut sessions = Vec::new();
for _ in 0..MAX_DATAGRAMS {
let Ok(operation) = input.arbitrary::<u8>() else {
break;
};
match operation % 4 {
// Keep arbitrary-byte admission coverage alongside the stateful
// production-encoded requests below.
0 => raw_datagram(&mut core, &mut input),
1 => open_session(&mut core, &mut input, hmac_key, &mut sessions),
2 => echo_session(&mut core, &mut input, hmac_key, &sessions),
_ => close_session(&mut core, &mut input, hmac_key, &mut sessions),
}
// Arbitrary network input is expected to be rejected or to produce an
// internal error in exceptional host conditions. It must never panic,
// and it may never grow the bounded table past its configured cap.
assert!(core.session_count() <= core.config().max_sessions());
}
});
fn config(input: &mut Unstructured<'_>) -> arbitrary::Result<ServerConfig> {
let max_sessions = usize::from(input.int_in_range(0..=MAX_SESSIONS)?);
Ok(ServerConfig::default()
.with_max_sessions(max_sessions)
.with_max_packet_length(MAX_DATAGRAM_LEN)
.with_min_send_interval(Duration::ZERO))
}
fn raw_datagram(core: &mut ServerCore, input: &mut Unstructured<'_>) {
let (Ok(peer), Ok(length)) = (peer(input), input.int_in_range(0..=MAX_DATAGRAM_LEN)) else {
return;
};
let Ok(packet) = input.bytes(length) else {
return;
};
let _ = core.handle_datagram(peer, packet);
}
fn open_session(
core: &mut ServerCore,
input: &mut Unstructured<'_>,
hmac_key: Option<&[u8]>,
sessions: &mut Vec<FuzzSession>,
) {
let Ok(peer) = peer(input) else {
return;
};
let params = session_params();
let request = encode_request(
RequestToEncode::Open {
params: &params,
no_test: false,
},
hmac_key,
)
.expect("fixed valid fuzz open must encode");
let Ok(Some(reply)) = core.handle_datagram(peer, &request) else {
return;
};
let Ok(reply) = decode_open_reply(reply.bytes(), hmac_key) else {
return;
};
sessions.push(FuzzSession {
token: reply.token,
peer,
params: reply.params,
});
}
fn echo_session(
core: &mut ServerCore,
input: &mut Unstructured<'_>,
hmac_key: Option<&[u8]>,
sessions: &[FuzzSession],
) {
let Some(session) =
sessions.get(usize::from(input.arbitrary::<u8>().unwrap_or(0)) % sessions.len().max(1))
else {
return;
};
let sequence = input.arbitrary().unwrap_or_default();
let request = encode_request(
RequestToEncode::Echo {
token: session.token,
sequence,
params: &session.params,
payload: &[],
},
hmac_key,
)
.expect("fixed valid fuzz echo must encode");
let _ = core.handle_datagram(session.peer, &request);
}
fn close_session(
core: &mut ServerCore,
input: &mut Unstructured<'_>,
hmac_key: Option<&[u8]>,
sessions: &mut Vec<FuzzSession>,
) {
let index = usize::from(input.arbitrary::<u8>().unwrap_or(0)) % sessions.len().max(1);
let Some(session) = sessions.get(index) else {
return;
};
let request = encode_request(
RequestToEncode::Close {
token: session.token,
},
hmac_key,
)
.expect("fixed valid fuzz close must encode");
let _ = core.handle_datagram(session.peer, &request);
sessions.swap_remove(index);
}
fn session_params() -> Params {
Params {
protocol_version: 1,
length: 256,
..Params::default()
}
}
fn peer(input: &mut Unstructured<'_>) -> arbitrary::Result<SocketAddr> {
let port = input.arbitrary()?;
let address = if input.arbitrary()? {
IpAddr::V4(Ipv4Addr::from(input.arbitrary::<[u8; 4]>()?))
} else {
IpAddr::V6(Ipv6Addr::from(input.arbitrary::<[u8; 16]>()?))
};
Ok(SocketAddr::new(address, port))
}