Compare commits

...
105 Commits
Author SHA1 Message Date
Nils Andreas Svee 352cebb5c3 build: enable tui feature in distribution workspace
Include the TUI module in release builds to ensure command-line interface functionality is available in distributed binaries.
2026-06-10 00:59:40 +07:00
Nils Andreas Svee 58fd017d4f build(ci): update cargo-dist to v0.32.0 and revert runner to ubuntu-22.04
Upgrade CI tooling to use cargo-dist v0.32.0 and switch to the ubuntu-22.04 runner to ensure environment compatibility. Pin GitHub Actions to major versions for better maintainability.
2026-06-10 00:47:34 +07:00
Nils Andreas Svee 5efb8a1870 build: bump workspace and crate versions to 0.3.0 2026-06-10 00:44:10 +07:00
Nils Andreas Svee f41fd4a8e7 refactor: simplify lib.rs and update feature gating for cmd modules 2026-06-10 00:41:03 +07:00
Nils Andreas Svee 5de1bf7343 docs: update README with multi-applet usage and installation instructions 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 7e89d11e6e build: configure multi-binary targets and feature-gate dependencies 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 196fc29f76 feat(tui): optimize terminal UI and simplify graph data series 2026-06-09 23:23:51 +07:00
Nils Andreas Svee ded8a5f87d refactor(client): encapsulate session lifecycle and stream output 2026-06-09 23:23:51 +07:00
Nils Andreas Svee 133ff71b72 feat(cli): implement multi-applet binary dispatch and signal handling 2026-06-09 23:23:50 +07:00
Nils Andreas Svee d7cdcc5cee refactor(cli): reorganize project structure into modular commands 2026-06-09 23:23:41 +07:00
Nils Andreas Svee c31ad07d61 feat(irtt-cli): add optional tui output mode 2026-06-09 13:29:00 +07:00
renovate[bot] ece9fd6db0 chore(deps): maintain cargo lockfile 2026-06-09 11:32:29 +07:00
Nils Andreas Svee 9b0cc2b395 test(ci): split netem interop summaries into local and GitHub-specific reports
Create separate markdown files for local browsing with relative links and GitHub Action summaries with artifact notices. This ensures the GitHub Step Summary remains readable and informative without broken links.
2026-06-05 17:13:51 +07:00
renovate[bot] b544df7cc9 fix(deps): update socket dependencies to 0.6.4 2026-06-05 10:43:53 +07:00
renovate[bot] 5646365677 chore(deps): update github actions 2026-06-05 10:43:31 +07:00
Nils Andreas Svee 64be840c1e test(ci): enhance netem interop reporting and loss validation 2026-06-04 09:16:32 +07:00
Nils Andreas Svee b8b0425c4e test(ci): add deterministic Linux netem interop testing 2026-06-03 14:48:51 +07:00
Nils Andreas Svee 3e07732934 test(scripts): add interoperability harness to compare irtt clients
Add a Python-based black-box testing script to validate the Rust client's output and metrics against the upstream Go implementation.
2026-05-25 16:22:46 +07:00
renovate[bot] 61a242c5d4 chore(deps): update github actions 2026-05-25 15:02:49 +07:00
renovate[bot] 6bb4e70df3 chore(deps): maintain cargo lockfile 2026-05-25 01:56:28 +00:00
Nils Andreas Svee c739764476 ci: split actionlint into separate steps for handwritten and generated workflows
Disable ShellCheck globally for the cargo-dist generated release workflow to replace brittle regex ignore patterns with a cleaner configuration.
2026-05-18 13:50:18 +07:00
Nils Andreas Svee 5c96024c4f ci: simplify actionlint ignore patterns for generated release workflow
Update the regex patterns to be more robust and improve the clarity of comments regarding cargo-dist generated workflows.
2026-05-18 13:46:26 +07:00
Nils Andreas Svee 5d653dac13 fix(client): remove duplicate constant exports and definitions 2026-05-18 11:12:21 +07:00
Nils Andreas Svee 6f77d38a7f docs: document client errors and bounds 2026-05-18 10:56:58 +07:00
Nils Andreas Svee 01c10e2e0d docs: document client public API 2026-05-18 01:34:04 +07:00
Nils Andreas Svee 8fd0b803e9 docs: add public crate landing docs 2026-05-18 01:22:08 +07:00
Nils Andreas Svee 614ad5b5cd ci: ignore specific shellcheck warnings in generated release workflow
Suppress SC2086 and SC2129 linting errors for the generated release.yml to prevent build noise while maintaining checks for logic issues.
2026-05-18 01:05:40 +07:00
Nils Andreas Svee 2fcef0adc5 docs: clarify params validation boundaries 2026-05-18 00:36:18 +07:00
Nils Andreas Svee c7a0e5d055 chore: tighten release readiness checks 2026-05-18 00:30:07 +07:00
Nils Andreas Svee e117813578 test: consolidate and prune redundant unit tests
Simplify the test suite by merging similar test cases and removing redundant assertions that verify trivial initialization and basic container logic.
2026-05-18 00:16:51 +07:00
Nils Andreas Svee 3b5336a487 refactor(client): use SignedDuration for one-way delay to support negative values
Allow one-way delay samples to represent negative values caused by wall-clock skew between client and server. Update CLI output and statistics collection to correctly handle and report these signed durations.
2026-05-17 23:55:01 +07:00
Nils Andreas Svee 0b16ee802d fix(stats): support sequence number wraparound in IPDV tracking
Use wrapping arithmetic instead of checked arithmetic to ensure inter-packet delay variation (IPDV) is correctly calculated when sequence numbers wrap from u32::MAX to 0.
2026-05-17 23:11:26 +07:00
Nils Andreas Svee 64da60d480 feat(proto): enforce maximum length for server fill parameter
Move MAX_SERVER_FILL_BYTES to irtt-proto and validate it during decoding to prevent processing oversized parameters.
2026-05-17 22:59:57 +07:00
Nils Andreas Svee 2d6581f31f refactor(client): encapsulate SignedDuration and enhance its API
Hide internal fields to improve type safety and provide helper methods for unit conversions and negative value checks. Fix several feature-gated test compilation issues.
2026-05-17 22:47:16 +07:00
Nils Andreas Svee bec56d4114 refactor(client): use SignedDuration for effective RTT to support negative values
Simplify RttSample by merging signed and unsigned fields into a single SignedDuration for adjusted and effective RTT. This ensures accurate reporting and statistics when server processing time exceeds raw RTT.
2026-05-17 21:30:51 +07:00
Nils Andreas Svee 37de7c96a1 test(client): remove redundant probe/reply tests 2026-05-17 15:22:08 +07:00
Nils Andreas Svee 6c5972aa58 test(client): refactor probes and replies tests into submodules
Split the monolithic `probes_replies.rs` file into logical submodules and expose internal packet processing methods to improve test maintainability and coverage of boundary conditions.
2026-05-17 14:54:59 +07:00
renovate[bot] 55034fc23c chore(deps): update github actions 2026-05-17 14:49:03 +07:00
Nils Andreas Svee bed877760e test: prune client tests that assert private implementation details 2026-05-17 14:48:45 +07:00
renovate[bot] 87e1755a4b chore(deps): pin dependencies 2026-05-16 20:39:57 +00:00
Nils Andreas Svee 39d414d588 test: consolidate formatter and hub policy cases 2026-05-17 00:39:23 +07:00
Nils Andreas Svee 00fd0ebb15 test: consolidate duplicate CLI and client config cases 2026-05-17 00:34:18 +07:00
Nils Andreas Svee ddd420cc21 test: consolidate and prune implementation-coupled tests 2026-05-17 00:28:05 +07:00
Nils Andreas Svee a7288cc612 test(client): extract backend smoke tests into a dedicated module 2026-05-17 00:17:10 +07:00
Nils Andreas Svee fdcdb3b9d7 test(client): remove implementation-coupled client tests 2026-05-16 23:41:11 +07:00
Nils Andreas Svee bd9ed7eb88 test(client): trim implementation-coupled probe tests 2026-05-16 23:41:11 +07:00
Nils Andreas Svee 5d471b77fb refactor(irtt-cli): split stream runtime path 2026-05-13 22:20:51 +07:00
Nils Andreas Svee 61c527eb26 refactor(irtt-cli): rename stream output helper 2026-05-13 22:12:03 +07:00
Nils Andreas Svee f5c35e8142 feat(irtt-cli): note running-only continuous summaries 2026-05-13 22:05:18 +07:00
Nils Andreas Svee 9adab6882d test(cli): add tests for interrupted continuous mode summary 2026-05-13 22:01:56 +07:00
Nils Andreas Svee 67ef912ba0 feat(cli): print summary when continuous mode is interrupted 2026-05-13 22:01:56 +07:00
Nils Andreas Svee f2f5728c72 refactor(cli): rename print_finite_summary to print_final_summary 2026-05-13 22:01:56 +07:00
Nils Andreas Svee c19dac5aef docs(irtt-cli): expand duration flag documentation
Add detailed help text to clarify the difference in statistics handling between finite and continuous test modes.
2026-05-13 21:36:30 +07:00
Nils Andreas Svee 480cb4fa92 warn(irtt-cli): flag large finite stats runs 2026-05-13 20:18:45 +07:00
Nils Andreas Svee ea85384ced docs(irtt-stats): clarify event contribution for rolling_count 2026-05-13 16:58:12 +07:00
Nils Andreas Svee aa6c3f4fa6 refactor(irtt-stats): remove unknown loss field 2026-05-13 16:25:11 +07:00
Nils Andreas Svee dd1b08c51c docs(irtt-stats): enforce public API documentation 2026-05-13 15:26:37 +07:00
Nils Andreas Svee a180388dc6 docs(irtt-stats): Add documentation comments to public-facing types and fields
Add docstrings to clarify the purpose of statistics collection types and describe the units and behaviors of metric fields.
2026-05-13 15:15:41 +07:00
Nils Andreas Svee 19e0e7aab9 refactor(irtt-stats): extract core aggregation module 2026-05-13 14:57:19 +07:00
Nils Andreas Svee c26008a0b6 refactor(irtt-stats): extract event normalization module 2026-05-13 14:53:13 +07:00
Nils Andreas Svee 1469a2d9e8 refactor(irtt-stats): extract rolling events module 2026-05-13 14:44:56 +07:00
Nils Andreas Svee ebc0d46621 refactor(irtt-stats): extract loss stats module 2026-05-13 14:32:12 +07:00
Nils Andreas Svee cc43b43f00 refactor(irtt-stats): extract IPDV tracking module 2026-05-13 14:25:54 +07:00
Nils Andreas Svee 04d5b634a2 refactor(irtt-stats): extract time stats module 2026-05-13 14:16:07 +07:00
Nils Andreas Svee 557c946796 refactor(irtt-stats): split snapshot builders 2026-05-13 14:07:43 +07:00
Nils Andreas Svee bda1d83c27 refactor(irtt-stats): extract rolling event storage 2026-05-13 13:59:54 +07:00
Nils Andreas Svee 820304f6eb refactor(irtt-stats): split unique reply accounting 2026-05-13 13:49:43 +07:00
Nils Andreas Svee f211845645 refactor(irtt-stats): extract IPDV update recording 2026-05-13 13:45:28 +07:00
Nils Andreas Svee 851825822d refactor(irtt-stats): split CoreStats event handlers 2026-05-13 13:40:55 +07:00
Nils Andreas Svee 969d26d0f1 refactor(irtt-stats): extract event normalization boundary 2026-05-13 13:31:27 +07:00
Nils Andreas Svee c1b2486046 refactor(irtt-stats): split reply and IPDV samples 2026-05-13 13:28:08 +07:00
Nils Andreas Svee 90bb29cd75 refactor(irtt-stats): extract IPDV tracker 2026-05-13 13:20:58 +07:00
Nils Andreas Svee 0dd137a59e refactor(irtt-stats): rename WindowEvent to StatsEvent 2026-05-13 13:12:06 +07:00
Nils Andreas Svee 05f9732760 refactor(irtt-stats): rename median mode to sample mode 2026-05-13 12:58:13 +07:00
Nils Andreas Svee ce4b3c37ab fix(irtt-stats): keep RTT IPDV independent from directional timing 2026-05-13 12:57:10 +07:00
Nils Andreas Svee f05b7cceb2 refactor(irtt-stats): improve duration safety by handling negative time offsets 2026-05-13 12:44:35 +07:00
Nils Andreas Svee 0a66f6fb49 refactor(irtt-stats): remove unused push_duration helper 2026-05-13 12:36:51 +07:00
Nils Andreas Svee 5138dee29f refactor(irtt-stats): unify duration statistics into a single TimeStats type
Simplify the statistics model by merging signed and unsigned duration types into a single structure that uses i128 to consistently handle both positive and negative time offsets.
2026-05-13 12:32:47 +07:00
Nils Andreas Svee 542e67bf74 refactor(irtt-stats): consolidate snapshot types and rename summary to snapshot 2026-05-13 12:21:51 +07:00
Nils Andreas Svee 197903fb8e test(irtt-stats): rename internal date-time helper and update test case name
Rename system_time_ns to unix_time_ns_after_epoch for better clarity on output behavior and update a continuous mode test case name to more accurately reflect its purpose.
2026-05-13 12:07:51 +07:00
Nils Andreas Svee adfe8e7a6b test(irtt-stats): move integration tests to dedicated test module 2026-05-13 11:59:06 +07:00
Nils Andreas Svee 72470bd196 test(client): update and expand DSCP negotiation test coverage 2026-05-13 00:37:54 +07:00
Nils Andreas Svee fec14b2c7c feat(session): restrict DSCP negotiation to allow only disabling (reset to 0) 2026-05-13 00:37:53 +07:00
Nils Andreas Svee 3dfca1ab5e refactor(session): implement Display and add documentation for NegotiationRestriction 2026-05-13 00:37:45 +07:00
Nils Andreas Svee cbe91f2fe0 feat(client): track and report specific negotiation restrictions
Replace basic validation with a detailed negotiation process that records specific parameter changes. This allows the client to provide better feedback when a server restricts requested session parameters like duration or DSCP.
2026-05-12 15:18:02 +07:00
Nils Andreas Svee a7e10358fd test(client): use fallible packet length helpers in integration tests 2026-05-12 13:05:45 +07:00
Nils Andreas Svee 4549d95d0a test(client): update test helpers to handle fallible packet length calculations 2026-05-12 13:05:45 +07:00
Nils Andreas Svee 1b79c864d4 refactor(client): propagate ProtoError from recv_buffer_size calculation 2026-05-12 13:05:45 +07:00
Nils Andreas Svee 20a1d9dc8e refactor(proto): make echo_packet_len return Result to handle negative lengths 2026-05-12 13:05:45 +07:00
Nils Andreas Svee b508a94198 build: bump version to 0.2.0 2026-05-11 15:11:58 +07:00
Nils Andreas Svee 4ed09e2209 ci: add cargo-dist for automated release management
Introduce cargo-dist workflows and configuration to automate binary building and GitHub Release creation across multiple platforms.
2026-05-11 15:10:15 +07:00
Nils Andreas Svee e2a9d68843 ci: add actions read permission to OSV-Scanner workflow
Grant necessary read access to actions to ensure the vulnerability scanner can evaluate the execution environment.
2026-05-11 15:01:05 +07:00
renovate[bot] c04e8f7084 chore(deps): maintain cargo lockfile 2026-05-11 08:00:31 +00:00
renovate[bot] 323e5f7f3d chore(deps): update github actions 2026-05-11 14:58:04 +07:00
Nils Andreas Svee 7ad83ca1f6 refactor: remove logical_seq from client events, session tracking and stats usage
It was an attempt at handling integer wraparound more cleanly keeping in mind that we have a continuous mode,
and so we can more easily reach seq > u32::MAX

However after running the numbers, if we run with a interval of 10ms, it's still gonna take ~497 days before we hit integer overflow, so for now it doesn't seem worth to have this extra logic to handle something that'll in 99.9% of cases be purely an edge case

Keeping in mind autorate usage, in sqm-autorate-rust each send loop is 500 ms by default, and the sleep is then 500 ms / 5 reflectors, so our interval is 100 ms. that means it'll take 4970 days or ~13.6 years to reach the limit. Not worth dealing with IMO
2026-05-11 14:56:07 +07:00
Nils Andreas Svee 8ef53c999a build: optimize release profile for binary size and performance
Enable LTO, single codegen units, and symbol stripping to reduce binary footprint and improve execution efficiency.
2026-05-11 13:27:50 +07:00
Nils Andreas Svee 21bc7805f7 refactor: mark public enums as non-exhaustive to improve API stability
Apply the #[non-exhaustive] attribute to ClientError and WarningKind to prevent breaking changes when adding new variants. Handle the catch-all case in the CLI formatter.
2026-05-11 12:33:40 +07:00
Nils Andreas Svee 8d1d0f50de style(client): reformat imports in test files
Collapse multi-line import statements into single lines to improve code legibility and maintain consistent formatting across test suites.
2026-05-11 10:13:06 +07:00
Nils Andreas Svee d6c6e60742 refactor(client): expose len methods for map structures 2026-05-11 10:10:24 +07:00
Nils Andreas Svee 1cdd100473 refactor(irtt-stats): remove redundant process_with_update method
Simplify the StatsCollector API by removing an unnecessary indirection that performed the same logic as the main process method.
2026-05-11 10:09:13 +07:00
Nils Andreas Svee 80fda92231 fix(irtt-stats): use event timestamp for warnings instead of current time
Ensure warning statistics reflect the actual time the event occurred rather than the moment the event was processed.
2026-05-11 09:55:11 +07:00
Nils Andreas Svee 5f6bff5dcb feat(client): add timestamp to ClientEvent::Warning
Include a timestamp when reporting warnings to allow consumers like the machine-readable formatter to provide temporal context for troubleshooting.
2026-05-11 09:50:16 +07:00
Nils Andreas Svee 1a04f44bb5 refactor(client): remove redundant timestamp parameter from poll_timeouts
Use internal monotonic time by default and introduce poll_timeouts_at for callers requiring a specific sampled Instant. This simplifies the API for standard usage while maintaining flexibility for external event loops.
2026-05-11 09:33:55 +07:00
Nils Andreas Svee d8b580a19c refactor(client): remove redundant timestamp parameter from close method 2026-05-10 23:47:46 +07:00
Nils Andreas Svee 023e85741a refactor(client): remove redundant timestamp parameter from open method 2026-05-10 23:37:35 +07:00
93 changed files with 11526 additions and 6752 deletions

No files matched your search

+19 -5
View File
@@ -23,13 +23,27 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
- 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/release.yml
shellcheck: false
zizmor:
name: zizmor
runs-on: ubuntu-latest
@@ -42,11 +56,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
persona: regular
+43 -4
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -44,7 +44,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -63,6 +63,38 @@ jobs:
--locked \
-- -D warnings
- name: Run clippy without default features
run: |
cargo clippy \
--workspace \
--all-targets \
--no-default-features \
--locked \
-- -D warnings
check:
name: cargo check
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
cache-key: check
- name: Run check without default features
run: |
cargo check \
--workspace \
--no-default-features \
--locked
test:
name: cargo test
runs-on: ubuntu-latest
@@ -70,7 +102,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -87,6 +119,13 @@ jobs:
--all-features \
--locked
- name: Run tests without default features
run: |
cargo test \
--workspace \
--no-default-features \
--locked
docs:
name: cargo doc
runs-on: ubuntu-latest
@@ -97,7 +136,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
+75
View File
@@ -0,0 +1,75 @@
name: Netem Interop
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
GOBIN: ${{ github.workspace }}/.tools/bin
IRTT_UPSTREAM_VERSION: v0.9.1
jobs:
netem:
name: netem interop
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
cache-key: interop-netem
- name: Check host tools
run: |
ip -V
tc -V
go version
- name: Install upstream irtt
run: |
mkdir -p "$GOBIN"
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
"$GOBIN/irtt" version
- name: Build irtt-rs CLI
run: cargo build -p irtt-cli --locked
- name: Run netem interop comparisons
run: |
python3 scripts/run_netem_interop_ci.py \
--all \
--skip-build \
--upstream-irtt "$GOBIN/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli"
- name: Append netem interop summary
if: always()
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"
else
echo "No netem interop github-step-summary.md found." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload netem interop artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: interop-netem
path: target/interop-netem
+7 -6
View File
@@ -13,24 +13,25 @@ name: OSV-Scanner
on:
pull_request:
branches: [ "main" ]
branches: ["main"]
merge_group:
branches: [ "main" ]
branches: ["main"]
schedule:
- cron: '45 8 * * 1'
- cron: "45 8 * * 1"
push:
branches: [ "main" ]
branches: ["main"]
permissions:
# Require writing security events to upload SARIF file to security tab
security-events: write
# Read commit contents
contents: read
actions: read
jobs:
scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
# Example of specifying custom arguments
scan-args: |-
@@ -39,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@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
with:
# Example of specifying custom arguments
scan-args: |-
+296
View File
@@ -0,0 +1,296 @@
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v8
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
# Write and read notes from a file to avoid quoting breaking things
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
announce:
needs:
- plan
- host
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
submodules: recursive
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
exit 0
- name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
if: always()
with:
sarif_file: rust-clippy-results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
+20 -5
View File
@@ -38,12 +38,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Run cargo-deny
uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v2
uses: EmbarkStudios/cargo-deny-action@bb137d7af7e4fb67e5f82a49c4fce4fad40782fe # v2
with:
command: check
arguments: --all-features
@@ -56,9 +56,24 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Run actionlint
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
- 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/release.yml
shellcheck: false
Generated
+528 -23
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anstream"
version = "1.0.0"
@@ -38,7 +44,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -49,14 +55,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "bitflags"
version = "2.11.1"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
@@ -76,6 +82,21 @@ dependencies = [
"objc2",
]
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -130,9 +151,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmov"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
@@ -140,6 +161,20 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -147,10 +182,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "crypto-common"
version = "0.2.1"
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags",
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
@@ -163,7 +223,7 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -175,6 +235,40 @@ dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.11.3"
@@ -199,6 +293,45 @@ dependencies = [
"objc2",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -223,20 +356,50 @@ dependencies = [
"typenum",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "irtt-cli"
version = "0.1.1"
version = "0.3.0"
dependencies = [
"clap",
"crossterm",
"ctrlc",
"irtt-client",
"irtt-proto",
"irtt-stats",
"ratatui",
]
[[package]]
name = "irtt-client"
version = "0.1.1"
version = "0.3.0"
dependencies = [
"irtt-proto",
"libc",
@@ -246,7 +409,7 @@ dependencies = [
[[package]]
name = "irtt-proto"
version = "0.1.1"
version = "0.3.0"
dependencies = [
"hmac",
"md-5",
@@ -256,7 +419,7 @@ dependencies = [
[[package]]
name = "irtt-stats"
version = "0.1.1"
version = "0.3.0"
dependencies = [
"irtt-client",
]
@@ -267,12 +430,57 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown",
]
[[package]]
name = "md-5"
version = "0.11.0"
@@ -284,10 +492,22 @@ dependencies = [
]
[[package]]
name = "nix"
version = "0.31.2"
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
@@ -316,6 +536,35 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -335,21 +584,147 @@ dependencies = [
]
[[package]]
name = "socket2"
version = "0.6.3"
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"windows-sys",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -389,9 +764,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.20.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
@@ -399,18 +774,84 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[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.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -419,3 +860,67 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+15 -3
View File
@@ -16,6 +16,18 @@ homepage = "https://github.com/Lochnair/irtt-rs"
readme = "README.md"
[workspace.dependencies]
irtt-proto = { version = "0.1.1", path = "crates/irtt-proto" }
irtt-client = { version = "0.1.1", path = "crates/irtt-client" }
irtt-stats = { version = "0.1.1", path = "crates/irtt-stats" }
irtt-proto = { version = "0.3.0", path = "crates/irtt-proto" }
irtt-client = { version = "0.3.0", path = "crates/irtt-client" }
irtt-stats = { version = "0.3.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"
+37 -3
View File
@@ -10,13 +10,17 @@ It is not the upstream IRTT project. For the original implementation, protocol b
This repository currently provides:
- `irtt-cli`, a command-line IRTT-compatible client
- `irtt-rs`, the canonical multi-applet binary
- `irtt-cli`, a stream/text IRTT-compatible client applet
- `irtt-tui`, an optional terminal UI applet when built with the `tui` feature
- `irtt-client`, a Rust library for running client sessions and consuming events
- finite and continuous probe runs
- human, simple, machine-readable, and RTT-only output modes
- human, simple, machine-readable, and RTT-only stream output modes
- optional local summary statistics
Server support is not implemented.
The installed binary is intentionally not named `irtt` to avoid conflicting or
confusing overlap with upstream IRTT.
## Install
@@ -30,12 +34,24 @@ cd irtt-rs
cargo install --path crates/irtt-cli
```
The default install provides the normal client-oriented binaries, including
`irtt-rs` and `irtt-cli`. To install the TUI applet as a separate binary target:
```sh
cargo install --path crates/irtt-cli --features tui
```
Or run it directly without installing:
```sh
cargo run -p irtt-cli -- <server>
```
For space-sensitive packaging, such as OpenWrt packages, distributors can ship
only `irtt-rs` and symlink or hardlink applet names such as `irtt-cli`,
`irtt-tui`, and future server applet names to it. Applet dispatch is based on
the invoked binary name.
## CLI usage
Basic test:
@@ -65,10 +81,22 @@ irtt-cli <server> --output machine
irtt-cli <server> --output rtt-us
```
With the optional TUI feature, `irtt-tui` opens a live cumulative dashboard for
interactive probing. It defaults to continuous probing, equivalent to
`--duration 0`. Quit with `q` or `Ctrl-C`; the client will drain and close the
session gracefully.
```sh
irtt-tui <server>
irtt-tui <server> --duration 30s
cargo run -p irtt-cli --features tui --bin irtt-tui -- <server>
```
For available options:
```sh
irtt-cli --help
irtt-tui --help
```
## Machine output
@@ -84,10 +112,16 @@ irtt-cli <server> --duration 0 --interval 250ms --output machine
Each line represents one client event and starts with an `event` field, for example:
```text
event=echo_reply seq=4 logical_seq=4 remote=203.0.113.10:2112 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_processing_us=300 dscp=0 ecn=0 kernel_rx_ns=none
event=echo_reply seq=4 remote=203.0.113.10:2112 client_send_wall_ns=1760000000000000000 client_receive_wall_ns=1760000000012400000 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_receive_wall_ns=1760000000006100000 server_receive_mono_ns=5000006100000 server_send_wall_ns=1760000000006400000 server_send_mono_ns=5000006400000 server_processing_us=300 client_to_server_us=6100 server_to_client_us=6000 server_received_count=5 server_received_window=0x1f dscp=0 ecn=0 kernel_rx_ns=none
```
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
RTT fields are reported in microseconds. `raw_rtt_us` is the measured client
send-to-receive RTT. `effective_rtt_us` and `adjusted_rtt_us` are signed and may
be negative when server processing exceeds the raw RTT or timing correction
produces a negative adjusted value. When one-way delay fields are present,
`client_to_server_us` and `server_to_client_us` are also signed microseconds and
may be negative due to wall-clock skew.
For consumers that only need RTT values, use:
+26 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-cli"
version = "0.1.1"
version = "0.3.0"
description = "Command-line IRTT-compatible client"
edition.workspace = true
license.workspace = true
@@ -11,13 +11,34 @@ readme.workspace = true
keywords = ["irtt", "networking", "latency", "rtt"]
categories = ["command-line-utilities", "network-programming"]
[lib]
doc = false
[[bin]]
name = "irtt-rs"
path = "src/main.rs"
[[bin]]
name = "irtt-cli"
path = "src/main.rs"
required-features = ["client"]
[[bin]]
name = "irtt-tui"
path = "src/main.rs"
required-features = ["tui"]
[features]
default = ["stats"]
stats = ["dep:irtt-stats"]
default = ["client", "stats"]
client = ["dep:irtt-client", "dep:irtt-proto"]
stats = ["client", "dep:irtt-stats"]
tui = ["client", "stats", "dep:ratatui", "dep:crossterm"]
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
crossterm = { version = "0.28.1", optional = true }
ctrlc = "3.5.2"
irtt-client.workspace = true
irtt-proto.workspace = true
irtt-client = { workspace = true, optional = true }
irtt-proto = { workspace = true, optional = true }
irtt-stats = { workspace = true, optional = true }
ratatui = { version = "0.29.0", optional = true }
+85
View File
@@ -0,0 +1,85 @@
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestedApplet {
Client,
Tui,
Server,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppletAvailability {
Client,
Tui,
Server,
None,
}
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
match Path::new(argv0)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(argv0)
{
"irtt-cli" => Some(RequestedApplet::Client),
"irtt-tui" => Some(RequestedApplet::Tui),
"irttd" | "irtt-server" => Some(RequestedApplet::Server),
"irtt-rs" => None,
_ => None,
}
}
pub const fn default_applet_for_features(
client_enabled: bool,
tui_enabled: bool,
server_enabled: bool,
) -> AppletAvailability {
if client_enabled {
AppletAvailability::Client
} else if tui_enabled {
AppletAvailability::Tui
} else if server_enabled {
AppletAvailability::Server
} else {
AppletAvailability::None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applet_detection_maps_binary_names() {
assert_eq!(
detect_applet_from_argv0("/usr/bin/irtt-cli"),
Some(RequestedApplet::Client)
);
assert_eq!(
detect_applet_from_argv0("irtt-tui"),
Some(RequestedApplet::Tui)
);
assert_eq!(
detect_applet_from_argv0("irtt-server"),
Some(RequestedApplet::Server)
);
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
assert_eq!(detect_applet_from_argv0("custom-name"), None);
}
#[test]
fn default_applet_prefers_client_then_tui() {
assert_eq!(
default_applet_for_features(true, true, false),
AppletAvailability::Client
);
assert_eq!(
default_applet_for_features(false, true, false),
AppletAvailability::Tui
);
assert_eq!(
default_applet_for_features(false, false, false),
AppletAvailability::None
);
}
}
+136
View File
@@ -0,0 +1,136 @@
use std::time::Duration;
use clap::{Parser, ValueEnum};
use irtt_client::ClientConfig;
use crate::shared::client::{parse_test_duration, CommonClientArgs, TimestampArg};
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Parser)]
#[command(name = "irtt-cli", about = "Minimal IRTT-compatible stream client")]
pub struct ClientArgs {
/// Server address or host, with optional port.
pub server: String,
#[arg(
long,
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."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputMode::Human)]
pub output: OutputMode,
/// Include extra fields in human output.
#[arg(long)]
pub verbose: bool,
}
impl ClientArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for ClientArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputMode {
/// Readable terminal output with a final summary.
Human,
/// Parseable full event fields.
Machine,
/// Simple key=value-ish event stream.
Simple,
/// RTT microseconds only.
RttUs,
}
impl OutputMode {
pub fn prints_summary(self) -> bool {
matches!(self, Self::Human)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
use irtt_client::NegotiationPolicy;
use irtt_proto::{Clock, ReceivedStats, StampAt};
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn client_parser_keeps_finite_default_duration() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
assert_eq!(args.interval, Duration::from_secs(1));
assert_eq!(args.output, OutputMode::Human);
assert!(!args.is_continuous());
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
let config = args.to_client_config();
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
assert_eq!(config.received_stats, ReceivedStats::Both);
assert_eq!(config.stamp_at, StampAt::Both);
assert_eq!(config.clock, Clock::Both);
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
}
#[test]
fn client_parser_accepts_stream_outputs_and_rejects_tui_output() {
for output in ["human", "simple", "machine", "rtt-us"] {
assert!(parse(&["--output", output, "127.0.0.1:2112"]).is_ok());
}
assert!(parse(&["--output", "tui", "127.0.0.1:2112"]).is_err());
}
#[test]
fn client_help_lists_shared_protocol_options() {
let help = ClientArgs::command().render_help().to_string();
assert!(help.contains("--output <OUTPUT>"));
assert!(help.contains("--tstamp <MODE>"));
assert!(help.contains("--stats <STATS>"));
assert!(help.contains("--sfill <STRING>"));
assert!(help.contains("--dscp <0..=63>"));
assert!(help.contains("--ttl <1..=255>"));
assert!(help.contains("--loose"));
}
#[test]
fn output_mode_summary_policy_is_human_only() {
assert!(OutputMode::Human.prints_summary());
assert!(!OutputMode::Simple.prints_summary());
assert!(!OutputMode::Machine.prints_summary());
assert!(!OutputMode::RttUs.prints_summary());
}
}
+12
View File
@@ -0,0 +1,12 @@
pub mod args;
pub mod output;
pub mod run;
#[cfg(feature = "stats")]
pub mod summary;
pub use args::{ClientArgs, OutputMode};
pub use output::{
format_event, format_human_event, format_human_event_with_options, HumanEventStats,
HumanIpdvPair, HumanOutputOptions,
};
pub use run::run_stream;
+656
View File
@@ -0,0 +1,656 @@
use std::{
fmt::Write as _,
net::SocketAddr,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use irtt_client::{
ClientEvent, NegotiatedParams, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration, WarningKind,
};
use super::args::OutputMode;
pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option<String> {
if matches!(event, ClientEvent::EchoSent { .. }) {
return None;
}
match mode {
OutputMode::RttUs => format_rtt_us(event),
OutputMode::Human => Some(format_human_event(event, None)),
OutputMode::Machine => Some(format_machine(event)),
OutputMode::Simple => Some(format_simple(event)),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HumanEventStats {
pub contributed_sample: bool,
pub ipdv_pairs: Vec<HumanIpdvPair>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HumanIpdvPair {
pub previous_seq: u32,
pub current_seq: u32,
pub rtt_ipdv: Duration,
pub send_ipdv: Option<Duration>,
pub receive_ipdv: Option<Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HumanOutputOptions {
pub verbose: bool,
}
#[cfg(feature = "stats")]
impl From<irtt_stats::EventStatsUpdate> for HumanEventStats {
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
Self {
contributed_sample: value.contributed_sample,
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
}
}
}
#[cfg(feature = "stats")]
impl From<irtt_stats::IpdvPairUpdate> for HumanIpdvPair {
fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
Self {
previous_seq: value.previous_seq,
current_seq: value.current_seq,
rtt_ipdv: value.rtt_ipdv,
send_ipdv: value.send_ipdv,
receive_ipdv: value.receive_ipdv,
}
}
}
pub fn format_human_event(event: &ClientEvent, stats: Option<HumanEventStats>) -> String {
format_human_event_with_options(event, stats, HumanOutputOptions::default())
}
pub fn format_human_event_with_options(
event: &ClientEvent,
stats: Option<HumanEventStats>,
options: HumanOutputOptions,
) -> String {
let stats = stats.as_ref();
match event {
ClientEvent::SessionStarted { remote, token, .. } => {
format!("session started remote={remote} token={token:#x}")
}
ClientEvent::NoTestCompleted { remote, .. } => {
format!("no-test completed remote={remote}")
}
ClientEvent::SessionClosed { remote, token, .. } => {
format!("session closed remote={remote} token={token:#x}")
}
ClientEvent::EchoSent { .. } => String::new(),
ClientEvent::EchoReply {
seq,
rtt,
server_timing,
one_way,
received_stats,
..
} => {
let mut out = format!("seq={seq}");
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
write_human_one_way(&mut out, *one_way);
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
write!(out, " proc={}", format_duration(processing)).unwrap();
}
if options.verbose {
write_human_received_stats(&mut out, *received_stats);
}
out
}
ClientEvent::EchoLoss { seq, .. } => {
format!("loss seq={seq}")
}
ClientEvent::DuplicateReply { seq, remote, .. } => {
format!("duplicate seq={seq} remote={remote}")
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
rtt,
one_way,
received_stats,
..
} => {
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
if let Some(rtt) = rtt {
write!(out, " rtt={}", format_signed_duration(rtt.effective)).unwrap();
write_human_one_way(&mut out, *one_way);
write!(out, " ipdv={}", format_human_ipdv(stats, *seq)).unwrap();
}
write_human_received_stats(&mut out, *received_stats);
out
}
ClientEvent::Warning { kind, message, .. } => {
format!("warning kind={} message={message}", warning_kind(*kind))
}
}
}
fn format_rtt_us(event: &ClientEvent) -> Option<String> {
match event {
ClientEvent::EchoReply { rtt, .. } => Some(signed_duration_us(rtt.effective).to_string()),
_ => None,
}
}
fn format_machine(event: &ClientEvent) -> String {
let mut out = String::new();
match event {
ClientEvent::SessionStarted {
remote,
token,
negotiated,
at,
} => {
write_common(&mut out, "session_started");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "event_wall_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::NoTestCompleted {
remote,
negotiated,
at,
} => {
write_common(&mut out, "no_test_completed");
write_remote(&mut out, *remote);
write_wall(&mut out, "event_wall_ns", at.wall);
write_negotiated(&mut out, negotiated);
}
ClientEvent::SessionClosed { remote, token, at } => {
write_common(&mut out, "session_closed");
write_remote(&mut out, *remote);
write_token(&mut out, *token);
write_wall(&mut out, "event_wall_ns", at.wall);
}
ClientEvent::EchoSent { .. } => {}
ClientEvent::EchoReply {
seq,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes: _,
packet_meta,
} => {
write_common(&mut out, "echo_reply");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
write_rtt(&mut out, rtt);
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
}
ClientEvent::EchoLoss { seq, sent_at, .. } => {
write_common(&mut out, "loss");
write_seq(&mut out, *seq);
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
out.push_str(" warning=loss");
}
ClientEvent::DuplicateReply {
seq,
remote,
received_at,
bytes: _,
} => {
write_common(&mut out, "duplicate");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
out.push_str(" warning=duplicate");
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes: _,
packet_meta,
} => {
write_common(&mut out, "late");
write_seq(&mut out, *seq);
write_remote(&mut out, *remote);
write!(out, " highest_seen={highest_seen}").unwrap();
if let Some(sent_at) = sent_at {
write_wall(&mut out, "client_send_wall_ns", sent_at.wall);
}
write_wall(&mut out, "client_receive_wall_ns", received_at.wall);
if let Some(rtt) = rtt {
write_rtt(&mut out, rtt);
}
write_server_timing(&mut out, *server_timing);
write_one_way(&mut out, *one_way);
write_received_stats(&mut out, *received_stats);
write_packet_meta(&mut out, *packet_meta);
out.push_str(" warning=late");
}
ClientEvent::Warning { kind, message, at } => {
write_common(&mut out, "warning");
write_wall(&mut out, "at", at.wall);
write!(
out,
" warning_kind={} message={}",
warning_kind(*kind),
escape_value(message)
)
.unwrap();
}
}
out
}
fn format_simple(event: &ClientEvent) -> String {
match event {
ClientEvent::SessionStarted { remote, token, .. } => {
format!("session started remote={remote} token={token:#x}")
}
ClientEvent::NoTestCompleted { remote, .. } => {
format!("no-test completed remote={remote}")
}
ClientEvent::SessionClosed { remote, token, .. } => {
format!("session closed remote={remote} token={token:#x}")
}
ClientEvent::EchoSent { .. } => String::new(),
ClientEvent::EchoReply {
seq,
remote,
rtt,
server_timing,
bytes: _,
..
} => {
let mut out = format!(
"reply seq={seq} remote={remote} rtt_us={}",
signed_duration_us(rtt.effective)
);
if rtt.adjusted.is_some() {
write!(out, " raw_rtt_us={}", duration_us(rtt.raw)).unwrap();
}
if let Some(processing) = server_timing.and_then(|timing| timing.processing) {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
out
}
ClientEvent::EchoLoss { seq, .. } => {
format!("loss seq={seq}")
}
ClientEvent::DuplicateReply { seq, remote, .. } => {
format!("duplicate seq={seq} remote={remote}")
}
ClientEvent::LateReply {
seq,
highest_seen,
remote,
rtt,
..
} => {
let mut out = format!("late seq={seq} highest_seen={highest_seen} remote={remote}",);
if let Some(rtt) = rtt {
write!(out, " rtt_us={}", signed_duration_us(rtt.effective)).unwrap();
}
out
}
ClientEvent::Warning { kind, message, .. } => {
format!("warning kind={} message={message}", warning_kind(*kind))
}
}
}
fn write_common(out: &mut String, event: &str) {
write!(out, "event={event}").unwrap();
}
fn write_seq(out: &mut String, seq: u32) {
write!(out, " seq={seq}").unwrap();
}
fn write_remote(out: &mut String, remote: SocketAddr) {
write!(out, " remote={remote}").unwrap();
}
fn write_token(out: &mut String, token: u64) {
write!(out, " token={token:#x}").unwrap();
}
fn write_wall(out: &mut String, key: &str, wall: SystemTime) {
if let Ok(duration) = wall.duration_since(UNIX_EPOCH) {
write!(out, " {key}={}", duration.as_nanos()).unwrap();
}
}
fn write_negotiated(out: &mut String, negotiated: &NegotiatedParams) {
write!(
out,
" duration_ns={} interval_ns={} payload_length={}",
negotiated.params.duration_ns, negotiated.params.interval_ns, negotiated.params.length
)
.unwrap();
}
fn write_rtt(out: &mut String, rtt: &RttSample) {
write!(
out,
" raw_rtt_us={} effective_rtt_us={}",
duration_us(rtt.raw),
signed_duration_us(rtt.effective)
)
.unwrap();
if let Some(adjusted) = rtt.adjusted {
write!(out, " adjusted_rtt_us={}", signed_duration_us(adjusted)).unwrap();
}
}
fn write_server_timing(out: &mut String, timing: Option<ServerTiming>) {
if let Some(timing) = timing {
write_optional_i64(out, "server_receive_wall_ns", timing.receive_wall_ns);
write_optional_i64(out, "server_receive_mono_ns", timing.receive_mono_ns);
write_optional_i64(out, "server_send_wall_ns", timing.send_wall_ns);
write_optional_i64(out, "server_send_mono_ns", timing.send_mono_ns);
write_optional_i64(out, "server_midpoint_wall_ns", timing.midpoint_wall_ns);
write_optional_i64(out, "server_midpoint_mono_ns", timing.midpoint_mono_ns);
if let Some(processing) = timing.processing {
write!(out, " server_processing_us={}", duration_us(processing)).unwrap();
}
}
}
fn write_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
if let Some(one_way) = one_way {
if let Some(value) = one_way.client_to_server {
write!(out, " client_to_server_us={}", signed_duration_us(value)).unwrap();
}
if let Some(value) = one_way.server_to_client {
write!(out, " server_to_client_us={}", signed_duration_us(value)).unwrap();
}
}
}
fn write_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
if let Some(stats) = stats {
if let Some(count) = stats.count {
write!(out, " server_received_count={count}").unwrap();
}
if let Some(window) = stats.window {
write!(out, " server_received_window={window:#x}").unwrap();
}
}
}
fn write_packet_meta(out: &mut String, meta: PacketMeta) {
write_optional_u8(out, "traffic_class", meta.traffic_class);
write_optional_u8(out, "dscp", meta.dscp);
write_optional_u8(out, "ecn", meta.ecn);
match meta.kernel_rx_timestamp {
Some(timestamp) => write_wall(out, "kernel_rx_ns", timestamp),
None => write!(out, " kernel_rx_ns=none").unwrap(),
}
}
fn write_human_one_way(out: &mut String, one_way: Option<OneWayDelaySample>) {
match one_way {
Some(one_way) => {
write!(
out,
" rd={} sd={}",
format_optional_signed_duration(one_way.server_to_client),
format_optional_signed_duration(one_way.client_to_server)
)
.unwrap();
}
None => out.push_str(" rd=n/a sd=n/a"),
}
}
fn write_human_received_stats(out: &mut String, stats: Option<ReceivedStatsSample>) {
if let Some(stats) = stats {
if let Some(count) = stats.count {
write!(out, " server_received={count}").unwrap();
}
if let Some(window) = stats.window {
write!(out, " server_window={window:#x}").unwrap();
}
}
}
fn format_human_ipdv(stats: Option<&HumanEventStats>, seq: u32) -> String {
let Some(stats) = stats else {
return "n/a".to_owned();
};
let pair = stats
.ipdv_pairs
.iter()
.find(|pair| pair.current_seq == seq)
.or_else(|| {
stats
.ipdv_pairs
.iter()
.find(|pair| pair.previous_seq == seq)
});
pair.map(|pair| format_duration(pair.rtt_ipdv))
.unwrap_or_else(|| "n/a".to_owned())
}
fn write_optional_u8(out: &mut String, key: &str, value: Option<u8>) {
match value {
Some(value) => write!(out, " {key}={value}").unwrap(),
None => write!(out, " {key}=none").unwrap(),
}
}
fn write_optional_i64(out: &mut String, key: &str, value: Option<i64>) {
if let Some(value) = value {
write!(out, " {key}={value}").unwrap();
}
}
fn duration_us(duration: Duration) -> u128 {
duration.as_micros()
}
fn signed_duration_us(duration: SignedDuration) -> i128 {
duration.as_micros()
}
fn format_optional_signed_duration(duration: Option<SignedDuration>) -> String {
duration
.map(format_signed_duration)
.unwrap_or_else(|| "n/a".to_owned())
}
fn format_duration(duration: Duration) -> String {
format_ns(duration.as_nanos() as f64)
}
fn format_signed_duration(duration: SignedDuration) -> String {
format_signed_ns(duration.as_nanos() as f64)
}
fn format_signed_ns(ns: f64) -> String {
if ns < 0.0 {
format!("-{}", format_ns(-ns))
} else {
format_ns(ns)
}
}
fn format_ns(ns: f64) -> String {
if ns < 1_000.0 {
format!("{ns:.0}ns")
} else if ns < 1_000_000.0 {
format!("{:.1}µs", ns / 1_000.0)
} else if ns < 1_000_000_000.0 {
format!("{:.1}ms", ns / 1_000_000.0)
} else {
format!("{:.3}s", ns / 1_000_000_000.0)
}
}
fn warning_kind(kind: WarningKind) -> &'static str {
match kind {
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
WarningKind::WrongToken => "wrong_token",
WarningKind::UntrackedReply => "untracked_reply",
_ => "unknown",
}
}
fn escape_value(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace(' ', "\\s")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
use irtt_client::{ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event() -> ClientEvent {
ClientEvent::EchoReply {
seq: 7,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at: test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500)),
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: Some(1_000),
receive_mono_ns: Some(2_000),
send_wall_ns: Some(301_000),
send_mono_ns: Some(302_000),
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_micros(300)),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(400_000)),
server_to_client: Some(SignedDuration::from_nanos(500_000)),
}),
received_stats: Some(ReceivedStatsSample {
count: Some(9),
window: Some(0x7),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn rtt_us_prints_only_effective_reply_rtt() {
assert_eq!(
format_event(&reply_event(), OutputMode::RttUs),
Some("1200".to_owned())
);
assert_eq!(
format_event(
&ClientEvent::Warning {
kind: WarningKind::WrongToken,
message: "bad".to_owned(),
at: ClientTimestamp::now()
},
OutputMode::RttUs
),
None
);
}
#[test]
fn machine_prints_stable_key_value_fields() {
let line = format_event(&reply_event(), OutputMode::Machine).unwrap();
assert!(line.starts_with("event=echo_reply "));
assert!(line.contains("seq=7"));
assert!(line.contains("remote=127.0.0.1:2112"));
assert!(line.contains("client_send_wall_ns=1000000000"));
assert!(line.contains("client_receive_wall_ns=1001500000"));
assert!(line.contains("raw_rtt_us=1500"));
assert!(line.contains("adjusted_rtt_us=1200"));
assert!(line.contains("effective_rtt_us=1200"));
assert!(line.contains("server_processing_us=300"));
assert!(line.contains("server_received_count=9"));
}
#[test]
fn simple_and_human_use_readable_reply_lines() {
assert_eq!(
format_event(&reply_event(), OutputMode::Simple),
Some(
"reply seq=7 remote=127.0.0.1:2112 rtt_us=1200 raw_rtt_us=1500 server_processing_us=300"
.to_owned()
)
);
let human = format_event(&reply_event(), OutputMode::Human).unwrap();
assert!(human.starts_with("seq=7 rtt=1.2ms"));
assert!(human.contains("rd=500.0µs"));
assert!(human.contains("sd=400.0µs"));
assert!(human.contains("ipdv=n/a"));
assert!(human.contains("proc=300.0µs"));
assert!(!human.contains("rtt_us="));
}
#[test]
fn echo_sent_is_not_formatted_for_stream_outputs() {
let ts = test_timestamp(Duration::from_secs(1));
let event = ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: ts.mono,
sent_at: ts,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
};
assert!(format_event(&event, OutputMode::RttUs).is_none());
assert!(format_event(&event, OutputMode::Human).is_none());
assert!(format_event(&event, OutputMode::Machine).is_none());
assert!(format_event(&event, OutputMode::Simple).is_none());
}
}
+485
View File
@@ -0,0 +1,485 @@
use std::{
io::{self, Write},
sync::atomic::AtomicBool,
};
use irtt_client::ClientEvent;
#[cfg(all(test, feature = "stats"))]
use irtt_client::{ClientTimestamp, PacketMeta, RttSample, SignedDuration};
use super::{
args::{ClientArgs, OutputMode},
output::{format_event, format_human_event_with_options, HumanOutputOptions},
};
#[cfg(feature = "stats")]
use crate::shared::client::expected_probe_count;
use crate::shared::client::{
is_shutdown_requested, session::should_print_final_summary, ClientSession,
};
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
#[cfg(feature = "stats")]
const FINITE_STATS_BYTES_PER_PROBE: u64 = 500;
#[cfg(feature = "stats")]
const MIB: u64 = 1024 * 1024;
#[cfg(feature = "stats")]
const GIB: u64 = 1024 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
#[cfg(feature = "stats")]
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
pub fn run_stream(
args: ClientArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output;
let continuous = args.is_continuous();
#[cfg(feature = "stats")]
if let Some(warning) = finite_stats_memory_warning(&args) {
eprintln!("{warning}");
}
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut stream_output = StreamOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let mut session = ClientSession::connect(args.to_client_config(), continuous)?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
stream_output.print_events(&events)?;
let mut interrupted = false;
while session.should_continue(shutdown_requested) {
let events = session.step(shutdown_requested)?;
stream_output.print_events(&events)?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
session.sleep_until_next_send();
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if session.should_drain_final(interrupted) {
session.drain_final(|events| {
let _ = stream_output.print_events(events);
})?;
}
let events = session.poll_timeouts()?;
stream_output.print_events(&events)?;
let events = session.close()?;
stream_output.print_events(&events)?;
let print_final_summary = should_print_final_summary(continuous, interrupted);
stream_output.print_final_summary = print_final_summary;
stream_output.show_running_only_summary_note = continuous && interrupted && print_final_summary;
stream_output.print_summary()?;
stream_output.out.flush()?;
Ok(())
}
struct StreamOutput<'a, W: Write> {
mode: OutputMode,
human_options: HumanOutputOptions,
print_final_summary: bool,
show_running_only_summary_note: bool,
out: &'a mut W,
#[cfg(feature = "stats")]
stats: &'a mut StatsCollector,
}
impl<W: Write> StreamOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
for event in events {
self.print_event(event)?;
}
Ok(())
}
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
#[cfg(feature = "stats")]
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = crate::cmd::client::HumanEventStats::default();
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
#[cfg(feature = "stats")]
let human_stats = stats_update.into();
#[cfg(not(feature = "stats"))]
let human_stats = stats_update;
Some(format_human_event_with_options(
event,
Some(human_stats),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_final_summary || !self.mode.prints_summary() {
return Ok(());
}
#[cfg(feature = "stats")]
{
write!(
self.out,
"{}",
crate::cmd::client::summary::format_summary_with_options(
&self.stats.snapshot(),
crate::cmd::client::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
show_running_only_note: self.show_running_only_summary_note,
},
)
)?;
}
Ok(())
}
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
#[cfg(feature = "stats")]
fn estimate_finite_stats_memory_bytes(expected_probes: u64) -> u64 {
expected_probes.saturating_mul(FINITE_STATS_BYTES_PER_PROBE)
}
#[cfg(feature = "stats")]
fn finite_stats_memory_warning(args: &ClientArgs) -> Option<String> {
if args.is_continuous() || args.duration.is_zero() {
return None;
}
let expected_probes = expected_probe_count(args.duration, args.interval);
let estimated_bytes = estimate_finite_stats_memory_bytes(expected_probes);
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
return None;
}
let formatted = format_bytes_for_warning(estimated_bytes);
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
"this may be unsuitable on memory-constrained systems"
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
"consider shortening the run, increasing the interval, or using continuous mode"
} else {
"use continuous mode for bounded-memory long-running tests"
};
Some(format!(
"irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"
))
}
#[cfg(feature = "stats")]
fn format_bytes_for_warning(bytes: u64) -> String {
if bytes >= GIB {
format!("{} GiB", bytes.saturating_add(GIB / 2) / GIB)
} else {
format!("{} MiB", bytes.saturating_add(MIB / 2) / MIB)
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: SignedDuration::from_nanos(i128::from(rtt_us) * 1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
fn cli_args(args: &[&str]) -> ClientArgs {
let mut argv = vec!["irtt-rs"];
argv.extend_from_slice(args);
<ClientArgs as clap::Parser>::try_parse_from(argv).unwrap()
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
print_final_summary: true,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
stream_output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.snapshot();
assert_eq!(rendered, "1200\n");
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_summary_prints_when_enabled_and_suppresses_when_disabled() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: true,
show_running_only_summary_note: true,
out: &mut out,
stats: &mut stats,
};
stream_output.print_event(&reply_event(1, 1200)).unwrap();
stream_output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
assert!(rendered.contains("irtt-rs summary"));
assert!(rendered.contains("medians unavailable"));
assert!(rendered.contains("continuous mode"));
assert!(rendered.contains("packets:"));
assert!(rendered.contains("received=1"));
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: true,
out: &mut out,
stats: &mut stats,
};
stream_output.print_summary().unwrap();
}
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut stream_output = StreamOutput {
mode: OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_final_summary: false,
show_running_only_summary_note: false,
out: &mut out,
stats: &mut stats,
};
stream_output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000_u32 {
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
collector.process(&ClientEvent::EchoReply {
seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: SignedDuration::from_nanos(1_000),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.snapshot().rtt.primary.median_ns, None);
}
#[test]
fn finite_stats_memory_warning_reports_only_large_finite_runs() {
for args in [
cli_args(&["--duration", "0", "--interval", "1ms", "127.0.0.1:2112"]),
cli_args(&[
"--duration",
"1000ms",
"--interval",
"1ms",
"127.0.0.1:2112",
]),
] {
assert_eq!(finite_stats_memory_warning(&args), None);
}
for (threshold, size, guidance) in [
(
FINITE_STATS_MEMORY_WARNING_BYTES,
"about 128 MiB",
"bounded-memory long-running tests",
),
(
FINITE_STATS_MEMORY_STRONG_WARNING_BYTES,
"about 512 MiB",
"shortening the run",
),
(
FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES,
"about 1 GiB",
"memory-constrained systems",
),
] {
let expected_probes = threshold.div_ceil(FINITE_STATS_BYTES_PER_PROBE);
let args = cli_args(&[
"--duration",
&format!("{expected_probes}ms"),
"--interval",
"1ms",
"127.0.0.1:2112",
]);
let warning = finite_stats_memory_warning(&args).unwrap();
assert!(warning.contains(size));
assert!(warning.contains(guidance));
}
}
#[test]
fn expected_probe_count_rounds_up() {
assert_eq!(
expected_probe_count(Duration::from_millis(1001), Duration::from_secs(1)),
2
);
}
#[test]
fn estimate_finite_stats_memory_bytes_saturates() {
assert_eq!(estimate_finite_stats_memory_bytes(u64::MAX), u64::MAX);
}
}
@@ -1,28 +1,31 @@
use std::fmt::Write as _;
use irtt_stats::{
DurationStats, DurationStatsWithMedian, FiniteSummary, SignedDurationStatsWithMedian,
};
use irtt_stats::{Snapshot, TimeStats};
pub fn format_summary(summary: &FiniteSummary) -> String {
pub fn format_summary(summary: &Snapshot) -> String {
format_summary_with_options(summary, SummaryFormatOptions::default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SummaryFormatOptions {
pub verbose: bool,
pub show_running_only_note: bool,
}
pub fn format_summary_with_options(
summary: &FiniteSummary,
options: SummaryFormatOptions,
) -> String {
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
let mut out = String::new();
let packets = summary.packets;
let loss = summary.loss;
writeln!(out).unwrap();
writeln!(out, "irtt-rs summary").unwrap();
if options.show_running_only_note {
writeln!(
out,
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
)
.unwrap();
}
writeln!(out).unwrap();
writeln!(
out,
@@ -32,27 +35,27 @@ pub fn format_summary_with_options(
.unwrap();
writeln!(out, " {}", "-".repeat(82)).unwrap();
write_signed_duration_row(&mut out, "RTT", &summary.rtt.primary);
write_time_row(&mut out, "RTT", &summary.rtt.primary);
if options.verbose {
write_duration_row(&mut out, "raw RTT", &summary.rtt.raw);
write_signed_duration_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
}
write_duration_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
write_duration_row(&mut out, "send IPDV", &summary.ipdv.send);
write_duration_row(&mut out, "receive IPDV", &summary.ipdv.receive);
write_duration_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
write_duration_row(
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
write_time_row(
&mut out,
"receive delay",
&summary.one_way_delay.receive_delay,
);
write_duration_row_no_median(
write_time_row(
&mut out,
"server processing",
&summary.server_processing.processing,
);
write_duration_row_no_median(&mut out, "send call", &summary.send_call);
write_duration_row_no_median(&mut out, "timer error", &summary.timer_error);
write_time_row(&mut out, "send call", &summary.send_call);
write_time_row(&mut out, "timer error", &summary.timer_error);
writeln!(out).unwrap();
writeln!(
@@ -97,24 +100,7 @@ pub fn format_summary_with_options(
out
}
fn write_duration_row(out: &mut String, label: &str, value: &DurationStatsWithMedian) {
if value.stats.count == 0 {
return;
}
writeln!(
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.stats.count,
format_ns_u64(value.stats.min_ns),
format_ns_f64(value.stats.mean_ns),
format_ns_f64_opt(value.median_ns),
format_ns_u64(value.stats.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
}
fn write_duration_row_no_median(out: &mut String, label: &str, value: &DurationStats) {
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
if value.count == 0 {
return;
}
@@ -122,27 +108,10 @@ fn write_duration_row_no_median(out: &mut String, label: &str, value: &DurationS
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.count,
format_ns_u64(value.min_ns),
format_ns_i128(value.min_ns),
format_ns_f64(value.mean_ns),
"-",
format_ns_u64(value.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
}
fn write_signed_duration_row(out: &mut String, label: &str, value: &SignedDurationStatsWithMedian) {
if value.stats.count == 0 {
return;
}
writeln!(
out,
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
value.stats.count,
format_ns_i128(value.stats.min_ns),
format_ns_f64(value.stats.mean_ns),
format_ns_f64_opt(value.median_ns),
format_ns_i128(value.stats.max_ns),
format_ns_i128(value.max_ns),
format_ns_f64(value.stddev_ns())
)
.unwrap();
@@ -152,12 +121,6 @@ fn format_percent(value: f64) -> String {
format!("{value:.2}%")
}
fn format_ns_u64(value: Option<u64>) -> String {
value
.map(|value| format_ns_f64(value as f64))
.unwrap_or_else(|| "-".to_owned())
}
fn format_ns_i128(value: Option<i128>) -> String {
value
.map(|value| format_ns_f64(value as f64))
@@ -188,7 +151,7 @@ mod tests {
use irtt_client::{
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
};
use irtt_stats::{SignedDurationStats, StatsCollector, StatsConfig};
use irtt_stats::{StatsCollector, StatsConfig};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::{Duration, Instant, UNIX_EPOCH},
@@ -207,7 +170,7 @@ mod tests {
#[test]
fn empty_summary_omits_optional_metric_sections() {
let summary = StatsCollector::new(StatsConfig::finite()).summary();
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
let output = format_summary(&summary);
assert!(output.contains("Metric"));
@@ -229,7 +192,6 @@ mod tests {
collector.process(&ClientEvent::EchoSent {
seq: 1,
logical_seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
@@ -239,16 +201,13 @@ mod tests {
});
collector.process(&ClientEvent::EchoReply {
seq: 1,
logical_seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(Duration::from_micros(1200)),
effective: Duration::from_micros(1200),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: Some(ServerTiming {
receive_wall_ns: None,
@@ -265,7 +224,7 @@ mod tests {
packet_meta: PacketMeta::default(),
});
let output = format_summary(&collector.summary());
let output = format_summary(&collector.snapshot());
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
assert!(output.contains("bytes: sent=64 received=64"));
@@ -279,6 +238,7 @@ mod tests {
assert!(output.contains("10.0µs"));
assert!(output.contains("timer error"));
assert!(output.contains("2.0µs"));
assert!(!output.contains("medians unavailable"));
}
#[test]
@@ -287,16 +247,13 @@ mod tests {
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
collector.process(&ClientEvent::EchoReply {
seq: 1,
logical_seq: 1,
remote: test_remote(),
sent_at: test_timestamp(Duration::from_secs(1)),
received_at,
rtt: RttSample {
raw: Duration::from_micros(1500),
adjusted: Some(Duration::from_micros(1200)),
effective: Duration::from_micros(1200),
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
effective_signed: SignedDuration { ns: 1_200_000 },
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
effective: SignedDuration::from_nanos(1_200_000),
},
server_timing: None,
one_way: None,
@@ -306,8 +263,11 @@ mod tests {
});
let output = format_summary_with_options(
&collector.summary(),
SummaryFormatOptions { verbose: true },
&collector.snapshot(),
SummaryFormatOptions {
verbose: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("raw RTT"));
@@ -315,21 +275,35 @@ mod tests {
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = SignedDurationStatsWithMedian {
stats: SignedDurationStats {
count: 1,
total_ns: -500,
min_ns: Some(-500),
max_ns: Some(-500),
mean_ns: -500.0,
variance_ns2: 0.0,
fn running_only_note_is_format_option() {
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
let output = format_summary_with_options(
&summary,
SummaryFormatOptions {
show_running_only_note: true,
..SummaryFormatOptions::default()
},
);
assert!(output.contains("irtt-rs summary"));
assert!(output.contains("medians unavailable"));
assert!(output.contains("continuous mode"));
}
#[test]
fn signed_stats_can_format_negative_values() {
let stats = TimeStats {
count: 1,
total_ns: -500,
min_ns: Some(-500),
max_ns: Some(-500),
mean_ns: -500.0,
median_ns: Some(-500.0),
variance_ns2: 0.0,
};
let mut out = String::new();
write_signed_duration_row(&mut out, "RTT", &stats);
write_time_row(&mut out, "RTT", &stats);
assert!(out.contains("-500ns"));
}
}
+7
View File
@@ -0,0 +1,7 @@
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "tui")]
pub mod tui;
// Future command applets, such as a server applet, belong here behind their
// own feature gate.
+134
View File
@@ -0,0 +1,134 @@
use std::time::Duration;
use clap::Parser;
use irtt_client::ClientConfig;
#[cfg(test)]
use crate::shared::client::TimestampArg;
use crate::shared::client::{parse_test_duration, CommonClientArgs};
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 or host, with optional port.
pub server: String,
#[arg(
long,
default_value = "0",
value_parser = parse_test_duration,
help = "Test duration; use 0 for continuous mode",
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
)]
pub duration: Duration,
#[command(flatten)]
pub common: CommonClientArgs,
}
impl TuiArgs {
pub fn to_client_config(&self) -> ClientConfig {
self.common.to_client_config(&self.server, self.duration)
}
pub fn is_continuous(&self) -> bool {
self.duration == Duration::ZERO
}
#[cfg(test)]
pub fn timestamp_mode(&self) -> TimestampArg {
self.common.tstamp
}
}
impl std::ops::Deref for TuiArgs {
type Target = CommonClientArgs;
fn deref(&self) -> &Self::Target {
&self.common
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::client::ClientArgs;
use clap::{CommandFactory, Parser};
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
let mut argv = vec!["irtt-tui"];
argv.extend_from_slice(args);
TuiArgs::try_parse_from(argv)
}
fn parse_client(args: &[&str]) -> Result<ClientArgs, clap::Error> {
let mut argv = vec!["irtt-cli"];
argv.extend_from_slice(args);
ClientArgs::try_parse_from(argv)
}
#[test]
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
let args = parse(&["127.0.0.1:2112"]).unwrap();
assert_eq!(args.server, "127.0.0.1:2112");
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
assert!(args.is_continuous());
assert_eq!(args.to_client_config().duration, None);
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
assert_eq!(finite.duration, Duration::from_secs(30));
assert_eq!(
finite.to_client_config().duration,
Some(Duration::from_secs(30))
);
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
let help = TuiArgs::command().render_help().to_string();
assert!(!help.contains("--output"));
}
#[test]
fn shared_client_options_match_between_stream_and_tui_parsers() {
let shared = [
"--duration",
"30s",
"--interval",
"250ms",
"--length",
"128",
"--hmac",
"secret",
"--clock",
"monotonic",
"--tstamp",
"receive",
"--stats",
"count",
"--sfill",
"abc",
"--dscp",
"46",
"--ttl",
"64",
"--loose",
"127.0.0.1:2112",
];
let client = parse_client(&shared).unwrap().to_client_config();
let tui = parse(&shared).unwrap().to_client_config();
assert_eq!(client.server_addr, tui.server_addr);
assert_eq!(client.duration, tui.duration);
assert_eq!(client.interval, tui.interval);
assert_eq!(client.length, tui.length);
assert_eq!(client.received_stats, tui.received_stats);
assert_eq!(client.stamp_at, tui.stamp_at);
assert_eq!(client.clock, tui.clock);
assert_eq!(client.dscp, tui.dscp);
assert_eq!(client.hmac_key, tui.hmac_key);
assert_eq!(client.server_fill, tui.server_fill);
assert_eq!(client.negotiation_policy, tui.negotiation_policy);
assert_eq!(client.socket_config.ttl, tui.socket_config.ttl);
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod args;
pub mod run;
mod ui;
pub use args::TuiArgs;
pub use run::run_tui;
+216
View File
@@ -0,0 +1,216 @@
use std::{
io,
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use irtt_stats::StatsCollector;
use crate::{
cmd::tui::args::TuiArgs,
shared::client::{is_shutdown_requested, ClientSession},
};
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
fn stats_config(continuous: bool) -> irtt_stats::StatsConfig {
if continuous {
irtt_stats::StatsConfig::continuous()
} else {
irtt_stats::StatsConfig::finite()
}
}
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
pub fn run_tui(
args: TuiArgs,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
let continuous = args.is_continuous();
let mut terminal = TuiTerminal::enter()?;
let mut state = TuiState::new(TuiConfig::from_args(&args));
let mut stats = StatsCollector::new(stats_config(continuous));
let mut next_render = Instant::now();
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
state.set_status(TuiStatus::Opening);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
Ok(session) => session,
Err(err) => {
state.set_error(err.to_string());
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
return Err(Box::new(err));
}
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let events = session.open()?;
state.process_events(&events, &mut stats);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let mut interrupted = false;
// Keep this in lockstep with run_stream: send due probes, drain available
// replies, poll timeouts, sleep toward the next absolute send deadline,
// then perform the same final drain, timeout poll, and close sequence.
while session.should_continue(shutdown_requested) {
if handle_input(&mut state, shutdown_requested)? {
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
if state.quit_requested {
interrupted = true;
break;
}
let events = session.step(shutdown_requested)?;
state.process_events(&events, &mut stats);
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?;
wait_for_tui_activity(
session.next_send_deadline(),
&mut next_render,
&mut state,
&stats,
&mut terminal,
shutdown_requested,
)?;
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
state.set_status(TuiStatus::Interrupted);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
if session.should_drain_final(interrupted) {
state.set_status(TuiStatus::Draining);
let mut drain_render = Instant::now();
session.drain_final(|events| {
state.process_events(events, &mut stats);
let _ = render_if_due(&mut terminal, &state, &stats, &mut drain_render, false);
})?;
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
}
let events = session.poll_timeouts()?;
state.process_events(&events, &mut stats);
state.set_status(TuiStatus::Closing);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
let events = session.close()?;
state.process_events(&events, &mut stats);
state.set_status(TuiStatus::Complete);
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
Ok(())
}
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
let mut force_render = false;
while event::poll(Duration::ZERO)? {
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
match key.code {
KeyCode::Char('q') => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
state.quit_requested = true;
shutdown_requested.store(true, Ordering::Relaxed);
force_render = true;
}
KeyCode::Char('r') => {
state.clear_visible_history();
force_render = true;
}
KeyCode::Char('p') => {
state.toggle_pause();
force_render = true;
}
KeyCode::Char('g') => {
state.cycle_graph_mode();
force_render = true;
}
KeyCode::Char('f') => {
state.toggle_full_graph();
force_render = true;
}
_ => {}
}
}
Ok(force_render)
}
fn render_if_due(
terminal: &mut TuiTerminal,
state: &TuiState,
stats: &StatsCollector,
next_render: &mut Instant,
force: bool,
) -> io::Result<()> {
let now = Instant::now();
if !should_render(now, *next_render, state.paused, force) {
return Ok(());
}
terminal.draw(state, &stats.snapshot())?;
*next_render = now + RENDER_INTERVAL;
Ok(())
}
fn wait_for_tui_activity(
next_send_deadline: Option<Instant>,
next_render: &mut Instant,
state: &mut TuiState,
stats: &StatsCollector,
terminal: &mut TuiTerminal,
shutdown_requested: &AtomicBool,
) -> io::Result<()> {
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
if wait_for.is_zero() || !event::poll(wait_for)? {
return Ok(());
}
if handle_input(state, shutdown_requested)? {
render_if_due(terminal, state, stats, next_render, true)?;
}
Ok(())
}
fn tui_wait_duration(
next_send_deadline: Option<Instant>,
next_render: Instant,
paused: bool,
) -> Duration {
let now = Instant::now();
let send_wait = next_send_deadline
.map(|deadline| deadline.saturating_duration_since(now))
.unwrap_or(IDLE_SLEEP);
let render_wait = if paused {
send_wait
} else {
next_render.saturating_duration_since(now)
};
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+59 -446
View File
@@ -1,36 +1,19 @@
use std::{
io::{self, Write},
env,
ffi::OsString,
process::ExitCode,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::{Duration, Instant},
sync::{atomic::AtomicBool, Arc},
};
use clap::Parser;
use irtt_cli::{
format_event, format_human_event_with_options, CliArgs, HumanOutputOptions, OutputMode,
applet::{
default_applet_for_features, detect_applet_from_argv0, AppletAvailability, RequestedApplet,
},
signal::install_signal_handler,
};
use irtt_client::{Client, ClientEvent, ClientTimestamp, OpenOutcome, RecvBudget};
#[cfg(feature = "stats")]
use irtt_stats::{StatsCollector, StatsConfig};
const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
fn main() -> ExitCode {
let args = CliArgs::parse();
let shutdown_requested = Arc::new(AtomicBool::new(false));
if let Err(err) = install_signal_handler(Arc::clone(&shutdown_requested)) {
eprintln!("irtt-rs: failed to install signal handler: {err}");
return ExitCode::FAILURE;
}
match run(args, shutdown_requested.as_ref()) {
match run_from_env() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("irtt-rs: {err}");
@@ -39,441 +22,71 @@ fn main() -> ExitCode {
}
}
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> {
let mode = args.output;
let continuous = args.is_continuous();
let mut stdout = io::LineWriter::new(io::stdout().lock());
#[cfg(feature = "stats")]
let mut stats = StatsCollector::new(stats_config(continuous));
let mut output = EventOutput {
mode,
human_options: HumanOutputOptions {
verbose: args.verbose,
},
print_finite_summary: !continuous,
out: &mut stdout,
#[cfg(feature = "stats")]
stats: &mut stats,
fn run_from_env() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<OsString> = env::args_os().collect();
let argv0 = argv
.first()
.and_then(|arg| arg.to_str())
.unwrap_or("irtt-rs");
let requested = match detect_applet_from_argv0(argv0) {
Some(applet) => applet,
None => default_requested_applet()?,
};
if is_shutdown_requested(shutdown_requested) {
return Ok(());
let shutdown_requested = Arc::new(AtomicBool::new(false));
install_signal_handler(Arc::clone(&shutdown_requested))
.map_err(|err| format!("failed to install signal handler: {err}"))?;
match requested {
RequestedApplet::Client => run_client_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Tui => run_tui_applet(argv, shutdown_requested.as_ref()),
RequestedApplet::Server => Err("server applet is not available in this build".into()),
}
let mut client = Client::connect(args.to_client_config())?;
if is_shutdown_requested(shutdown_requested) {
return Ok(());
}
let open = client.open(ClientTimestamp::now())?;
output.print_event(open_event(&open))?;
let mut interrupted = false;
while should_continue_run(continuous, client.next_send_deadline(), shutdown_requested) {
if should_send_probe(client.next_send_deadline(), shutdown_requested) {
let events = client.send_probe()?;
output.print_events(&events)?;
}
let events = client.recv_available(RECV_BUDGET)?;
output.print_events(&events)?;
let events = client.poll_timeouts(ClientTimestamp::now())?;
output.print_events(&events)?;
if is_shutdown_requested(shutdown_requested) {
interrupted = true;
break;
}
sleep_until_next_send(client.next_send_deadline());
}
interrupted |= is_shutdown_requested(shutdown_requested);
if interrupted {
eprintln!("interrupted, closing session...");
}
if should_drain_final(continuous, interrupted) {
drain_final_replies(&mut client, &mut output)?;
}
let events = client.poll_timeouts(ClientTimestamp::now())?;
output.print_events(&events)?;
let events = client.close(ClientTimestamp::now())?;
output.print_events(&events)?;
output.print_summary()?;
output.out.flush()?;
Ok(())
}
fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
fn default_requested_applet() -> Result<RequestedApplet, Box<dyn std::error::Error>> {
match default_applet_for_features(cfg!(feature = "client"), cfg!(feature = "tui"), false) {
AppletAvailability::Client => Ok(RequestedApplet::Client),
AppletAvailability::Tui => Ok(RequestedApplet::Tui),
AppletAvailability::Server => Ok(RequestedApplet::Server),
AppletAvailability::None => Err("no runnable applet is available in this build".into()),
}
}
fn should_continue_run(
continuous: bool,
next_send_deadline: Option<Instant>,
#[cfg(feature = "client")]
fn run_client_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> bool {
!is_shutdown_requested(shutdown_requested) && (continuous || next_send_deadline.is_some())
}
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
}
fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
match outcome {
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
}
}
fn drain_final_replies<W: Write>(
client: &mut Client,
output: &mut EventOutput<'_, W>,
) -> Result<(), Box<dyn std::error::Error>> {
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
loop {
let mut printed = false;
use clap::Parser;
let events = client.recv_available(RECV_BUDGET)?;
printed |= !events.is_empty();
output.print_events(&events)?;
let events = client.poll_timeouts(ClientTimestamp::now())?;
printed |= !events.is_empty();
output.print_events(&events)?;
if client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !printed {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
let args = irtt_cli::cmd::client::ClientArgs::parse_from(argv);
irtt_cli::cmd::client::run_stream(args, shutdown_requested)
}
fn sleep_until_next_send(deadline: Option<Instant>) {
let sleep_for = match deadline {
Some(deadline) => deadline
.saturating_duration_since(Instant::now())
.min(MAX_SLEEP),
None => Duration::from_millis(1),
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
#[cfg(not(feature = "client"))]
fn run_client_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("client applet is not available; rebuild with the client feature".into())
}
struct EventOutput<'a, W: Write> {
mode: irtt_cli::OutputMode,
human_options: HumanOutputOptions,
print_finite_summary: bool,
out: &'a mut W,
#[cfg(feature = "stats")]
stats: &'a mut StatsCollector,
#[cfg(feature = "tui")]
fn run_tui_applet(
argv: Vec<OsString>,
shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
use clap::Parser;
let args = irtt_cli::cmd::tui::TuiArgs::parse_from(argv);
irtt_cli::cmd::tui::run_tui(args, shutdown_requested)
}
impl<W: Write> EventOutput<'_, W> {
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
for event in events {
self.print_event(event)?;
}
Ok(())
}
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
#[cfg(feature = "stats")]
let stats_update = self.stats.process(event);
#[cfg(not(feature = "stats"))]
let stats_update = irtt_cli::HumanEventStats::default();
let line =
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
Some(format_human_event_with_options(
event,
Some(stats_update.into()),
self.human_options,
))
} else {
format_event(event, self.mode)
};
if let Some(line) = line {
writeln!(self.out, "{line}")?;
}
Ok(())
}
fn print_summary(&mut self) -> io::Result<()> {
if !self.print_finite_summary || !self.mode.prints_summary() {
return Ok(());
}
#[cfg(feature = "stats")]
{
write!(
self.out,
"{}",
irtt_cli::summary::format_summary_with_options(
&self.stats.summary(),
irtt_cli::summary::SummaryFormatOptions {
verbose: self.human_options.verbose,
},
)
)?;
}
Ok(())
}
}
#[cfg(feature = "stats")]
fn stats_config(continuous: bool) -> StatsConfig {
if continuous {
StatsConfig::continuous()
} else {
StatsConfig::finite()
}
}
fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
#[cfg(test)]
mod shutdown_tests {
use super::*;
#[test]
fn shutdown_flag_stops_run_loop() {
let shutdown = AtomicBool::new(false);
assert!(should_continue_run(true, Some(Instant::now()), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_continue_run(true, Some(Instant::now()), &shutdown));
}
#[test]
fn shutdown_flag_suppresses_due_probe_send() {
let shutdown = AtomicBool::new(false);
let due = Instant::now() - Duration::from_millis(1);
assert!(should_send_probe(Some(due), &shutdown));
shutdown.store(true, Ordering::Relaxed);
assert!(!should_send_probe(Some(due), &shutdown));
}
#[test]
fn interrupted_continuous_run_uses_final_drain_before_close() {
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
assert!(should_drain_final(false, false));
}
}
#[cfg(all(test, feature = "stats"))]
mod tests {
use super::*;
use irtt_client::{PacketMeta, RttSample, SignedDuration};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
time::UNIX_EPOCH,
};
fn test_timestamp(offset: Duration) -> ClientTimestamp {
ClientTimestamp {
wall: UNIX_EPOCH + offset,
mono: Instant::now() + offset,
}
}
fn test_remote() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
}
fn reply_event(logical_seq: u64, rtt_us: u64) -> ClientEvent {
let sent_at = test_timestamp(Duration::from_millis(logical_seq));
let received_at = ClientTimestamp {
wall: sent_at.wall + Duration::from_micros(rtt_us),
mono: sent_at.mono + Duration::from_micros(rtt_us),
};
ClientEvent::EchoReply {
seq: logical_seq as u32,
logical_seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(rtt_us),
adjusted: None,
effective: Duration::from_micros(rtt_us),
adjusted_signed: None,
effective_signed: SignedDuration {
ns: i128::from(rtt_us) * 1_000,
},
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn output_helper_streams_and_collects_events() {
let sent_at = test_timestamp(Duration::from_secs(1));
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
let events = [
ClientEvent::EchoSent {
seq: 1,
logical_seq: 1,
remote: test_remote(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 64,
send_call: Duration::from_micros(10),
timer_error: Duration::ZERO,
},
ClientEvent::EchoReply {
seq: 1,
logical_seq: 1,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1200),
adjusted: None,
effective: Duration::from_micros(1200),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_200_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
},
];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::RttUs,
human_options: HumanOutputOptions::default(),
print_finite_summary: true,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
output.print_summary().unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let summary = stats.summary();
assert_eq!(rendered, "1200\n");
assert_eq!(summary.packets.packets_sent, 1);
assert_eq!(summary.packets.unique_replies, 1);
}
#[test]
fn continuous_output_does_not_print_finite_summary() {
let mut stats = StatsCollector::new(StatsConfig::continuous());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_summary().unwrap();
}
assert!(out.is_empty());
}
#[test]
fn human_output_prints_ipdv_from_stats_update() {
let events = [reply_event(0, 1200), reply_event(1, 1250)];
let mut stats = StatsCollector::new(StatsConfig::finite());
let mut out = Vec::new();
{
let mut output = EventOutput {
mode: irtt_cli::OutputMode::Human,
human_options: HumanOutputOptions::default(),
print_finite_summary: false,
out: &mut out,
stats: &mut stats,
};
output.print_events(&events).unwrap();
}
let rendered = String::from_utf8(out).unwrap();
let mut lines = rendered.lines();
assert!(lines.next().unwrap().contains("ipdv=n/a"));
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
}
#[test]
fn continuous_mode_uses_continuous_stats_config() {
let mut collector = StatsCollector::new(stats_config(true));
for seq in 0..5000 {
let sent_at = test_timestamp(Duration::from_micros(seq));
let received_at = test_timestamp(Duration::from_micros(seq + 1));
collector.process(&ClientEvent::EchoReply {
seq: seq as u32,
logical_seq: seq,
remote: test_remote(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_micros(1),
adjusted: None,
effective: Duration::from_micros(1),
adjusted_signed: None,
effective_signed: SignedDuration { ns: 1_000 },
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
}
assert_eq!(collector.summary().rtt.primary.median_ns, None);
}
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
#[cfg(not(feature = "tui"))]
fn run_tui_applet(
_argv: Vec<OsString>,
_shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> {
Err("TUI applet is not available; rebuild with the tui feature".into())
}
+223
View File
@@ -0,0 +1,223 @@
use std::time::Duration;
use clap::ValueEnum;
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
use irtt_proto::{Clock, ReceivedStats, StampAt};
#[derive(Debug, Clone, clap::Args)]
pub struct CommonClientArgs {
/// Probe interval.
#[arg(long, default_value = "1s", value_parser = parse_duration)]
pub interval: Duration,
/// UDP payload length.
#[arg(long, default_value_t = 0, value_parser = parse_length)]
pub length: u32,
/// HMAC key.
#[arg(long)]
pub hmac: Option<String>,
/// Clock mode to request.
#[arg(long, value_enum, default_value_t = ClockArg::Both)]
pub clock: ClockArg,
/// Timestamp mode to request.
#[arg(
long = "tstamp",
visible_alias = "timestamps",
value_enum,
value_name = "MODE",
default_value_t = TimestampArg::Both
)]
pub tstamp: TimestampArg,
/// Received-stats mode to request.
#[arg(
long = "stats",
value_enum,
default_value_t = ReceivedStatsArg::Both,
help = "Server received-stats negotiation mode"
)]
pub stats: ReceivedStatsArg,
/// Server payload fill string to request, up to 32 bytes.
#[arg(
long = "sfill",
visible_alias = "server-fill",
value_name = "STRING",
value_parser = parse_server_fill
)]
pub server_fill: Option<String>,
/// DSCP codepoint to request; this is not a raw TOS or Traffic Class byte.
#[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)]
pub dscp: u8,
/// Local outgoing IPv4 TTL or IPv6 unicast hop limit; not negotiated.
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
pub ttl: Option<u32>,
/// Accept safe server restrictions during negotiation.
#[arg(long)]
pub loose: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ClockArg {
Wall,
Monotonic,
Both,
}
impl From<ClockArg> for Clock {
fn from(value: ClockArg) -> Self {
match value {
ClockArg::Wall => Self::Wall,
ClockArg::Monotonic => Self::Monotonic,
ClockArg::Both => Self::Both,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TimestampArg {
None,
Send,
Receive,
Both,
Midpoint,
}
impl From<TimestampArg> for StampAt {
fn from(value: TimestampArg) -> Self {
match value {
TimestampArg::None => Self::None,
TimestampArg::Send => Self::Send,
TimestampArg::Receive => Self::Receive,
TimestampArg::Both => Self::Both,
TimestampArg::Midpoint => Self::Midpoint,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ReceivedStatsArg {
None,
Count,
Window,
Both,
}
impl From<ReceivedStatsArg> for ReceivedStats {
fn from(value: ReceivedStatsArg) -> Self {
match value {
ReceivedStatsArg::None => Self::None,
ReceivedStatsArg::Count => Self::Count,
ReceivedStatsArg::Window => Self::Window,
ReceivedStatsArg::Both => Self::Both,
}
}
}
pub fn parse_duration(input: &str) -> Result<Duration, String> {
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Err("duration must be greater than zero".to_owned());
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
if input == "0" {
return Ok(Duration::ZERO);
}
let (number, unit) = split_duration(input)?;
let value: u64 = number
.parse()
.map_err(|_| format!("invalid duration value {input:?}"))?;
if value == 0 {
return Ok(Duration::ZERO);
}
match unit {
"ms" => Ok(Duration::from_millis(value)),
"s" => Ok(Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
_ => Err(format!(
"unsupported duration unit {unit:?}; use ms, s, or m"
)),
}
}
fn split_duration(input: &str) -> Result<(&str, &str), String> {
let split = input
.find(|ch: char| !ch.is_ascii_digit())
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
let (number, unit) = input.split_at(split);
if number.is_empty() || unit.is_empty() {
return Err("duration must include a positive value and unit".to_owned());
}
if number.starts_with('-') {
return Err("duration must be greater than zero".to_owned());
}
Ok((number, unit))
}
pub fn parse_length(input: &str) -> Result<u32, String> {
let length: u32 = input
.parse()
.map_err(|_| format!("invalid packet length {input:?}"))?;
if length > MAX_UDP_PAYLOAD_LENGTH {
return Err(format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"));
}
Ok(length)
}
pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("server fill must not be empty".to_owned());
}
let len = input.len();
if len > MAX_SERVER_FILL_BYTES {
return Err(format!(
"server fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"
));
}
Ok(input.to_owned())
}
pub fn parse_dscp(input: &str) -> Result<u8, String> {
let value: u8 = input
.parse()
.map_err(|_| format!("invalid DSCP codepoint {input:?}"))?;
if value > MAX_DSCP_CODEPOINT {
return Err(format!("DSCP codepoint must be <= {MAX_DSCP_CODEPOINT}"));
}
Ok(value)
}
pub fn parse_ttl(input: &str) -> Result<u32, String> {
let value: u32 = input
.parse()
.map_err(|_| format!("invalid TTL/hop limit {input:?}"))?;
if value == 0 || value > MAX_TTL {
return Err(format!("TTL/hop limit must be in range 1..={MAX_TTL}"));
}
Ok(value)
}
@@ -0,0 +1,48 @@
use std::time::Duration;
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
use super::args::CommonClientArgs;
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
impl CommonClientArgs {
pub fn to_client_config(&self, server: &str, duration: Duration) -> ClientConfig {
ClientConfig {
server_addr: server.to_owned(),
duration: (!duration.is_zero()).then_some(duration),
interval: self.interval,
length: self.length,
received_stats: self.stats.into(),
stamp_at: self.tstamp.into(),
clock: self.clock.into(),
dscp: self.dscp,
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
server_fill: self.server_fill.clone(),
negotiation_policy: if self.loose {
NegotiationPolicy::Loose
} else {
NegotiationPolicy::Strict
},
socket_config: SocketConfig {
ttl: self.ttl,
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
..SocketConfig::default()
},
..ClientConfig::default()
}
}
}
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
let interval_nanos = interval.as_nanos();
if interval_nanos == 0 {
return u64::MAX;
}
let expected = duration
.as_nanos()
.saturating_add(interval_nanos.saturating_sub(1))
/ interval_nanos;
expected.min(u128::from(u64::MAX)) as u64
}
+10
View File
@@ -0,0 +1,10 @@
pub mod args;
pub mod config;
pub mod session;
pub use args::{
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
};
pub use config::{expected_probe_count, DEFAULT_RECV_TIMEOUT};
pub use session::{final_drain_duration, is_shutdown_requested, ClientSession, RECV_BUDGET};
@@ -0,0 +1,162 @@
use std::{
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, RecvBudget};
pub const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
const IDLE_SLEEP: Duration = Duration::from_millis(5);
const MAX_SLEEP: Duration = Duration::from_millis(20);
pub struct ClientSession {
client: Client,
continuous: bool,
}
impl ClientSession {
pub fn connect(
config: ClientConfig,
continuous: bool,
) -> Result<Self, irtt_client::ClientError> {
Ok(Self {
client: Client::connect(config)?,
continuous,
})
}
pub fn open(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
Ok(vec![open_event(&self.client.open()?).clone()])
}
pub fn step(
&mut self,
shutdown_requested: &AtomicBool,
) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
let mut events = Vec::new();
if should_send_probe(self.client.next_send_deadline(), shutdown_requested) {
events.extend(self.client.send_probe()?);
}
events.extend(self.client.recv_available(RECV_BUDGET)?);
events.extend(self.client.poll_timeouts()?);
Ok(events)
}
pub fn should_continue(&self, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& (self.continuous || self.client.next_send_deadline().is_some())
}
pub fn should_drain_final(&self, interrupted: bool) -> bool {
should_drain_final(self.continuous, interrupted)
}
pub fn drain_final<F>(&mut self, mut on_events: F) -> Result<(), irtt_client::ClientError>
where
F: FnMut(&[ClientEvent]),
{
let deadline = Instant::now() + final_drain_duration(self.client.probe_timeout());
loop {
let mut received = false;
let events = self.client.recv_available(RECV_BUDGET)?;
received |= !events.is_empty();
on_events(&events);
let events = self.client.poll_timeouts()?;
received |= !events.is_empty();
on_events(&events);
if self.client.is_run_complete() || Instant::now() >= deadline {
break;
}
if !received {
thread::sleep(IDLE_SLEEP);
}
}
Ok(())
}
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
self.client.poll_timeouts()
}
pub fn close(&mut self) -> Result<Vec<ClientEvent>, irtt_client::ClientError> {
self.client.close()
}
pub fn next_send_deadline(&self) -> Option<Instant> {
self.client.next_send_deadline()
}
pub fn probe_timeout(&self) -> Duration {
self.client.probe_timeout()
}
pub fn sleep_until_next_send(&self) {
let sleep_for = match self.next_send_deadline() {
Some(deadline) => deadline
.saturating_duration_since(Instant::now())
.min(MAX_SLEEP),
None => Duration::from_millis(1),
};
if !sleep_for.is_zero() {
thread::sleep(sleep_for);
}
}
}
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
shutdown_requested.load(Ordering::Relaxed)
}
pub fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
!continuous || interrupted
}
pub fn final_drain_duration(probe_timeout: Duration) -> Duration {
probe_timeout.min(MAX_FINAL_DRAIN)
}
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
!is_shutdown_requested(shutdown_requested)
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
}
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
match outcome {
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn final_drain_uses_capped_probe_timeout() {
assert_eq!(
final_drain_duration(Duration::from_secs(4)),
Duration::from_secs(4)
);
assert_eq!(
final_drain_duration(Duration::from_secs(60)),
Duration::from_secs(30)
);
}
#[test]
fn should_drain_final_for_finite_or_interrupted_runs() {
assert!(should_drain_final(false, false));
assert!(should_drain_final(false, true));
assert!(should_drain_final(true, true));
assert!(!should_drain_final(true, false));
}
}
+2
View File
@@ -0,0 +1,2 @@
#[cfg(feature = "client")]
pub mod client;
+10
View File
@@ -0,0 +1,10 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
ctrlc::set_handler(move || {
shutdown_requested.store(true, Ordering::Relaxed);
})
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-client"
version = "0.1.1"
version = "0.3.0"
description = "IRTT-compatible Rust client library"
edition.workspace = true
license.workspace = true
@@ -14,7 +14,7 @@ categories = ["network-programming"]
[dependencies]
irtt-proto.workspace = true
libc = { version = "0.2.186", optional = true }
socket2 = { version = "0.6.3", features = ["all"] }
socket2 = { version = "0.6.4", features = ["all"] }
thiserror = "2.0.18"
[features]
+121 -117
View File
@@ -23,7 +23,7 @@ use crate::{
metadata::ReceiveMeta,
probe::{CompletedSet, PendingMap, PendingProbe, TimedOutMap},
receive::recv_datagram,
session::{validate_negotiated_params, ActiveSession, ClientPhase, NegotiatedParams},
session::{negotiate_params, ActiveSession, ClientPhase, NegotiatedParams},
socket::{connect_udp_socket, resolve_remote, validate_open_timeouts},
socket_options::{apply_dscp_to_socket, clear_dscp_on_socket},
timing::ClientTimestamp,
@@ -32,6 +32,12 @@ use crate::{
const MAX_OPEN_PACKET_SIZE: usize = 512;
const MIN_RECV_BUFFER_SIZE: usize = 2048;
/// Low-level synchronous IRTT client.
///
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
/// session, send probes, receive replies, poll timeouts, and close. Callers
/// that do not need to own this loop can use [`ManagedClient`](crate::ManagedClient)
/// instead.
#[derive(Debug)]
pub struct Client {
config: ClientConfig,
@@ -45,6 +51,11 @@ pub struct Client {
}
impl Client {
/// Resolve the configured server and create a connected UDP socket.
///
/// This validates local configuration and prepares the open request, but it
/// does not contact the server. Call [`open`](Self::open) to perform the
/// IRTT open exchange.
pub fn connect(config: ClientConfig) -> Result<Self, ClientError> {
validate_open_timeouts(&config.open_timeouts)?;
if config.max_pending_probes == 0 {
@@ -73,7 +84,12 @@ impl Client {
})
}
pub fn open(&mut self, now: ClientTimestamp) -> Result<OpenOutcome, ClientError> {
/// Perform the IRTT open exchange.
///
/// 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`].
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
match self.phase {
ClientPhase::Connected => {}
ClientPhase::Open { .. } => return Err(ClientError::AlreadyOpen),
@@ -81,10 +97,44 @@ impl Client {
ClientPhase::NoTestCompleted => return Err(ClientError::AlreadyCompleted),
}
let outcome = self.open_inner(now);
let outcome = (|| -> Result<OpenOutcome, ClientError> {
let request = OpenRequest {
params: self.requested.clone(),
close: self.config.run_mode == RunMode::NoTest,
};
let packet = encode_open_request(&request, self.config.hmac_key.as_deref())?;
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
for timeout in &self.config.open_timeouts {
self.socket.set_read_timeout(Some(*timeout))?;
self.socket.send(&packet)?;
match self.socket.recv(&mut buf) {
Ok(size) => {
let reply = irtt_proto::decode_open_reply(
&buf[..size],
self.config.hmac_key.as_deref(),
)?;
return self.accept_open_reply(reply, ClientTimestamp::now());
}
Err(err)
if matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => {}
Err(err) => return Err(ClientError::Socket(err)),
}
}
Err(ClientError::OpenTimeout)
})();
let restore = self
.socket
.set_read_timeout(self.config.socket_config.recv_timeout);
match (outcome, restore) {
(Ok(outcome), Ok(())) => Ok(outcome),
(Ok(_), Err(source)) => Err(ClientError::ReadTimeoutRestore { source }),
@@ -93,39 +143,11 @@ impl Client {
}
}
fn open_inner(&mut self, _now: ClientTimestamp) -> Result<OpenOutcome, ClientError> {
let request = OpenRequest {
params: self.requested.clone(),
close: self.config.run_mode == RunMode::NoTest,
};
let packet = encode_open_request(&request, self.config.hmac_key.as_deref())?;
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
for timeout in &self.config.open_timeouts {
self.socket.set_read_timeout(Some(*timeout))?;
self.socket.send(&packet)?;
match self.socket.recv(&mut buf) {
Ok(size) => {
let reply = irtt_proto::decode_open_reply(
&buf[..size],
self.config.hmac_key.as_deref(),
)?;
return self.accept_open_reply(reply, ClientTimestamp::now());
}
Err(err)
if matches!(
err.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => {}
Err(err) => return Err(ClientError::Socket(err)),
}
}
Err(ClientError::OpenTimeout)
}
pub fn close(&mut self, now: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
/// Send a close request and emit a [`ClientEvent::SessionClosed`] event.
///
/// The close event means the client has sent its close packet and stopped
/// tracking the session locally; it is not a server acknowledgement.
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
let token = match self.phase {
ClientPhase::Open { token } => token,
ClientPhase::Closed => return Err(ClientError::AlreadyClosed),
@@ -147,10 +169,12 @@ impl Client {
Ok(vec![ClientEvent::SessionClosed {
remote: self.remote,
token,
at: now,
at: ClientTimestamp::now(),
}])
}
/// Return the monotonic deadline for the next probe send, if another probe
/// is scheduled.
pub fn next_send_deadline(&self) -> Option<Instant> {
let session = self.session.as_ref()?;
if session.sending_done {
@@ -159,14 +183,24 @@ impl Client {
Some(session.next_send_at)
}
/// Return the local timeout used to classify pending probes as lost.
pub fn probe_timeout(&self) -> Duration {
self.config.probe_timeout
}
/// Send one echo probe if the negotiated run is still active.
///
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
/// list when the run duration has elapsed and no further probe should be
/// sent.
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_inner(None)
}
/// Receive and classify at most one datagram from the socket.
///
/// Returns an empty event list when the socket read would block or times
/// out. Malformed or unrelated datagrams are reported as warning events.
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.recv_once_inner(None)
}
@@ -208,7 +242,6 @@ impl Client {
.expect("negotiated must exist when Open");
let wire_seq = session.next_wire_seq;
let logical_seq = session.next_logical_seq;
let scheduled_at = session.next_send_at;
let request = EchoRequest {
@@ -230,7 +263,6 @@ impl Client {
.expect("session must exist when phase is Open");
let pending = PendingProbe {
logical_seq,
wire_seq,
sent_at,
timeout_at: sent_at
@@ -241,13 +273,6 @@ impl Client {
session.pending.insert(pending)?;
session.next_wire_seq = session.next_wire_seq.wrapping_add(1);
session.next_logical_seq =
session
.next_logical_seq
.checked_add(1)
.ok_or(ClientError::CounterOverflow {
counter: "next_logical_seq",
})?;
session.packets_sent =
session
.packets_sent
@@ -277,7 +302,6 @@ impl Client {
Ok(vec![ClientEvent::EchoSent {
seq: wire_seq,
logical_seq,
remote: self.remote,
scheduled_at,
sent_at,
@@ -317,11 +341,14 @@ impl Client {
return Ok(vec![ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
message: "dropped malformed or unrelated packet".to_owned(),
at: now,
}]);
};
self.process_echo_reply(reply, packet_len, now, datagram.meta)
}
/// Receive and classify datagrams until a receive produces no events or the
/// receive budget is exhausted.
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
let mut all_events = Vec::new();
for _ in 0..budget.max_packets {
@@ -334,12 +361,19 @@ impl Client {
Ok(all_events)
}
#[cfg(test)]
fn recv_buffer_size(&self) -> usize {
recv_buffer_size(self.config.hmac_key.is_some(), self.negotiated.as_ref())
/// Polls for probes that have timed out as of the current monotonic time.
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
self.poll_timeouts_at(Instant::now())
}
pub fn poll_timeouts(&mut self, now: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
/// Polls for probes that have timed out as of `now`.
///
/// This is useful for callers that drive `Client` from their own event loop and
/// want timeout decisions to use the same sampled `Instant` as their scheduling
/// logic.
///
/// `now` is monotonic time only; wall-clock time is not used for timeout expiry.
pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
match self.phase {
ClientPhase::Open { .. } => {}
ClientPhase::Closed => return Err(ClientError::AlreadyClosed),
@@ -352,12 +386,11 @@ impl Client {
.as_mut()
.expect("session must exist when phase is Open");
let expired = session.pending.drain_expired(now.mono);
let expired = session.pending.drain_expired(now);
let mut events = Vec::with_capacity(expired.len());
for probe in expired {
events.push(ClientEvent::EchoLoss {
seq: probe.wire_seq,
logical_seq: probe.logical_seq,
sent_at: probe.sent_at,
timeout_at: probe.timeout_at,
});
@@ -367,6 +400,11 @@ impl Client {
Ok(events)
}
/// Return whether the current run has completed.
///
/// A normal run is complete once no more probes will be sent and all
/// pending probes have either replied or timed out. No-test and closed
/// sessions are also considered complete.
pub fn is_run_complete(&self) -> bool {
let Some(session) = self.session.as_ref() else {
return matches!(
@@ -389,23 +427,6 @@ impl Client {
.map_or(0, |session| session.packets_sent)
}
#[cfg(test)]
fn process_received_packet(
&mut self,
packet: &[u8],
now: ClientTimestamp,
meta: ReceiveMeta,
) -> Result<Vec<ClientEvent>, ClientError> {
let packet_len = packet.len();
let Some(reply) = self.decode_received_packet(packet) else {
return Ok(vec![ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
message: "dropped malformed or unrelated packet".to_owned(),
}]);
};
self.process_echo_reply(reply, packet_len, now, meta)
}
fn decode_received_packet(&self, packet: &[u8]) -> Option<EchoReply> {
let negotiated = self
.negotiated
@@ -433,6 +454,7 @@ impl Client {
"dropped reply with wrong token: expected {token:#x}, got {:#x}",
reply.token
),
at: now,
}]);
}
@@ -450,14 +472,13 @@ impl Client {
.is_some_and(|h| sequence_is_before(wire_seq, h));
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(session, wire_seq);
session.completed.insert(wire_seq, pending.logical_seq);
update_highest_received(&mut session.highest_received_seq, wire_seq);
session.completed.insert(wire_seq);
let mut events = Vec::new();
if is_late {
events.push(ClientEvent::LateReply {
seq: wire_seq,
logical_seq: Some(pending.logical_seq),
highest_seen,
remote: self.remote,
sent_at: Some(pending.sent_at),
@@ -472,7 +493,6 @@ impl Client {
} else {
events.push(ClientEvent::EchoReply {
seq: wire_seq,
logical_seq: pending.logical_seq,
remote: self.remote,
sent_at: pending.sent_at,
received_at: now,
@@ -489,7 +509,7 @@ impl Client {
}
Ok(events)
} else if session.completed.contains(wire_seq) {
update_highest_received(session, wire_seq);
update_highest_received(&mut session.highest_received_seq, wire_seq);
Ok(vec![ClientEvent::DuplicateReply {
seq: wire_seq,
remote: self.remote,
@@ -502,12 +522,11 @@ impl Client {
let one_way = compute_one_way(&timed_out.sent_at, &now, &reply.timestamps);
let received_stats = build_received_stats(&reply);
let highest_seen = session.highest_received_seq.unwrap_or(wire_seq);
update_highest_received(session, wire_seq);
session.completed.insert(wire_seq, timed_out.logical_seq);
update_highest_received(&mut session.highest_received_seq, wire_seq);
session.completed.insert(wire_seq);
let mut events = vec![ClientEvent::LateReply {
seq: wire_seq,
logical_seq: Some(timed_out.logical_seq),
highest_seen,
remote: self.remote,
sent_at: Some(timed_out.sent_at),
@@ -529,7 +548,6 @@ impl Client {
{
Ok(vec![ClientEvent::LateReply {
seq: wire_seq,
logical_seq: None,
highest_seen: session.highest_received_seq.unwrap(),
remote: self.remote,
sent_at: None,
@@ -547,6 +565,7 @@ impl Client {
message: format!(
"dropped reply with untracked seq {wire_seq} (no pending or completed entry)"
),
at: now,
}])
}
}
@@ -601,15 +620,13 @@ impl Client {
reply: OpenReply,
now: ClientTimestamp,
) -> Result<OpenOutcome, ClientError> {
validate_negotiated_params(
let token = reply.token;
let negotiated = negotiate_params(
&self.requested,
&reply.params,
reply.params,
self.config.negotiation_policy,
)?;
let negotiated = NegotiatedParams {
params: reply.params.clone(),
};
let recv_buffer_size = recv_buffer_size(self.config.hmac_key.is_some(), Some(&negotiated));
let recv_buffer_size = recv_buffer_size(self.config.hmac_key.is_some(), Some(&negotiated))?;
let negotiated_dscp =
u8::try_from(negotiated.params.dscp).map_err(|_| ClientError::InvalidConfig {
reason: "negotiated dscp must be in range 0..=63".to_owned(),
@@ -617,7 +634,7 @@ impl Client {
apply_dscp_to_socket(&self.socket, self.remote, negotiated_dscp)?;
self.negotiated = Some(negotiated.clone());
self.recv_buffer.resize(recv_buffer_size, 0);
self.phase = ClientPhase::Open { token: reply.token };
self.phase = ClientPhase::Open { token };
let end_mono = if negotiated.params.duration_ns > 0 {
Some(negotiated_end_mono(
@@ -630,7 +647,6 @@ impl Client {
self.session = Some(ActiveSession {
next_wire_seq: 0,
next_logical_seq: 0,
highest_received_seq: None,
packets_sent: 0,
start_mono: now.mono,
@@ -644,14 +660,14 @@ impl Client {
let event = ClientEvent::SessionStarted {
remote: self.remote,
token: reply.token,
token,
negotiated: negotiated.clone(),
at: now,
};
Ok(OpenOutcome::Started {
remote: self.remote,
token: reply.token,
token,
negotiated,
event,
})
@@ -662,14 +678,11 @@ impl Client {
reply: OpenReply,
now: ClientTimestamp,
) -> Result<OpenOutcome, ClientError> {
validate_negotiated_params(
let negotiated = negotiate_params(
&self.requested,
&reply.params,
reply.params,
self.config.negotiation_policy,
)?;
let negotiated = NegotiatedParams {
params: reply.params.clone(),
};
self.negotiated = Some(negotiated.clone());
self.phase = ClientPhase::NoTestCompleted;
let event = ClientEvent::NoTestCompleted {
@@ -690,14 +703,10 @@ impl Client {
fn send_probe_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
self.send_probe_inner(Some(ts))
}
fn recv_once_at(&mut self, ts: ClientTimestamp) -> Result<Vec<ClientEvent>, ClientError> {
self.recv_once_inner(Some(ts))
}
}
fn update_highest_received(session: &mut ActiveSession, wire_seq: u32) {
session.highest_received_seq = Some(session.highest_received_seq.map_or(wire_seq, |h| {
fn update_highest_received(highest_received_seq: &mut Option<u32>, wire_seq: u32) {
*highest_received_seq = Some(highest_received_seq.map_or(wire_seq, |h| {
if sequence_is_after(wire_seq, h) {
wire_seq
} else {
@@ -733,13 +742,16 @@ fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
.unwrap_or(Duration::ZERO)
}
fn recv_buffer_size(has_hmac: bool, negotiated: Option<&NegotiatedParams>) -> usize {
match negotiated {
Some(negotiated) => echo_packet_len(has_hmac, &negotiated.params)
pub(crate) fn recv_buffer_size(
has_hmac: bool,
negotiated: Option<&NegotiatedParams>,
) -> Result<usize, ClientError> {
Ok(match negotiated {
Some(negotiated) => echo_packet_len(has_hmac, &negotiated.params)?
.saturating_add(1)
.max(MIN_RECV_BUFFER_SIZE),
None => MIN_RECV_BUFFER_SIZE,
}
})
}
fn compute_rtt(
@@ -754,22 +766,14 @@ fn compute_rtt(
let server_processing = compute_server_processing(ts);
let adjusted = server_processing.and_then(|sp| raw.checked_sub(sp));
let effective = adjusted.unwrap_or(raw);
let adjusted_signed = server_processing.map(|sp| SignedDuration {
ns: duration_ns_i128(raw) - duration_ns_i128(sp),
});
let effective_signed = adjusted_signed.unwrap_or(SignedDuration {
ns: duration_ns_i128(raw),
});
let adjusted = server_processing
.map(|sp| SignedDuration::from_nanos(duration_ns_i128(raw) - duration_ns_i128(sp)));
let effective = adjusted.unwrap_or_else(|| SignedDuration::from_duration(raw));
RttSample {
raw,
adjusted,
effective,
adjusted_signed,
effective_signed,
}
}
@@ -824,11 +828,11 @@ fn compute_one_way(
let c2s = server_recv_wall
.zip(client_send_ns)
.and_then(|(srv, cli)| srv.checked_sub(cli))
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos));
.map(|d| SignedDuration::from_nanos(i128::from(d)));
let s2c = client_recv_ns
.zip(server_send_wall)
.and_then(|(cli, srv)| cli.checked_sub(srv))
.and_then(|d| u64::try_from(d).ok().map(Duration::from_nanos));
.map(|d| SignedDuration::from_nanos(i128::from(d)));
if c2s.is_none() && s2c.is_none() {
return None;
+9 -37
View File
@@ -11,12 +11,9 @@ fn open_fails_after_close() {
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.close(ClientTimestamp::now()).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::AlreadyClosed)
));
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(client.open(), Err(ClientError::AlreadyClosed)));
server.join();
}
@@ -31,8 +28,8 @@ fn close_sends_one_close_packet_with_negotiated_token() {
let _ = recv_request(&socket, &tx);
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
let events = client.close(ClientTimestamp::now()).unwrap();
assert_open_started(client.open().unwrap());
let events = client.close().unwrap();
assert_eq!(events.len(), 1);
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
@@ -62,8 +59,8 @@ fn send_probe_fails_after_close() {
}
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.close(ClientTimestamp::now()).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyClosed)
@@ -71,31 +68,6 @@ fn send_probe_fails_after_close() {
server.join();
}
#[test]
fn close_clears_timed_out_metadata() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
client.poll_timeouts(ClientTimestamp::now()).unwrap();
assert_eq!(client.session.as_ref().unwrap().timed_out.len(), 1);
client.close(ClientTimestamp::now()).unwrap();
assert!(client.session.is_none());
server.join();
}
#[test]
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
let params = default_params();
@@ -132,7 +104,7 @@ fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
@@ -170,6 +142,6 @@ fn normal_echo_reply_does_not_close_session() {
assert!(client.next_send_deadline().is_some());
assert!(client.send_probe().is_ok());
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
server.join();
}
+80 -98
View File
@@ -51,7 +51,7 @@ fn params_from_config_maps_compatibility_fields() {
}
#[test]
fn params_from_config_accepts_udp_payload_length_boundary_values() {
fn params_from_config_accepts_boundary_values() {
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
let config = ClientConfig {
length,
@@ -62,18 +62,12 @@ fn params_from_config_accepts_udp_payload_length_boundary_values() {
i64::from(length)
);
}
}
#[test]
fn params_from_config_rejects_oversized_udp_payload_length() {
let config = ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
dscp: 63,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason.contains("packet length")
));
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
}
#[test]
@@ -86,97 +80,85 @@ fn params_from_config_encodes_continuous_duration_as_zero() {
}
#[test]
fn params_from_config_rejects_zero_finite_duration() {
let config = ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "duration must be greater than zero; use None for continuous mode"
));
}
#[test]
fn params_from_config_rejects_zero_interval() {
let config = ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason == "interval must be greater than zero"
));
}
#[test]
fn params_from_config_rejects_nanosecond_encoding_overflow_as_invalid_config() {
fn params_from_config_rejects_invalid_values() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let config = ClientConfig {
duration: Some(Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1)),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "duration is too large to encode as nanoseconds"
));
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let cases = [
(
"oversized UDP payload",
ClientConfig {
length: MAX_UDP_PAYLOAD_LENGTH + 1,
..ClientConfig::default()
},
"packet length",
),
(
"zero finite duration",
ClientConfig {
duration: Some(Duration::ZERO),
..ClientConfig::default()
},
"duration must be greater than zero; use None for continuous mode",
),
(
"zero interval",
ClientConfig {
interval: Duration::ZERO,
..ClientConfig::default()
},
"interval must be greater than zero",
),
(
"duration nanosecond overflow",
ClientConfig {
duration: Some(too_large),
..ClientConfig::default()
},
"duration is too large to encode as nanoseconds",
),
(
"interval nanosecond overflow",
ClientConfig {
interval: too_large,
..ClientConfig::default()
},
"interval is too large to encode as nanoseconds",
),
(
"invalid DSCP codepoint",
ClientConfig {
dscp: 64,
..ClientConfig::default()
},
"dscp",
),
(
"empty server fill",
ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
(
"oversized server fill",
ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
},
"server_fill",
),
];
let config = ClientConfig {
interval: Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason })
if reason == "interval is too large to encode as nanoseconds"
));
}
#[test]
fn params_from_config_accepts_max_dscp_codepoint() {
let config = ClientConfig {
dscp: 63,
..ClientConfig::default()
};
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
}
#[test]
fn params_from_config_rejects_invalid_dscp_codepoint() {
let config = ClientConfig {
dscp: 64,
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn params_from_config_rejects_empty_server_fill() {
let config = ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn params_from_config_rejects_oversized_server_fill() {
let config = ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { .. })
));
for (name, config, expected_reason) in cases {
assert!(
matches!(
params_from_config(&config),
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
),
"{name} should fail with InvalidConfig containing {expected_reason:?}"
);
}
}
#[test]
+10 -3
View File
@@ -26,13 +26,13 @@ fn normal_open_applies_negotiated_dscp_after_open_and_close_clears_it() {
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
0
);
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
184
);
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(),
0
@@ -58,8 +58,15 @@ fn normal_open_uses_negotiated_dscp_not_requested_dscp() {
config.negotiation_policy = NegotiationPolicy::Loose;
let mut client = Client::connect(config).unwrap();
let negotiated = assert_open_started(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params.dscp, 0);
assert_eq!(
negotiated.restrictions,
vec![crate::NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}]
);
assert_eq!(
socket_traffic_class(&client.socket, client.remote).unwrap(),
0
+7 -7
View File
@@ -14,7 +14,7 @@ fn hmac_open_success() {
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
server.join();
}
@@ -32,7 +32,7 @@ fn hmac_open_rejects_missing_hmac() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::Protocol(
irtt_proto::ProtoError::HmacPresenceMismatch
))
@@ -55,7 +55,7 @@ fn hmac_open_rejects_bad_hmac() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::Protocol(irtt_proto::ProtoError::BadHmac))
));
server.join();
@@ -76,8 +76,8 @@ fn hmac_close_packet_includes_valid_hmac() {
});
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
client.close(ClientTimestamp::now()).unwrap();
assert_open_started(client.open().unwrap());
client.close().unwrap();
let packets: Vec<_> = server.rx.iter().take(2).collect();
let close = &packets[1];
assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC);
@@ -125,7 +125,7 @@ fn bad_hmac_reply_is_dropped() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let events = client.recv_once().unwrap();
@@ -174,7 +174,7 @@ fn hmac_echo_request_reply_works() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
+2 -2
View File
@@ -1,8 +1,8 @@
use super::*;
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
use irtt_proto::{
echo_packet_len, flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_hmac, Clock,
ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_hmac, Clock, ReceivedStats,
StampAt, HMAC_SIZE, MAGIC,
};
use std::{thread, time::SystemTime};
@@ -1,32 +1,72 @@
use super::*;
use crate::NegotiationRestriction;
fn assert_negotiates(
requested: &Params,
returned: &Params,
policy: NegotiationPolicy,
) -> NegotiatedParams {
negotiate_params(requested, returned.clone(), policy)
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
}
fn rejection_reason(requested: &Params, returned: &Params, policy: NegotiationPolicy) -> String {
match negotiate_params(requested, returned.clone(), policy) {
Err(ClientError::NegotiationRejected { reason }) => reason,
other => panic!("expected negotiation rejection, got {other:?}"),
}
}
#[test]
fn strict_negotiation_accepts_identical_params() {
let config = ClientConfig::default();
let params = params_from_config(&config).unwrap();
assert!(validate_negotiated_params(&params, &params, NegotiationPolicy::Strict).is_ok());
let negotiated = assert_negotiates(&params, &params, NegotiationPolicy::Strict);
assert_eq!(negotiated.params, params);
assert!(negotiated.restrictions.is_empty());
}
#[test]
fn strict_negotiation_rejects_changed_params() {
let config = ClientConfig::default();
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 1;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Strict),
Err(ClientError::NegotiationRejected { .. })
));
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: requested.dscp,
negotiated: returned.dscp,
}
.message()
);
}
#[test]
fn loose_negotiation_accepts_server_restricted_params() {
let config = ClientConfig::default();
let requested = params_from_config(&config).unwrap();
let mut requested = params_from_config(&config).unwrap();
requested.length = 128;
let mut returned = requested.clone();
returned.duration_ns /= 2;
returned.length = 0;
assert!(validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose).is_ok());
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![
NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
},
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
},
]
);
}
#[test]
@@ -37,11 +77,10 @@ fn loose_negotiation_rejects_non_positive_returned_interval() {
for interval_ns in [0, -1] {
let mut returned = requested.clone();
returned.interval_ns = interval_ns;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason })
if reason == "interval must be positive"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"interval must be positive"
);
}
}
@@ -51,21 +90,28 @@ fn loose_negotiation_accepts_documented_duration_combinations() {
let requested_finite = params_from_config(&finite_config).unwrap();
let returned_same_finite = requested_finite.clone();
assert!(validate_negotiated_params(
assert!(assert_negotiates(
&requested_finite,
&returned_same_finite,
NegotiationPolicy::Loose
)
.is_ok());
.restrictions
.is_empty());
let mut returned_shorter_finite = requested_finite.clone();
returned_shorter_finite.duration_ns /= 2;
assert!(validate_negotiated_params(
&requested_finite,
&returned_shorter_finite,
NegotiationPolicy::Loose
)
.is_ok());
assert_eq!(
assert_negotiates(
&requested_finite,
&returned_shorter_finite,
NegotiationPolicy::Loose
)
.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested_finite.duration_ns,
negotiated_ns: returned_shorter_finite.duration_ns,
}]
);
let continuous_config = ClientConfig {
duration: None,
@@ -74,21 +120,28 @@ fn loose_negotiation_accepts_documented_duration_combinations() {
let requested_continuous = params_from_config(&continuous_config).unwrap();
let returned_continuous = requested_continuous.clone();
assert!(validate_negotiated_params(
assert!(assert_negotiates(
&requested_continuous,
&returned_continuous,
NegotiationPolicy::Loose
)
.is_ok());
.restrictions
.is_empty());
let mut returned_finite = requested_continuous.clone();
returned_finite.duration_ns = 1_000_000_000;
assert!(validate_negotiated_params(
&requested_continuous,
&returned_finite,
NegotiationPolicy::Loose
)
.is_ok());
assert_eq!(
assert_negotiates(
&requested_continuous,
&returned_finite,
NegotiationPolicy::Loose
)
.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested_continuous.duration_ns,
negotiated_ns: returned_finite.duration_ns,
}]
);
}
#[test]
@@ -98,11 +151,10 @@ fn loose_negotiation_rejects_finite_request_returned_continuous() {
let mut returned = requested.clone();
returned.duration_ns = 0;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason })
if reason == "server returned continuous duration for finite request"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"server returned continuous duration for finite request"
);
}
#[test]
@@ -111,10 +163,10 @@ fn loose_negotiation_rejects_negative_returned_length() {
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.length = -1;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason }) if reason == "length must be non-negative"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"length must be non-negative"
);
}
#[test]
@@ -123,10 +175,10 @@ fn strict_negotiation_rejects_negative_returned_length() {
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.length = -1;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Strict),
Err(ClientError::NegotiationRejected { reason }) if reason == "length must be non-negative"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
"length must be non-negative"
);
}
#[test]
@@ -137,10 +189,80 @@ fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
for dscp in [-1, 64] {
let mut returned = requested.clone();
returned.dscp = dscp;
assert!(matches!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
Err(ClientError::NegotiationRejected { reason })
if reason == "dscp must be in range 0..=63"
));
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"dscp must be in range 0..=63"
);
}
}
#[test]
fn loose_negotiation_records_dscp_disabled_by_server() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 0;
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}]
);
}
#[test]
fn strict_negotiation_rejects_dscp_disabled_by_server_as_specific_restriction() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: 46,
negotiated: 0,
}
.message()
);
}
#[test]
fn negotiation_rejects_unsupported_dscp_changes() {
let config = ClientConfig {
dscp: 46,
..ClientConfig::default()
};
let requested = params_from_config(&config).unwrap();
let mut returned = requested.clone();
returned.dscp = 8;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
"server returned unsupported DSCP change"
);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
"server returned unsupported DSCP change"
);
let zero_config = ClientConfig::default();
let zero_requested = params_from_config(&zero_config).unwrap();
let mut returned = zero_requested.clone();
returned.dscp = 46;
assert_eq!(
rejection_reason(&zero_requested, &returned, NegotiationPolicy::Loose),
"server returned unsupported DSCP change"
);
}
+17 -30
View File
@@ -1,23 +1,5 @@
use super::*;
#[test]
fn no_test_open_close_succeeds_on_open_reply_close() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
let params = params_from_config(&config).unwrap();
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(client.negotiated.as_ref(), Some(&negotiated));
assert!(matches!(
client.close(ClientTimestamp::now()),
Err(ClientError::NotOpen)
));
server.join();
}
#[test]
fn no_test_success_validates_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -26,7 +8,7 @@ fn no_test_success_validates_params() {
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
server.join();
}
@@ -40,7 +22,7 @@ fn no_test_rejects_non_close_open_reply() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::UnexpectedNoTestReply)
));
server.join();
@@ -55,7 +37,7 @@ fn no_test_rejects_non_zero_token_with_close_reply() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
));
server.join();
@@ -71,7 +53,7 @@ fn no_test_strict_negotiation_rejects_changed_params() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
@@ -82,13 +64,21 @@ fn no_test_loose_negotiation_accepts_restricted_params() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
config.run_mode = RunMode::NoTest;
config.negotiation_policy = NegotiationPolicy::Loose;
let mut params = params_from_config(&config).unwrap();
let requested = params_from_config(&config).unwrap();
let mut params = requested.clone();
params.duration_ns /= 2;
let server = no_test_server(params.clone(), 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
let negotiated = assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_no_test_completed(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert_eq!(
negotiated.restrictions,
vec![crate::NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: params.duration_ns,
}]
);
server.join();
}
@@ -100,7 +90,7 @@ fn send_probe_fails_after_no_test_completed() {
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
assert_no_test_completed(client.open().unwrap());
assert!(matches!(
client.send_probe(),
Err(ClientError::AlreadyCompleted)
@@ -116,10 +106,7 @@ fn open_fails_after_no_test_completed() {
let server = no_test_server(params, 0);
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert_no_test_completed(client.open(ClientTimestamp::now()).unwrap());
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::AlreadyCompleted)
));
assert_no_test_completed(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
server.join();
}
+7 -52
View File
@@ -1,16 +1,5 @@
use super::*;
#[test]
fn address_resolution_connects_to_local_fake_server() {
let server = start_fake_server(|socket, tx| {
let _ = recv_request(&socket, &tx);
});
let client = Client::connect(default_test_config(server.addr)).unwrap();
client.socket.send(b"ping").unwrap();
assert_eq!(server.rx.recv().unwrap(), b"ping");
server.join();
}
#[test]
fn successful_open_handshake() {
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -18,9 +7,8 @@ fn successful_open_handshake() {
let server = open_success_server(params.clone());
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
let negotiated = assert_open_started(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_open_started(client.open().unwrap());
assert_eq!(negotiated.params, params);
assert!(matches!(client.phase, ClientPhase::Open { token: TOKEN }));
server.join();
}
@@ -30,11 +18,8 @@ fn open_fails_when_already_open() {
let params = params_from_config(&config).unwrap();
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::AlreadyOpen)
));
assert_open_started(client.open().unwrap());
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
server.join();
}
@@ -53,7 +38,7 @@ fn open_retries_after_first_timeout() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_open_started(outcome);
assert_eq!(server.rx.iter().take(2).count(), 2);
server.join();
@@ -67,38 +52,11 @@ fn open_timeout_after_all_timeouts() {
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
assert_eq!(server.rx.iter().take(2).count(), 2);
server.join();
}
#[test]
fn open_restores_configured_read_timeout_after_timeout() {
let server = timeout_server(Duration::from_millis(700));
let config = ClientConfig {
open_timeouts: vec![Duration::from_millis(200)],
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(450)),
..crate::SocketConfig::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let start = std::time::Instant::now();
let mut buf = [0_u8; 1];
assert!(client.socket.recv(&mut buf).is_err());
assert!(start.elapsed() >= Duration::from_millis(350));
server.join();
}
#[test]
fn protocol_version_mismatch_fails() {
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
@@ -108,7 +66,7 @@ fn protocol_version_mismatch_fails() {
let server = open_success_server(params);
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
));
server.join();
@@ -124,9 +82,6 @@ fn server_rejection_fails_in_normal_mode() {
socket.send_to(&reply, peer).unwrap();
});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::ServerRejected)
));
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
server.join();
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
use super::*;
#[test]
fn recv_available_drains_burst_replies() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let events = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(events.len(), 3);
for (seq, event) in events.iter().enumerate() {
assert!(matches!(
event,
ClientEvent::EchoReply {
seq: actual_seq,
..
} if *actual_seq == seq as u32
));
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_available_respects_packet_budget() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(80));
let first = client
.recv_available(RecvBudget { max_packets: 1 })
.unwrap();
assert_eq!(first.len(), 1);
assert!(matches!(&first[0], ClientEvent::EchoReply { seq: 0, .. }));
let second = client
.recv_available(RecvBudget { max_packets: 8 })
.unwrap();
assert_eq!(second.len(), 1);
assert!(matches!(&second[0], ClientEvent::EchoReply { seq: 1, .. }));
client.close().unwrap();
server.join();
}
@@ -0,0 +1,29 @@
use super::*;
use support::echo_packet_len;
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
fn assert_packet_meta_unavailable(packet_meta: &crate::event::PacketMeta) {
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn metadata_unavailable_skip(test_name: &str) {
eprintln!("{test_name}: skipping metadata assertion because kernel did not provide traffic class metadata");
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn kernel_rx_timestamp_unavailable_skip(test_name: &str) {
eprintln!("{test_name}: skipping kernel timestamp assertion because kernel did not provide SCM_TIMESTAMPNS");
}
mod burst_recv;
mod packet_length;
mod probe_schedule;
mod reply_classification;
mod reply_decode;
mod reply_metadata;
mod timeouts;
mod timing_math;
@@ -0,0 +1,190 @@
use super::*;
#[test]
fn minimal_negotiated_layout_works() {
let params = Params {
protocol_version: 1,
duration_ns: 3_000_000_000,
interval_ns: 1_000_000_000,
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
clock: Clock::Both,
..Params::default()
};
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
received_stats: ReceivedStats::None,
stamp_at: StampAt::None,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
if let ClientEvent::EchoReply {
received_stats,
server_timing,
..
} = &events[0]
{
assert!(received_stats.is_none());
assert!(server_timing.is_none());
} else {
panic!("expected EchoReply");
}
server.join();
}
// ---------- Regression tests ----------
#[test]
fn short_echo_reply_does_not_emit_echo_reply() {
let params = Params {
length: 64,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.truncate(echo_packet_len(false, &params) - 1);
socket.send_to(&reply, peer).unwrap();
}
});
let config = ClientConfig {
length: 64,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn overlong_datagram_detection_uses_extra_receive_byte() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let mut reply =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
reply.push(0);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::Warning {
kind: WarningKind::MalformedOrUnrelatedPacket,
..
}]
));
client.close().unwrap();
server.join();
}
#[test]
fn exact_length_echo_reply_still_emits_echo_reply() {
let params = Params {
length: 4096,
..default_params()
};
let server = start_fake_server({
let params = params.clone();
move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let (request, _) = recv_request(&socket, &tx);
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
let reply = echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply, peer).unwrap();
let _ = recv_request_timeout(&socket, &tx);
}
});
let config = ClientConfig {
length: 4096,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::EchoReply { bytes, .. }] if *bytes == echo_packet_len(false, &params)
));
client.close().unwrap();
server.join();
}
@@ -0,0 +1,121 @@
use super::*;
#[test]
fn send_probe_fails_before_open() {
let server = start_fake_server(|_socket, _tx| {});
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
server.join();
}
#[test]
fn send_probe_sends_valid_echo_request() {
let params = default_params();
let server = silent_open_server(params.clone());
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.send_probe().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoSent {
seq, remote, bytes, ..
} => {
assert_eq!(*seq, 0);
assert_eq!(*remote, server.addr);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
thread::sleep(Duration::from_millis(30));
let packets: Vec<_> = server.rx.try_iter().collect();
let echo_reqs: Vec<_> = packets
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
.collect();
let echo_req = echo_reqs.first().unwrap();
assert_eq!(&echo_req[..3], &MAGIC);
assert_eq!(echo_req[3], 0x00);
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
assert_eq!(req_token, TOKEN);
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
assert_eq!(seq, 0);
client.close().unwrap();
server.join();
}
#[test]
fn send_probe_respects_finite_duration_exclusive_end() {
let params = Params {
protocol_version: 1,
duration_ns: 1_000_000_000,
interval_ns: 500_000_000,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::Both,
clock: Clock::Both,
..Params::default()
};
let server = silent_open_server(params.clone());
let config = ClientConfig {
duration: Some(Duration::from_secs(1)),
interval: Duration::from_millis(500),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let start = client.next_send_deadline().unwrap();
let interval = Duration::from_millis(500);
let now0 = ClientTimestamp {
mono: start,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now0).is_ok());
let now1 = ClientTimestamp {
mono: start + interval,
wall: SystemTime::now(),
};
assert!(client.send_probe_at(now1).is_ok());
let now2 = ClientTimestamp {
mono: start + Duration::from_secs(1),
wall: SystemTime::now(),
};
let events = client.send_probe_at(now2).unwrap();
assert!(events.is_empty());
assert!(client.next_send_deadline().is_none());
client.close().unwrap();
server.join();
}
#[test]
fn connect_rejects_invalid_probe_limits() {
for config in [
ClientConfig {
max_pending_probes: 0,
..ClientConfig::default()
},
ClientConfig {
probe_timeout: Duration::ZERO,
..ClientConfig::default()
},
] {
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
@@ -0,0 +1,113 @@
use super::*;
#[test]
fn duplicate_reply_emits_duplicate_event() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
let ts = TimestampFields::default();
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
thread::sleep(Duration::from_millis(10));
socket.send_to(&reply_packet, peer).unwrap();
}
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events1 = client.recv_once().unwrap();
assert_eq!(events1.len(), 1);
assert!(matches!(&events1[0], ClientEvent::EchoReply { .. }));
thread::sleep(Duration::from_millis(30));
let events2 = client.recv_once().unwrap();
assert_eq!(events2.len(), 1);
assert!(matches!(
&events2[0],
ClientEvent::DuplicateReply { seq: 0, .. }
));
server.join();
}
#[test]
fn out_of_order_reply_emits_late_event() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let mut buf = [0_u8; 2048];
if let Ok((size, _)) = socket.recv_from(&mut buf) {
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
seqs.push(seq);
}
}
let ts = TimestampFields::default();
let reply1 = echo_reply_packet(TOKEN, seqs[1], &params, &ts, None);
socket.send_to(&reply1, peer).unwrap();
thread::sleep(Duration::from_millis(10));
let reply0 = echo_reply_packet(TOKEN, seqs[0], &params, &ts, None);
socket.send_to(&reply0, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let ev1 = client.recv_once().unwrap();
assert_eq!(ev1.len(), 1);
assert!(matches!(&ev1[0], ClientEvent::EchoReply { seq: 1, .. }));
thread::sleep(Duration::from_millis(30));
let ev2 = client.recv_once().unwrap();
assert_eq!(ev2.len(), 1);
match &ev2[0] {
ClientEvent::LateReply {
seq,
highest_seen,
rtt,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*highest_seen, 1);
assert!(rtt.is_some());
}
other => panic!("expected LateReply, got {other:?}"),
}
server.join();
}
@@ -0,0 +1,140 @@
use super::*;
#[test]
fn recv_once_returns_empty_on_timeout() {
let params = default_params();
let server = open_success_server(params);
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
let events = client.recv_once().unwrap();
assert!(events.is_empty());
server.join();
}
#[test]
fn recv_once_decodes_echo_reply_and_emits_event() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
seq,
rtt,
received_stats,
server_timing,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*bytes, echo_packet_len(false, &params));
assert!(rtt.raw > Duration::ZERO);
assert_eq!(
rtt.effective,
rtt.adjusted
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
);
assert!(received_stats.is_some());
let stats = received_stats.as_ref().unwrap();
assert_eq!(stats.count, Some(42));
assert_eq!(stats.window, Some(0x07));
assert!(server_timing.is_some());
let st = server_timing.as_ref().unwrap();
assert!(st.processing.is_some());
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
{
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[cfg(all(target_os = "linux", feature = "ancillary"))]
{
assert_eq!(
packet_meta.dscp,
packet_meta.traffic_class.map(|tc| tc >> 2)
);
assert_eq!(
packet_meta.ecn,
packet_meta.traffic_class.map(|tc| tc & 0b11)
);
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
let params = default_params();
let server_params = params.clone();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &server_params, None);
socket.send_to(&reply, peer).unwrap();
let mut seqs = Vec::new();
for _ in 0..2 {
let (packet, _) = recv_request(&socket, &tx);
seqs.push(u32::from_le_bytes(packet[12..16].try_into().unwrap()));
}
let mut longer = echo_reply_packet(
TOKEN,
seqs[0],
&server_params,
&TimestampFields::default(),
None,
);
longer.extend_from_slice(b"trailing bytes");
socket.send_to(&longer, peer).unwrap();
let shorter = echo_reply_packet(
TOKEN,
seqs[1],
&server_params,
&TimestampFields::default(),
None,
);
socket.send_to(&shorter, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let _ = client.recv_once().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { seq, bytes, .. } => {
assert_eq!(*seq, 1);
assert_eq!(*bytes, echo_packet_len(false, &params));
}
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
}
server.join();
}
@@ -0,0 +1,86 @@
#[cfg(all(target_os = "linux", feature = "ancillary"))]
use super::*;
#[test]
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
crate::socket_options::apply_dscp_to_socket(&socket, peer, 46).unwrap();
let reply_packet =
echo_reply_packet(TOKEN, seq, &params, &TimestampFields::default(), None);
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(traffic_class) = packet_meta.traffic_class else {
metadata_unavailable_skip(
"echo_reply_metadata_propagates_observed_dscp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert_eq!(traffic_class, 184);
assert_eq!(packet_meta.dscp, Some(46));
assert_eq!(packet_meta.ecn, Some(0));
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
#[test]
#[cfg(all(target_os = "linux", feature = "ancillary"))]
fn echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary() {
let params = default_params();
let (mut client, server) = open_client_with_echo_server(&params);
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(50));
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { packet_meta, .. } => {
let Some(timestamp) = packet_meta.kernel_rx_timestamp else {
kernel_rx_timestamp_unavailable_skip(
"echo_reply_metadata_propagates_kernel_rx_timestamp_with_ancillary",
);
client.close().unwrap();
server.join();
return;
};
assert!(timestamp.duration_since(SystemTime::UNIX_EPOCH).is_ok());
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
server.join();
}
@@ -0,0 +1,161 @@
use super::*;
#[test]
fn poll_timeouts_emits_echo_loss() {
let params = default_params();
let server = silent_open_server(params);
let config = ClientConfig {
probe_timeout: Duration::from_millis(100),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
let no_loss = client.poll_timeouts().unwrap();
assert!(no_loss.is_empty());
thread::sleep(Duration::from_millis(150));
let events = client.poll_timeouts().unwrap();
assert_eq!(events.len(), 2);
for event in &events {
assert!(matches!(event, ClientEvent::EchoLoss { .. }));
}
server.join();
}
#[test]
fn late_reply_after_timeout_preserves_measurement_metadata() {
let params = default_params();
let server = start_fake_server(move |socket, tx| {
let (_, peer) = recv_request(&socket, &tx);
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, None);
socket.send_to(&reply, peer).unwrap();
let mut buf = [0_u8; 2048];
let (size, _) = socket.recv_from(&mut buf).unwrap();
tx.send(buf[..size].to_vec()).unwrap();
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
thread::sleep(Duration::from_millis(90));
let ts = TimestampFields {
recv_wall: Some(1_000_000_000),
recv_mono: Some(100_000),
send_wall: Some(1_000_100_000),
send_mono: Some(200_000),
..Default::default()
};
let reply_packet = echo_reply_packet(TOKEN, seq, &params, &ts, None);
socket.send_to(&reply_packet, peer).unwrap();
socket.send_to(&reply_packet, peer).unwrap();
});
let config = ClientConfig {
probe_timeout: Duration::from_millis(40),
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(200)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(60));
let losses = client.poll_timeouts().unwrap();
assert!(matches!(&losses[0], ClientEvent::EchoLoss { seq: 0, .. }));
let late = client.recv_once().unwrap();
match &late[0] {
ClientEvent::LateReply {
seq,
sent_at,
rtt,
server_timing,
one_way,
bytes,
packet_meta,
..
} => {
assert_eq!(*seq, 0);
assert!(sent_at.is_some());
assert!(rtt.is_some());
assert!(server_timing.is_some());
assert!(one_way.is_some());
assert_eq!(*bytes, echo_packet_len(false, &default_params()));
let _ = packet_meta;
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
assert_packet_meta_unavailable(packet_meta);
}
other => panic!("expected stats-eligible LateReply, got {other:?}"),
}
let duplicate = client.recv_once().unwrap();
assert!(matches!(
&duplicate[0],
ClientEvent::DuplicateReply {
seq: 0,
bytes,
..
} if *bytes == echo_packet_len(false, &default_params())
));
client.close().unwrap();
server.join();
}
#[test]
fn pending_full_does_not_send_packet() {
let params = Params {
duration_ns: 60_000_000_000,
..default_params()
};
let server = silent_open_server(params);
let config = ClientConfig {
duration: Some(Duration::from_secs(60)),
max_pending_probes: 2,
socket_config: crate::SocketConfig {
recv_timeout: Some(Duration::from_millis(50)),
..Default::default()
},
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open().unwrap());
client.send_probe().unwrap();
client.send_probe().unwrap();
thread::sleep(Duration::from_millis(30));
let before_count: Vec<_> = server.rx.try_iter().collect();
let echo_before: Vec<_> = before_count
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(echo_before.len(), 2);
assert!(matches!(
client.send_probe(),
Err(ClientError::PendingLimitExceeded { limit: 2 })
));
thread::sleep(Duration::from_millis(30));
let after: Vec<_> = server.rx.try_iter().collect();
let echo_after: Vec<_> = after
.iter()
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0 && p[3] & flags::FLAG_CLOSE == 0)
.collect();
assert_eq!(
echo_after.len(),
0,
"no packet should be sent when pending is full"
);
client.close().unwrap();
server.join();
}
@@ -0,0 +1,157 @@
use super::*;
#[test]
fn server_processing_greater_than_raw_does_not_underflow() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(1),
wall: SystemTime::now(),
},
&TimestampFields {
recv_mono: Some(0),
send_mono: Some(1_000_000_000),
..Default::default()
},
);
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
}
#[test]
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
let base = Instant::now();
let rtt = compute_rtt(
&ClientTimestamp {
mono: base,
wall: SystemTime::now(),
},
&ClientTimestamp {
mono: base + Duration::from_nanos(42),
wall: SystemTime::now(),
},
&TimestampFields::default(),
);
assert_eq!(rtt.adjusted, None);
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
}
#[test]
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
let overflow = max + Duration::from_nanos(1);
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
assert_eq!(unix_epoch_ns_i64(overflow), None);
}
#[test]
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
let overflow_wall =
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: overflow_wall,
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: overflow_wall + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(i64::MAX),
send_wall: Some(i64::MAX),
..Default::default()
};
assert_eq!(compute_one_way(&sent_at, &received_at, &ts), None);
}
#[test]
fn compute_one_way_returns_available_direction_samples() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_client_to_server_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 - 5_000_000),
send_wall: Some(10_000_000_000 + 25_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(-5_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(15_000_000))
);
}
#[test]
fn compute_one_way_preserves_negative_server_to_client_delay() {
let sent_at = ClientTimestamp {
mono: Instant::now(),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(40),
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
};
let ts = TimestampFields {
recv_wall: Some(10_000_000_000 + 15_000_000),
send_wall: Some(10_000_000_000 + 45_000_000),
..Default::default()
};
let sample = compute_one_way(&sent_at, &received_at, &ts).unwrap();
assert_eq!(
sample.client_to_server,
Some(SignedDuration::from_nanos(15_000_000))
);
assert_eq!(
sample.server_to_client,
Some(SignedDuration::from_nanos(-5_000_000))
);
}
@@ -1,10 +1,4 @@
use super::*;
use std::time::Instant;
use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap},
session::ActiveSession,
};
#[test]
fn sequence_ordering_handles_normal_and_wrapped_values() {
@@ -24,22 +18,10 @@ fn sequence_ordering_handles_normal_and_wrapped_values() {
#[test]
fn highest_received_seq_updates_across_wrap() {
let mut session = ActiveSession {
next_wire_seq: 0,
next_logical_seq: 0,
highest_received_seq: Some(u32::MAX),
packets_sent: 0,
start_mono: Instant::now(),
end_mono: None,
next_send_at: Instant::now(),
pending: PendingMap::new(8),
timed_out: TimedOutMap::new(8),
completed: CompletedSet::new(8),
sending_done: false,
};
let mut highest = Some(u32::MAX);
update_highest_received(&mut session, 0);
assert_eq!(session.highest_received_seq, Some(0));
update_highest_received(&mut session, u32::MAX);
assert_eq!(session.highest_received_seq, Some(0));
update_highest_received(&mut highest, 0);
assert_eq!(highest, Some(0));
update_highest_received(&mut highest, u32::MAX);
assert_eq!(highest, Some(0));
}
@@ -1,7 +1,8 @@
use super::*;
use irtt_proto::{
compute_hmac_in_place, echo_packet_len, flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY,
layout::PacketLayout, Clock, ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
compute_hmac_in_place, echo_packet_len as checked_echo_packet_len, flags::FLAG_HMAC,
flags::FLAG_OPEN, flags::FLAG_REPLY, layout::PacketLayout, Clock, ReceivedStats, StampAt,
HMAC_SIZE, MAGIC,
};
use std::{
net::UdpSocket,
@@ -74,6 +75,11 @@ pub(super) fn open_reply(
packet
}
pub(crate) fn echo_packet_len(hmac: bool, params: &Params) -> usize {
checked_echo_packet_len(hmac, params)
.expect("test params must have a non-negative packet length")
}
pub(super) fn echo_reply_packet(
token: u64,
seq: u32,
@@ -283,7 +289,7 @@ pub(super) fn open_client_with_echo_server(params: &Params) -> (Client, FakeServ
..default_test_config(server.addr)
};
let mut client = Client::connect(config).unwrap();
assert_open_started(client.open(ClientTimestamp::now()).unwrap());
assert_open_started(client.open().unwrap());
(client, server)
}
+2 -2
View File
@@ -29,10 +29,10 @@ fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
client.open(ClientTimestamp::now()).unwrap();
client.open().unwrap();
server.join();
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
}
+116 -1
View File
@@ -1,12 +1,31 @@
use std::{net::SocketAddr, time::Duration};
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
/// bytes.
///
/// This is the maximum encoded server-fill string accepted by this client and
/// protocol decoder. [`ClientConfig::server_fill`] enforces the same bound
/// before opening a session.
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
use irtt_proto::{Clock, ReceivedStats, StampAt};
pub(crate) const DEFAULT_PORT: u16 = 2112;
/// Largest valid DSCP codepoint accepted by client configuration.
///
/// DSCP is a six-bit value in the range `0..=63`. The client maps it into the
/// socket traffic-class field when configuring packet priority.
pub const MAX_DSCP_CODEPOINT: u8 = 63;
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
///
/// This is the public user-configuration bound for
/// [`SocketConfig::ttl`]. A value of zero is rejected separately because socket
/// TTL and hop-limit settings are configured as `1..=255`.
pub const MAX_TTL: u32 = 255;
/// Largest UDP payload length accepted by client configuration, in bytes.
///
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
/// [`ClientConfig::length`] before protocol packets are encoded or sent.
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
pub const MAX_SERVER_FILL_BYTES: usize = 32;
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
@@ -19,23 +38,83 @@ pub(crate) const MIN_OPEN_TIMEOUT: Duration = Duration::from_millis(200);
pub(crate) const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
/// Configuration for opening and running an IRTT client session.
///
/// This type describes both the protocol parameters sent in the IRTT open
/// 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).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientConfig {
/// Remote server name or address.
///
/// If no port is present, the default IRTT port 2112 is used. IPv6
/// literals may be supplied either bracketed or unbracketed when the
/// default port should be used.
pub server_addr: String,
/// Requested run duration.
///
/// `Some(duration)` requests a finite test and must be greater than zero.
/// `None` requests continuous mode and is encoded on the wire as a zero
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
/// without sending probes.
pub duration: Option<Duration>,
/// Requested spacing between probe sends.
///
/// The interval is encoded as nanoseconds in the open request and must be
/// greater than zero. The server may return a different interval depending
/// on the negotiated policy and server restrictions.
pub interval: Duration,
/// Requested echo packet payload length, in bytes.
///
/// The value must fit within the UDP payload limit after protocol overhead.
/// A server may reduce the requested length during negotiation.
pub length: u32,
/// Requested server receive-statistics fields in echo replies.
pub received_stats: ReceivedStats,
/// Requested timestamp placement in echo replies.
pub stamp_at: StampAt,
/// Requested server clock sources for timestamp fields.
pub clock: Clock,
/// Requested DSCP codepoint.
///
/// This is the six-bit DSCP value, not the full traffic-class byte, and
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
pub dscp: u8,
/// Optional HMAC key used to authenticate IRTT packets.
///
/// When present, open, echo, and close packets are encoded and decoded with
/// HMAC authentication. The peer must be configured with the same key.
pub hmac_key: Option<Vec<u8>>,
/// Optional server payload fill request.
///
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
/// a non-empty server fill mode/value and must not exceed
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
pub server_fill: Option<String>,
/// Per-attempt receive timeouts used while opening the session.
///
/// The client sends an open request for each entry until a valid open reply
/// is received. The list must not be empty, and each timeout must be at
/// least 200 ms.
pub open_timeouts: Vec<Duration>,
/// Whether opening the session should start a probe test or perform a
/// negotiation-only no-test exchange.
pub run_mode: RunMode,
/// Policy for server changes to negotiable protocol parameters.
pub negotiation_policy: NegotiationPolicy,
/// Local UDP socket configuration.
pub socket_config: SocketConfig,
/// Time after sending a probe before the client reports it as lost.
///
/// This timeout is local client behavior; it is not negotiated with the
/// server. It must be greater than zero.
pub probe_timeout: Duration,
/// Maximum number of probes tracked as pending/timed-out/completed.
///
/// This bounds memory used for reply classification and must be greater
/// than zero. A very small value can reject sends when replies are still
/// outstanding.
pub max_pending_probes: usize,
}
@@ -62,29 +141,65 @@ impl Default for ClientConfig {
}
}
/// Local UDP socket options used by [`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.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SocketConfig {
/// Local address to bind before connecting the UDP socket.
///
/// `None` binds to an unspecified address with an ephemeral port matching
/// the selected remote address family.
pub bind_addr: Option<SocketAddr>,
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
///
/// `None` leaves the platform default unchanged. Values must fit in the
/// platform socket option range; [`MAX_TTL`] is the public configuration
/// bound used by this crate.
pub ttl: Option<u32>,
/// Restrict name resolution to IPv4 addresses.
pub ipv4_only: bool,
/// Restrict name resolution to IPv6 addresses and set IPV6_V6ONLY for IPv6
/// sockets.
pub ipv6_only: bool,
/// Socket read timeout used after the session is open.
///
/// `None` leaves reads blocking for APIs that perform a single receive.
/// Managed sessions may replace `None` or long timeouts with a short
/// timeout so cooperative cancellation can be observed promptly.
pub recv_timeout: Option<Duration>,
}
/// How strictly to handle server-side negotiation restrictions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NegotiationPolicy {
/// Reject any negotiated parameter that is more restrictive or different
/// than requested.
Strict,
/// Accept documented server restrictions and report them in
/// [`NegotiatedParams`](crate::NegotiatedParams).
Loose,
}
/// Mode requested during the IRTT open exchange.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
/// Open a session and send echo probes for the negotiated run duration.
Normal,
/// Complete negotiation without running the echo probe test.
NoTest,
}
/// Bound for a single receive-drain operation.
///
/// This is used by lower-level callers that drive [`Client`](crate::Client)
/// directly and want to cap how many datagrams are processed before returning
/// to their own event loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecvBudget {
/// Maximum number of datagrams to process before returning.
pub max_packets: usize,
}
+111
View File
@@ -2,65 +2,176 @@ use std::{io, time::Duration};
use thiserror::Error;
/// Failure reported while configuring, opening, running, receiving, closing, or
/// joining an IRTT client session.
///
/// Configuration validation errors usually require changing
/// [`ClientConfig`](crate::ClientConfig) or related options before retrying.
/// Network, protocol, and runtime errors may be transient, but can also reflect
/// peer incompatibility or an invalid session state.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ClientError {
/// The configured server address could not be resolved to a socket address.
///
/// Check the host, port, address-family restrictions, and name service
/// configuration before retrying.
#[error("failed to resolve server address {addr:?}")]
Resolve { addr: String },
/// A UDP socket operation failed while binding, connecting, sending, or
/// receiving.
///
/// This can be a transient network or OS error, but may also reflect local
/// permissions, routing, or socket configuration.
#[error("socket error: {0}")]
Socket(#[from] io::Error),
/// Applying or reading a socket option failed.
///
/// This is usually caused by an unsupported option, an invalid local value,
/// or a platform-specific socket limitation. Changing socket configuration
/// is more likely to help than retrying unchanged.
#[error("socket option error while trying to {operation} for {remote}: {source}")]
SocketOption {
operation: &'static str,
remote: std::net::SocketAddr,
source: io::Error,
},
/// Restoring the configured read timeout after open negotiation failed.
///
/// The session may have opened successfully, but the socket could not be
/// returned to the caller's requested receive-timeout behavior.
#[error("failed to restore configured socket read timeout after open negotiation: {source}")]
ReadTimeoutRestore { source: io::Error },
/// A packet failed IRTT protocol parsing or authentication.
///
/// Likely causes include malformed input, unsupported protocol data, or an
/// HMAC mismatch between client and server.
#[error("protocol error: {0}")]
Protocol(#[from] irtt_proto::ProtoError),
/// No valid open reply was received before all configured open attempts
/// timed out.
///
/// Retrying can succeed when the server or network is temporarily
/// unavailable. Persistent failures usually require changing the address,
/// authentication key, server configuration, or timeout settings.
#[error("all open requests timed out")]
OpenTimeout,
/// An open-attempt timeout is below the client's supported minimum.
///
/// Increase the configured timeout before retrying.
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
OpenTimeoutTooSmall {
timeout: Duration,
minimum: Duration,
},
/// The configured open-attempt timeout list is empty.
///
/// Provide at least one timeout before opening the session.
#[error("open_timeouts must not be empty")]
NoOpenTimeouts,
/// The server explicitly rejected the open request.
///
/// Retrying unchanged is unlikely to help unless the server configuration or
/// load has changed.
#[error("server rejected the open request")]
ServerRejected,
/// A no-test open request received a reply that did not have no-test close
/// semantics.
///
/// This indicates an incompatible or unexpected peer response.
#[error("unexpected no-test open reply")]
UnexpectedNoTestReply,
/// A no-test open reply included a connection token.
///
/// No-test negotiation must not establish a probe session, so a non-zero
/// token indicates an incompatible or unexpected peer response.
#[error("no-test reply included a non-zero connection token: {token}")]
NonZeroNoTestToken { token: u64 },
/// The peer replied with a protocol version different from the requested
/// version.
///
/// This is a protocol compatibility error and requires compatible client
/// and server versions.
#[error("protocol version mismatch: requested {requested}, received {received}")]
ProtocolVersionMismatch { requested: i64, received: i64 },
/// The server returned a zero connection token for a normal test session.
///
/// A normal session requires a non-zero token to correlate echo traffic, so
/// this indicates an invalid peer response.
#[error("server returned a zero connection token")]
ZeroToken,
/// Negotiated parameters were rejected by the configured negotiation policy.
///
/// Use a less restrictive configuration or
/// [`NegotiationPolicy::Loose`](crate::NegotiationPolicy::Loose) when the
/// caller accepts documented server restrictions.
#[error("strict negotiation rejected changed params: {reason}")]
NegotiationRejected { reason: String },
/// The requested operation requires an open session, but the client is only
/// connected.
#[error("client is not open")]
NotOpen,
/// The session is already open.
///
/// Continue running, receiving, or closing the existing session instead of
/// opening it again.
#[error("client session is already open")]
AlreadyOpen,
/// The no-test session has already completed.
///
/// Create a new client session to perform another negotiation.
#[error("client session is already completed")]
AlreadyCompleted,
/// The session is already closed.
///
/// Create a new client session before opening or running another test.
#[error("client session is already closed")]
AlreadyClosed,
/// A duration could not be represented as nanoseconds for scheduling or
/// protocol encoding.
///
/// Use smaller configured or negotiated durations.
#[error("duration is too large to encode as nanoseconds")]
DurationOverflow,
/// A client-maintained counter would overflow.
///
/// This is a runtime limit rather than a network failure. Start a new
/// session if the test duration or send volume reaches this limit.
#[error("counter {counter} overflowed")]
CounterOverflow { counter: &'static str },
/// Sending another probe would exceed the configured pending-probe limit.
///
/// Increase [`ClientConfig::max_pending_probes`](crate::ClientConfig::max_pending_probes),
/// reduce send rate, or receive replies and timeouts more often before
/// sending again.
#[error("pending probe limit exceeded ({limit})")]
PendingLimitExceeded { limit: usize },
/// Client configuration failed validation before the requested operation.
///
/// The reported reason names a value that must be changed before retrying.
#[error("invalid configuration: {reason}")]
InvalidConfig { reason: String },
/// A managed-session worker thread panicked before producing an outcome.
///
/// This is a runtime failure from joining the managed session, not a
/// protocol response from the server.
#[error("managed client worker thread panicked")]
WorkerPanicked,
}
/// Failure reported while setting up or receiving from an event subscription.
///
/// Zero-capacity subscriber queues are rejected when subscriptions are created.
/// Once a subscription exists, this error type reports delivery failures observed
/// by [`EventSubscription`](crate::EventSubscription).
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum EventSubscriptionError {
/// The subscription can no longer receive new events.
///
/// Already queued events are delivered first. After the queue is drained,
/// `recv` and `try_recv` return this error when the hub disconnects the
/// subscriber, the hub is shut down, or the subscription is disconnected by
/// its overflow policy.
#[error("event subscription is disconnected")]
Disconnected,
}
+231 -27
View File
@@ -5,141 +5,360 @@ use std::{
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
/// Result of the IRTT open exchange.
///
/// The lower-level [`Client`](crate::Client) API returns this before any echo
/// probes are driven. Managed sessions publish the contained lifecycle event to
/// subscribers immediately after a successful open.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenOutcome {
/// A normal probe session was opened.
Started {
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr,
/// Session token assigned by the server and used on echo/close packets.
token: u64,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent,
},
/// A no-test open exchange completed without starting a probe session.
NoTestCompleted {
/// Resolved remote socket address used by the UDP socket.
remote: SocketAddr,
/// Parameters returned by the server after negotiation.
negotiated: NegotiatedParams,
/// Lifecycle event corresponding to this outcome.
event: ClientEvent,
},
}
/// Event emitted by an IRTT client session.
///
/// Events form the public lifecycle and measurement stream for callers. UIs
/// usually display lifecycle, warning, and per-packet events directly; summary
/// reporting should aggregate `EchoSent`, `EchoReply`, `EchoLoss`,
/// `LateReply`, and `DuplicateReply` events or pass the stream to
/// `irtt-stats`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientEvent {
/// A normal session has opened and is ready to send echo probes.
SessionStarted {
/// Resolved remote socket address.
remote: SocketAddr,
/// Session token assigned by the server.
token: u64,
/// Negotiated protocol parameters for this session.
negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp,
},
/// A negotiation-only no-test exchange completed.
///
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
/// expected for this open outcome.
NoTestCompleted {
/// Resolved remote socket address.
remote: SocketAddr,
/// Negotiated protocol parameters returned by the server.
negotiated: NegotiatedParams,
/// Client timestamp when the open reply was accepted.
at: ClientTimestamp,
},
/// The client sent a close request and considers the session closed.
///
/// This is a local lifecycle event, not an acknowledgement from the server.
/// Managed sessions disconnect event subscribers after the worker exits,
/// while leaving already queued events available to drain.
SessionClosed {
/// Resolved remote socket address.
remote: SocketAddr,
/// Session token that was closed.
token: u64,
/// Client timestamp when the close request was sent.
at: ClientTimestamp,
},
/// An echo request was sent.
///
/// This records local scheduling and send-call timing. It does not imply a
/// reply will arrive; callers should pair it with `EchoReply`, `EchoLoss`,
/// `LateReply`, or `DuplicateReply` by sequence number when displaying
/// packet lifecycle state.
EchoSent {
/// Wire sequence number sent in the echo request.
seq: u32,
logical_seq: u64,
/// Resolved remote socket address.
remote: SocketAddr,
/// Monotonic deadline at which the probe was scheduled to be sent.
scheduled_at: Instant,
/// Client wall/monotonic timestamp captured for the send.
sent_at: ClientTimestamp,
/// Number of bytes passed to the UDP socket.
bytes: usize,
/// Elapsed time spent in the socket send call.
send_call: Duration,
/// Absolute difference between the scheduled send time and actual send
/// timestamp.
timer_error: Duration,
},
/// First in-window reply for a pending echo request.
///
/// This is the primary successful measurement event. RTT and optional
/// server timing/statistics fields should generally be aggregated from this
/// variant.
EchoReply {
/// Wire sequence number from the echo reply.
seq: u32,
logical_seq: u64,
/// Resolved remote socket address.
remote: SocketAddr,
/// Client timestamp recorded when the matching request was sent.
sent_at: ClientTimestamp,
/// Client timestamp recorded when the reply was received.
received_at: ClientTimestamp,
/// Round-trip timing sample for this reply.
rtt: RttSample,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta,
},
/// A sent echo request exceeded the local probe timeout before a reply was
/// matched.
///
/// A later datagram for the same sequence may still arrive and will be
/// reported as [`ClientEvent::LateReply`] rather than changing this event.
EchoLoss {
/// Wire sequence number that timed out.
seq: u32,
logical_seq: u64,
/// Client timestamp recorded when the request was sent.
sent_at: ClientTimestamp,
/// Monotonic deadline at which the probe was declared lost.
timeout_at: std::time::Instant,
},
/// Additional reply for a sequence that was already completed.
///
/// Duplicates are not primary RTT samples. They are useful for diagnostics
/// and packet counters, but should not be aggregated as successful first
/// replies.
DuplicateReply {
/// Wire sequence number from the duplicate reply.
seq: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Client timestamp recorded when the duplicate was received.
received_at: ClientTimestamp,
/// UDP datagram size received from the socket.
bytes: usize,
},
/// Reply for a sequence that was no longer pending.
///
/// This commonly means the probe had already been reported as lost, or the
/// reply arrived out of the client's current tracking window. When the
/// original send metadata is still retained, RTT and one-way timing are
/// included; otherwise those fields are `None`.
LateReply {
/// Wire sequence number from the late reply.
seq: u32,
logical_seq: Option<u64>,
/// Highest sequence number accepted as an in-window first reply so far.
highest_seen: u32,
/// Resolved remote socket address.
remote: SocketAddr,
/// Original client send timestamp, when still retained.
sent_at: Option<ClientTimestamp>,
/// Client timestamp recorded when the late reply was received.
received_at: ClientTimestamp,
/// RTT sample computed when the original send timestamp is available.
rtt: Option<RttSample>,
/// Optional server-reported timestamps and processing time.
server_timing: Option<ServerTiming>,
/// Optional one-way delay values derived from client/server wall clocks.
one_way: Option<OneWayDelaySample>,
/// Optional server-reported received-statistics sample.
received_stats: Option<ReceivedStatsSample>,
/// UDP datagram size received from the socket.
bytes: usize,
/// Receive-side metadata observed outside the IRTT wire payload.
packet_meta: PacketMeta,
},
/// Non-fatal condition observed while receiving or classifying packets.
///
/// Warning events are diagnostic. They do not close the session by
/// themselves and are not measurement samples.
Warning {
/// Machine-readable warning category.
kind: WarningKind,
/// Human-readable diagnostic text.
message: String,
/// Client timestamp when the warning was emitted.
at: ClientTimestamp,
},
}
/// Category for a non-fatal [`ClientEvent::Warning`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WarningKind {
/// A received datagram could not be decoded as a relevant IRTT packet.
MalformedOrUnrelatedPacket,
/// A decoded packet used a token that does not match the open session.
WrongToken,
/// A decoded reply could not be matched to tracked probe state.
UntrackedReply,
}
/// Round-trip timing for one echo reply.
///
/// `raw` is the client-observed elapsed time from sending the echo request to
/// receiving the reply and is therefore non-negative. When the server reports
/// enough monotonic timing to compute processing time, `adjusted` subtracts
/// that server processing from `raw`. `effective` is the value used by reporting
/// and statistics: adjusted when available, otherwise raw converted to a signed
/// value.
///
/// Adjusted and effective RTT values are signed. They may be negative if the
/// reported server processing time is greater than the raw client-observed RTT.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RttSample {
/// Client-observed RTT from send to receive.
pub raw: Duration,
pub adjusted: Option<Duration>,
pub effective: Duration,
pub adjusted_signed: Option<SignedDuration>,
pub effective_signed: SignedDuration,
/// Signed RTT adjusted for server processing when server processing is available.
pub adjusted: Option<SignedDuration>,
/// Signed effective RTT used by stats and CLI reporting.
///
/// This is `adjusted` when server processing is available, otherwise `raw`
/// converted to a signed duration.
pub effective: SignedDuration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Signed nanosecond-backed duration used for derived timing values.
///
/// This type preserves negative values that can arise after subtracting server
/// 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`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SignedDuration {
pub ns: i128,
ns: i128,
}
impl SignedDuration {
/// Construct a signed duration from nanoseconds.
pub const fn from_nanos(ns: i128) -> Self {
Self { ns }
}
/// Convert a non-negative [`Duration`] into a signed duration.
///
/// Durations larger than `i128::MAX` nanoseconds saturate to `i128::MAX`.
pub fn from_duration(duration: Duration) -> Self {
Self {
ns: i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX),
}
}
/// Return the duration in nanoseconds.
pub const fn as_nanos(self) -> i128 {
self.ns
}
/// Return the duration in whole microseconds, truncated toward zero.
pub const fn as_micros(self) -> i128 {
self.ns / 1_000
}
/// Return the duration in whole milliseconds, truncated toward zero.
pub const fn as_millis(self) -> i128 {
self.ns / 1_000_000
}
/// Return whether this duration is less than zero.
pub const fn is_negative(self) -> bool {
self.ns < 0
}
}
impl From<Duration> for SignedDuration {
fn from(value: Duration) -> Self {
Self::from_duration(value)
}
}
/// Server-reported timing fields decoded from an echo reply.
///
/// These values are optional because the negotiated timestamp mode, clock mode,
/// server implementation, or packet contents may omit individual fields.
/// Timestamp values are nanoseconds in the server's reported wall-clock or
/// monotonic clock domain. `None` means unavailable; `Some(0)` means the server
/// reported a zero timestamp.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerTiming {
/// Server wall-clock receive timestamp, in nanoseconds.
pub receive_wall_ns: Option<i64>,
/// Server monotonic receive timestamp, in nanoseconds.
pub receive_mono_ns: Option<i64>,
/// Server wall-clock send timestamp, in nanoseconds.
pub send_wall_ns: Option<i64>,
/// Server monotonic send timestamp, in nanoseconds.
pub send_mono_ns: Option<i64>,
/// Server wall-clock midpoint timestamp, in nanoseconds.
pub midpoint_wall_ns: Option<i64>,
/// Server monotonic midpoint timestamp, in nanoseconds.
pub midpoint_mono_ns: Option<i64>,
/// Server processing time computed from monotonic receive/send timestamps.
///
/// `None` means the required monotonic timestamps were unavailable or
/// unusable. `Some(Duration::ZERO)` means the computed processing interval
/// was available and zero.
pub processing: Option<Duration>,
}
/// Directional one-way delay samples computed from wall-clock timestamps.
///
/// Each directional field is an `Option<SignedDuration>`. `None` means the
/// required client or server wall-clock timestamp was unavailable, or the
/// timestamp arithmetic was out of range. `Some(value)` means the delay was
/// computed from available timestamps.
///
/// Negative computed values are preserved as `Some` instead of being clamped to
/// zero or dropped. A negative one-way delay usually indicates wall-clock skew
/// between the client and server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OneWayDelaySample {
pub client_to_server: Option<Duration>,
pub server_to_client: Option<Duration>,
/// Client-to-server delay.
///
/// This is computed from the client send wall time and the server receive
/// wall time when both are available.
pub client_to_server: Option<SignedDuration>,
/// Server-to-client delay.
///
/// This is computed from the server send wall time and the client receive
/// wall time when both are available.
pub server_to_client: Option<SignedDuration>,
}
/// Server-reported receive counters decoded from an echo reply.
///
/// These fields are optional because the negotiated received-statistics mode or
/// server implementation may omit them. `None` means unavailable; `Some(0)`
/// means the server reported the value and it was zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceivedStatsSample {
/// Server-reported count of received packets, when available.
pub count: Option<u32>,
/// Server-reported receive window value, when available.
pub window: Option<u64>,
}
@@ -154,18 +373,3 @@ pub struct PacketMeta {
pub ecn: Option<u8>,
pub kernel_rx_timestamp: Option<std::time::SystemTime>,
}
#[cfg(test)]
mod tests {
use super::PacketMeta;
#[test]
fn packet_meta_default_is_unavailable() {
let meta = PacketMeta::default();
assert_eq!(meta.traffic_class, None);
assert_eq!(meta.dscp, None);
assert_eq!(meta.ecn, None);
assert_eq!(meta.kernel_rx_timestamp, None);
}
}
+48 -1
View File
@@ -1,3 +1,50 @@
//! Reusable client/session/event layer for IRTT-compatible probes.
//!
//! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives
//! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`]
//! values for callers to consume directly or aggregate with `irtt-stats`.
//!
//! Timing values intentionally preserve signed measurement semantics. When
//! server timing is available, [`RttSample::effective`] is adjusted for server
//! processing and can be negative if the reported server processing time exceeds
//! the raw client-observed RTT. Directional [`OneWayDelaySample`] values are
//! also signed when the required wall-clock timestamps are available; negative
//! values usually indicate client/server clock skew.
//!
//! A managed session can drive the socket loop on a worker thread and publish
//! events through a subscription:
//!
//! ```no_run
//! use std::time::Duration;
//!
//! use irtt_client::{ClientConfig, ClientEvent, ManagedClient, SubscriberConfig};
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let config = ClientConfig {
//! server_addr: "127.0.0.1:2112".to_owned(),
//! duration: Some(Duration::from_secs(10)),
//! interval: Duration::from_secs(1),
//! ..ClientConfig::default()
//! };
//!
//! let (session, events) =
//! ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
//!
//! while let Ok(event) = events.recv() {
//! match event {
//! ClientEvent::EchoReply { seq, rtt, .. } => {
//! println!("seq={seq} effective_rtt_us={}", rtt.effective.as_micros());
//! }
//! ClientEvent::SessionClosed { .. } => break,
//! _ => {}
//! }
//! }
//!
//! let _outcome = session.join()?;
//! # Ok(())
//! # }
//! ```
//!
#![cfg_attr(
not(all(target_os = "linux", feature = "ancillary")),
forbid(unsafe_code)
@@ -31,5 +78,5 @@ pub use managed::{
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientSession,
SessionEndReason, SessionOutcome, SubscriberConfig, SubscriberOverflow,
};
pub use session::NegotiatedParams;
pub use session::{NegotiatedParams, NegotiationRestriction};
pub use timing::ClientTimestamp;
+11 -5
View File
@@ -3,20 +3,31 @@ use std::sync::{
Arc,
};
/// Cloneable cooperative cancellation flag.
///
/// Managed sessions use this internally to ask their worker loop to stop. It is
/// exported for callers that want the same simple cancellation primitive in
/// code built around `irtt-client`.
#[derive(Debug, Clone, Default)]
pub struct CancellationToken {
cancelled: Arc<AtomicBool>,
}
impl CancellationToken {
/// Create a token in the non-cancelled state.
pub fn new() -> Self {
Self::default()
}
/// Mark the token as cancelled.
///
/// Cancellation is idempotent and is visible through all clones of the
/// token.
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
/// Return whether cancellation has been requested.
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
@@ -27,11 +38,6 @@ impl CancellationToken {
mod tests {
use super::*;
#[test]
fn token_starts_uncancelled() {
assert!(!CancellationToken::new().is_cancelled());
}
#[test]
fn cancel_is_idempotent_and_visible_through_clones() {
let token = CancellationToken::new();
+66 -55
View File
@@ -8,9 +8,17 @@ use std::{
use crate::{error::EventSubscriptionError, event::ClientEvent};
/// Configuration for one managed event subscriber.
///
/// Each subscriber has an independent bounded queue. The queue bounds memory
/// growth when event production is faster than that subscriber's consumer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubscriberConfig {
/// Maximum number of events buffered for this subscriber.
///
/// Must be greater than zero.
pub capacity: usize,
/// Policy applied when publishing to a full subscriber queue.
pub overflow: SubscriberOverflow,
}
@@ -37,6 +45,11 @@ pub enum SubscriberOverflow {
Disconnect,
}
/// Publish/subscribe fan-out for [`ClientEvent`] values.
///
/// `EventHub` is used by managed sessions internally and is also exported for
/// callers that want the same bounded subscription behavior around their own
/// event producer. Publishing clones each event once per subscriber.
#[derive(Debug, Clone)]
pub struct EventHub {
inner: Arc<HubInner>,
@@ -75,6 +88,7 @@ struct SubscriberState {
}
impl EventHub {
/// Create an empty event hub with no subscribers.
pub fn new() -> Self {
Self {
inner: Arc::new(HubInner {
@@ -84,6 +98,9 @@ impl EventHub {
}
}
/// Register a new subscriber with its own bounded queue.
///
/// Returns an error when `config.capacity` is zero.
pub fn subscribe(
&self,
config: SubscriberConfig,
@@ -116,6 +133,11 @@ impl EventHub {
})
}
/// Publish an event to all currently connected subscribers.
///
/// Slow subscribers are handled according to their
/// [`SubscriberOverflow`] policy. Publishing does not block waiting for a
/// subscriber to consume queued events.
pub fn publish(&self, event: ClientEvent) {
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
.inner
@@ -149,6 +171,8 @@ impl EventHub {
}
}
/// Disconnect all subscribers after leaving their already queued events
/// available to drain.
pub fn disconnect_all(&self) {
let subscribers: Vec<Arc<SubscriberInner>> = self
.inner
@@ -279,6 +303,7 @@ mod tests {
ClientEvent::Warning {
kind: WarningKind::UntrackedReply,
message: format!("event-{n}"),
at: ClientTimestamp::now(),
}
}
@@ -289,14 +314,16 @@ mod tests {
}
}
#[test]
fn publishes_to_one_subscriber() {
let hub = EventHub::new();
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
hub.publish(event(1));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
fn assert_next(
sub: &EventSubscription,
expected: Result<Option<&str>, EventSubscriptionError>,
) {
match (sub.try_recv(), expected) {
(Ok(Some(event)), Ok(Some(message))) => assert_eq!(event_message(event), message),
(Ok(None), Ok(None)) => {}
(Err(actual), Err(expected)) => assert_eq!(actual, expected),
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
}
}
#[test]
@@ -312,56 +339,40 @@ mod tests {
}
#[test]
fn drop_newest_keeps_existing_events() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropNewest,
})
.unwrap();
fn full_subscriber_overflow_policy_is_applied() {
let cases = [
(
SubscriberOverflow::DropNewest,
Ok(Some("event-1")),
Ok(None),
),
(
SubscriberOverflow::DropOldest,
Ok(Some("event-2")),
Ok(None),
),
(
SubscriberOverflow::Disconnect,
Err(EventSubscriptionError::Disconnected),
Err(EventSubscriptionError::Disconnected),
),
];
hub.publish(event(1));
hub.publish(event(2));
for (overflow, first_expected, second_expected) in cases {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow,
})
.unwrap();
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
assert!(sub.try_recv().unwrap().is_none());
}
hub.publish(event(1));
hub.publish(event(2));
#[test]
fn drop_oldest_replaces_existing_events() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::DropOldest,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
assert!(sub.try_recv().unwrap().is_none());
}
#[test]
fn disconnect_removes_full_subscriber() {
let hub = EventHub::new();
let sub = hub
.subscribe(SubscriberConfig {
capacity: 1,
overflow: SubscriberOverflow::Disconnect,
})
.unwrap();
hub.publish(event(1));
hub.publish(event(2));
assert_eq!(
sub.try_recv().unwrap_err(),
EventSubscriptionError::Disconnected
);
assert_next(&sub, first_expected);
assert_next(&sub, second_expected);
}
}
#[test]
+61 -26
View File
@@ -7,7 +7,6 @@ use crate::{
config::{ClientConfig, RecvBudget},
error::ClientError,
event::{ClientEvent, OpenOutcome},
timing::ClientTimestamp,
Client,
};
@@ -22,6 +21,12 @@ const MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
const IDLE_SLEEP: Duration = Duration::from_millis(1);
const MAX_SLEEP: Duration = Duration::from_millis(20);
/// Entry point for running a client session on a worker thread.
///
/// The managed API owns the lower-level [`Client`](crate::Client) loop: it
/// opens the session, sends probes at the negotiated interval, receives
/// datagrams, publishes [`ClientEvent`] values, and closes the session when the
/// run completes or is cancelled.
#[derive(Debug)]
pub struct ManagedClient;
@@ -47,26 +52,49 @@ pub struct ManagedClientSession {
#[must_use = "managed session outcomes contain completion status and counters"]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionOutcome {
/// Why the managed loop stopped.
pub end_reason: SessionEndReason,
/// Number of echo requests sent by the client.
pub packets_sent: u64,
/// Number of first in-window echo replies received.
pub replies_received: u64,
/// Number of duplicate reply events emitted.
pub duplicates: u64,
/// Number of late reply events emitted.
pub late: u64,
/// Number of warning events emitted.
pub warning_events: u64,
}
/// Reason a managed client session ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionEndReason {
/// The negotiated finite test duration completed and the client closed the
/// session normally.
TestComplete,
/// Cancellation was requested through [`ManagedClientSession::stop`] or by
/// dropping the session handle.
Cancelled,
/// The session was opened in [`RunMode::NoTest`](crate::RunMode) and
/// completed after negotiation.
NoTestComplete,
}
impl ManagedClient {
/// Start a managed session without creating an initial event subscription.
///
/// The returned [`ManagedClientSession`] can still create subscriptions via
/// [`ManagedClientSession::subscribe`], but events emitted before a
/// subscriber is registered are not replayed.
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
Self::start_inner(config, None).map(|(session, _)| session)
}
/// Start a managed session and subscribe to events before the worker runs.
///
/// The initial subscription receives the open lifecycle event and
/// subsequent session events, subject to its queue capacity and overflow
/// policy.
pub fn start_with_subscription(
config: ClientConfig,
subscriber_config: SubscriberConfig,
@@ -94,7 +122,7 @@ impl ManagedClient {
.transpose()?;
let mut client = Client::connect(config)?;
let outcome = client.open(ClientTimestamp::now())?;
let outcome = client.open()?;
publish_open_outcome(&hub, &outcome);
let cancellation = CancellationToken::new();
@@ -115,14 +143,28 @@ impl ManagedClient {
}
impl ManagedClientSession {
/// Add another event subscriber to this running managed session.
///
/// The subscription receives only events published after it is registered.
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
self.hub.subscribe(config)
}
/// Request cooperative cancellation of the managed session.
///
/// Cancellation is observed by the worker loop between socket receive,
/// timeout polling, and send scheduling steps. Call [`join`](Self::join) to
/// wait for the worker and obtain the final [`SessionOutcome`].
pub fn stop(&self) {
self.cancellation.cancel();
}
/// Wait for the managed worker thread to finish.
///
/// On success, returns lifecycle counters for the completed session. If the
/// worker panicked, this returns [`ClientError::WorkerPanicked`]. Joining
/// also disconnects all event subscriptions while leaving already queued
/// events available to drain.
pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
let worker = self
.worker
@@ -182,11 +224,7 @@ fn run_client(
&mut counters,
client.recv_available(MANAGED_RECV_BUDGET)?,
);
publish_events(
&hub,
&mut counters,
client.poll_timeouts(ClientTimestamp::now())?,
);
publish_events(&hub, &mut counters, client.poll_timeouts()?);
break;
}
@@ -205,11 +243,7 @@ fn run_client(
&mut counters,
client.recv_available(MANAGED_RECV_BUDGET)?,
);
publish_events(
&hub,
&mut counters,
client.poll_timeouts(ClientTimestamp::now())?,
);
publish_events(&hub, &mut counters, client.poll_timeouts()?);
if client.is_run_complete() {
break;
@@ -223,7 +257,7 @@ fn run_client(
}
let packets_sent = client.packets_sent();
let close_events = client.close(ClientTimestamp::now())?;
let close_events = client.close()?;
publish_events(&hub, &mut counters, close_events);
Ok(SessionOutcome {
@@ -267,7 +301,7 @@ fn drain_final_late_replies(
published |= !events.is_empty();
publish_events(hub, counters, events);
let events = client.poll_timeouts(ClientTimestamp::now())?;
let events = client.poll_timeouts()?;
published |= !events.is_empty();
publish_events(hub, counters, events);
@@ -320,8 +354,10 @@ mod tests {
use super::*;
use crate::{config::NegotiationPolicy, SubscriberOverflow};
use irtt_proto::{
echo_packet_len, flags, flags::FLAG_OPEN, flags::FLAG_REPLY, layout::PacketLayout, Clock,
Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
echo_packet_len,
flags::{self, FLAG_OPEN, FLAG_REPLY},
layout::PacketLayout,
Clock, Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
};
use std::{
net::{SocketAddr, UdpSocket},
@@ -340,6 +376,11 @@ mod tests {
}
}
fn test_echo_packet_len(hmac: bool, params: &Params) -> usize {
echo_packet_len(hmac, params)
.expect("managed runner test params must have a non-negative packet length")
}
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
Params {
protocol_version: PROTOCOL_VERSION,
@@ -470,7 +511,7 @@ mod tests {
timestamps: &TimestampFields,
) -> Vec<u8> {
let layout = PacketLayout::echo(false, params);
let packet_len = echo_packet_len(false, params);
let packet_len = test_echo_packet_len(false, params);
let mut packet = Vec::with_capacity(packet_len);
packet.extend_from_slice(&MAGIC);
@@ -603,19 +644,13 @@ mod tests {
server.join();
assert_eq!(outcome.end_reason, SessionEndReason::TestComplete);
assert!(events.iter().any(|event| matches!(
event,
ClientEvent::EchoLoss {
seq: 0,
logical_seq: 0,
..
}
)));
assert!(events
.iter()
.any(|event| matches!(event, ClientEvent::EchoLoss { seq: 0, .. })));
assert!(events.iter().any(|event| matches!(
event,
ClientEvent::LateReply {
seq: 0,
logical_seq: Some(0),
sent_at: Some(_),
rtt: Some(_),
..
-11
View File
@@ -24,17 +24,6 @@ mod tests {
use super::ReceiveMeta;
use crate::event::PacketMeta;
#[test]
fn metadata_default_is_unavailable() {
let meta = ReceiveMeta::default();
let packet_meta = PacketMeta::from(meta);
assert_eq!(packet_meta.traffic_class, None);
assert_eq!(packet_meta.dscp, None);
assert_eq!(packet_meta.ecn, None);
assert_eq!(packet_meta.kernel_rx_timestamp, None);
}
#[test]
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
let packet_meta = PacketMeta::from(ReceiveMeta {
+27 -61
View File
@@ -1,5 +1,5 @@
use std::{
collections::{HashMap, VecDeque},
collections::{HashMap, HashSet, VecDeque},
time::Instant,
};
@@ -7,7 +7,6 @@ use crate::{error::ClientError, timing::ClientTimestamp};
#[derive(Debug, Clone)]
pub(crate) struct PendingProbe {
pub logical_seq: u64,
pub wire_seq: u32,
pub sent_at: ClientTimestamp,
pub timeout_at: Instant,
@@ -59,11 +58,10 @@ impl PendingMap {
expired.push(probe);
}
}
expired.sort_by_key(|p| p.logical_seq);
expired.sort_by_key(|p| p.sent_at.mono);
expired
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.map.len()
}
@@ -115,7 +113,6 @@ impl TimedOutMap {
self.insertion_order.clear();
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.map.len()
}
@@ -136,7 +133,7 @@ impl TimedOutMap {
#[derive(Debug)]
pub(crate) struct CompletedSet {
set: HashMap<u32, u64>,
set: HashSet<u32>,
insertion_order: VecDeque<u32>,
max_capacity: usize,
}
@@ -144,31 +141,32 @@ pub(crate) struct CompletedSet {
impl CompletedSet {
pub fn new(max_capacity: usize) -> Self {
Self {
set: HashMap::new(),
set: HashSet::new(),
insertion_order: VecDeque::new(),
max_capacity,
}
}
pub fn insert(&mut self, wire_seq: u32, logical_seq: u64) {
if let Some(existing) = self.set.get_mut(&wire_seq) {
*existing = logical_seq;
pub fn insert(&mut self, seq: u32) {
if self.set.contains(&seq) {
return;
}
if self.set.len() >= self.max_capacity {
self.evict_oldest();
}
self.insertion_order.push_back(wire_seq);
self.set.insert(wire_seq, logical_seq);
self.insertion_order.push_back(seq);
self.set.insert(seq);
}
pub fn contains(&self, wire_seq: u32) -> bool {
self.set.contains_key(&wire_seq)
pub fn contains(&self, seq: u32) -> bool {
self.set.contains(&seq)
}
fn evict_oldest(&mut self) {
while let Some(oldest_key) = self.insertion_order.pop_front() {
if self.set.remove(&oldest_key).is_some() {
while let Some(oldest_seq) = self.insertion_order.pop_front() {
if self.set.remove(&oldest_seq) {
break;
}
}
@@ -187,79 +185,47 @@ mod tests {
}
}
fn pending(seq: u32, logical: u64, timeout_at: Instant) -> PendingProbe {
fn pending(seq: u32, timeout_at: Instant) -> PendingProbe {
PendingProbe {
logical_seq: logical,
wire_seq: seq,
sent_at: ts(timeout_at - Duration::from_secs(1)),
timeout_at,
}
}
#[test]
fn pending_map_insert_and_remove() {
let mut map = PendingMap::new(10);
let now = Instant::now();
let probe = pending(0, 0, now + Duration::from_secs(4));
map.insert(probe).unwrap();
assert_eq!(map.len(), 1);
assert!(map.remove(0).is_some());
assert_eq!(map.len(), 0);
assert!(map.remove(0).is_none());
}
#[test]
fn pending_map_rejects_over_capacity() {
let mut map = PendingMap::new(2);
let now = Instant::now();
map.insert(pending(0, 0, now + Duration::from_secs(4)))
map.insert(pending(0, now + Duration::from_secs(4)))
.unwrap();
map.insert(pending(1, 1, now + Duration::from_secs(4)))
map.insert(pending(1, now + Duration::from_secs(4)))
.unwrap();
assert!(matches!(
map.insert(pending(2, 2, now + Duration::from_secs(4))),
map.insert(pending(2, now + Duration::from_secs(4))),
Err(ClientError::PendingLimitExceeded { limit: 2 })
));
}
#[test]
fn drain_expired_returns_in_logical_order() {
let mut map = PendingMap::new(10);
let now = Instant::now();
let past = now - Duration::from_secs(1);
map.insert(pending(5, 5, past)).unwrap();
map.insert(pending(2, 2, past)).unwrap();
map.insert(pending(8, 8, now + Duration::from_secs(10)))
.unwrap();
let expired = map.drain_expired(now);
assert_eq!(expired.len(), 2);
assert_eq!(expired[0].wire_seq, 2);
assert_eq!(expired[1].wire_seq, 5);
assert_eq!(map.len(), 1);
}
#[test]
fn completed_set_tracks_and_evicts() {
fn bounded_probe_tracking_evicts_oldest_entries() {
let mut set = CompletedSet::new(3);
set.insert(0, 0);
set.insert(1, 1);
set.insert(2, 2);
set.insert(0);
set.insert(1);
set.insert(2);
assert!(set.contains(0));
assert!(set.contains(1));
assert!(set.contains(2));
set.insert(3, 3);
set.insert(3);
assert_eq!(set.set.len(), 3);
assert!(!set.contains(0));
assert!(set.contains(3));
}
#[test]
fn timed_out_map_tracks_and_evicts() {
let mut map = TimedOutMap::new(2);
let now = Instant::now();
map.insert(pending(0, 0, now));
map.insert(pending(1, 1, now));
map.insert(pending(2, 2, now));
map.insert(pending(0, now));
map.insert(pending(1, now));
map.insert(pending(2, now));
assert_eq!(map.len(), 2);
assert!(map.remove(0).is_none());
@@ -273,7 +239,7 @@ mod tests {
let now = Instant::now();
for i in 0..20 {
map.insert(pending(i, u64::from(i), now));
map.insert(pending(i, now));
assert!(map.remove(i).is_some());
assert_eq!(map.len(), 0);
assert_eq!(map.insertion_order_len(), 0);
+336 -26
View File
@@ -1,6 +1,6 @@
use std::time::Instant;
use std::{fmt, time::Instant};
use irtt_proto::{Params, PROTOCOL_VERSION};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, PROTOCOL_VERSION};
use crate::{
config::{NegotiationPolicy, MAX_DSCP_CODEPOINT},
@@ -8,9 +8,152 @@ use crate::{
probe::{CompletedSet, PendingMap, TimedOutMap},
};
/// Protocol parameters accepted for a session.
///
/// `params` contains the server-returned values that the client will use for
/// echo packets. `restrictions` records accepted differences from the request
/// when loose negotiation is enabled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiatedParams {
/// Server-returned protocol parameters used for the session.
pub params: Params,
/// Accepted server restrictions or parameter changes.
pub restrictions: Vec<NegotiationRestriction>,
}
/// A server-side restriction applied during session parameter negotiation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NegotiationRestriction {
/// Run duration was reduced.
///
/// A requested duration of `0` means the client requested continuous mode.
/// When `requested_ns == 0` and `negotiated_ns > 0`, the server limited
/// that continuous request to a finite duration.
DurationReduced {
requested_ns: i64,
negotiated_ns: i64,
},
/// Probe interval was increased.
IntervalIncreased {
requested_ns: i64,
negotiated_ns: i64,
},
/// Probe interval was reduced.
IntervalReduced {
requested_ns: i64,
negotiated_ns: i64,
},
/// Packet length was reduced.
LengthReduced { requested: i64, negotiated: i64 },
/// Returned received-statistics mode differs from the request.
ReceivedStatsChanged {
requested: ReceivedStats,
negotiated: ReceivedStats,
},
/// Returned timestamp placement differs from the request.
StampAtChanged {
requested: StampAt,
negotiated: StampAt,
},
/// Returned clock source differs from the request.
ClockChanged { requested: Clock, negotiated: Clock },
/// Returned DSCP codepoint differs from the request.
DscpChanged { requested: i64, negotiated: i64 },
/// Returned server payload fill behavior differs from the request.
ServerFillChanged,
}
impl NegotiationRestriction {
/// Return a human-readable description of this negotiated restriction.
pub fn message(&self) -> String {
self.to_string()
}
}
impl fmt::Display for NegotiationRestriction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DurationReduced {
requested_ns: 0,
negotiated_ns,
} => {
write!(
f,
"server limited continuous duration to {negotiated_ns} ns"
)
}
Self::DurationReduced {
requested_ns,
negotiated_ns,
} => {
write!(
f,
"server reduced duration from {requested_ns} ns to {negotiated_ns} ns"
)
}
Self::IntervalIncreased {
requested_ns,
negotiated_ns,
} => {
write!(
f,
"server increased interval from {requested_ns} ns to {negotiated_ns} ns"
)
}
Self::IntervalReduced {
requested_ns,
negotiated_ns,
} => {
write!(
f,
"server reduced interval from {requested_ns} ns to {negotiated_ns} ns"
)
}
Self::LengthReduced {
requested,
negotiated,
} => {
write!(
f,
"server reduced packet length from {requested} bytes to {negotiated} bytes"
)
}
Self::ReceivedStatsChanged {
requested,
negotiated,
} => {
write!(
f,
"server changed received-stats from {requested:?} to {negotiated:?}"
)
}
Self::StampAtChanged {
requested,
negotiated,
} => {
write!(
f,
"server changed stamp-at from {requested:?} to {negotiated:?}"
)
}
Self::ClockChanged {
requested,
negotiated,
} => {
write!(
f,
"server changed clock from {requested:?} to {negotiated:?}"
)
}
Self::DscpChanged {
requested,
negotiated,
} => {
write!(f, "server changed DSCP from {requested} to {negotiated}")
}
Self::ServerFillChanged => write!(f, "server changed payload fill behavior"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -24,7 +167,6 @@ pub(crate) enum ClientPhase {
#[derive(Debug)]
pub(crate) struct ActiveSession {
pub next_wire_seq: u32,
pub next_logical_seq: u64,
pub highest_received_seq: Option<u32>,
pub packets_sent: u64,
pub start_mono: Instant,
@@ -36,17 +178,19 @@ pub(crate) struct ActiveSession {
pub sending_done: bool,
}
pub(crate) fn validate_negotiated_params(
pub(crate) fn negotiate_params(
requested: &Params,
returned: &Params,
returned: Params,
policy: NegotiationPolicy,
) -> Result<(), ClientError> {
) -> Result<NegotiatedParams, ClientError> {
if returned.protocol_version != PROTOCOL_VERSION {
return Err(ClientError::ProtocolVersionMismatch {
requested: PROTOCOL_VERSION,
received: returned.protocol_version,
});
}
let mut restrictions = Vec::new();
validate_duration_restriction(requested.duration_ns, returned.duration_ns)?;
if returned.length < 0 {
return Err(ClientError::NegotiationRejected {
@@ -65,12 +209,105 @@ pub(crate) fn validate_negotiated_params(
}
validate_dscp_restriction(returned.dscp)?;
if policy == NegotiationPolicy::Strict && returned != requested {
if returned.duration_ns < requested.duration_ns
|| (requested.duration_ns == 0 && returned.duration_ns > 0)
{
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
},
)?;
}
if returned.interval_ns > requested.interval_ns {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::IntervalIncreased {
requested_ns: requested.interval_ns,
negotiated_ns: returned.interval_ns,
},
)?;
}
if returned.interval_ns < requested.interval_ns {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::IntervalReduced {
requested_ns: requested.interval_ns,
negotiated_ns: returned.interval_ns,
},
)?;
}
if returned.length < requested.length {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
},
)?;
}
if returned.received_stats != requested.received_stats {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::ReceivedStatsChanged {
requested: requested.received_stats,
negotiated: returned.received_stats,
},
)?;
}
if returned.stamp_at != requested.stamp_at {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::StampAtChanged {
requested: requested.stamp_at,
negotiated: returned.stamp_at,
},
)?;
}
if returned.clock != requested.clock {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::ClockChanged {
requested: requested.clock,
negotiated: returned.clock,
},
)?;
}
if returned.dscp != requested.dscp && returned.dscp != 0 {
return Err(ClientError::NegotiationRejected {
reason: "returned params differ from requested params".to_owned(),
reason: "server returned unsupported DSCP change".to_owned(),
});
}
Ok(())
if returned.dscp == 0 && requested.dscp != 0 {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::DscpChanged {
requested: requested.dscp,
negotiated: returned.dscp,
},
)?;
}
if returned.server_fill != requested.server_fill {
record_restriction(
policy,
&mut restrictions,
NegotiationRestriction::ServerFillChanged,
)?;
}
Ok(NegotiatedParams {
params: returned,
restrictions,
})
}
fn validate_duration_restriction(requested: i64, returned: i64) -> Result<(), ClientError> {
@@ -105,6 +342,21 @@ fn validate_dscp_restriction(returned: i64) -> Result<(), ClientError> {
Ok(())
}
fn record_restriction(
policy: NegotiationPolicy,
restrictions: &mut Vec<NegotiationRestriction>,
restriction: NegotiationRestriction,
) -> Result<(), ClientError> {
if policy == NegotiationPolicy::Strict {
return Err(ClientError::NegotiationRejected {
reason: restriction.message(),
});
}
restrictions.push(restriction);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -128,17 +380,26 @@ mod tests {
fn assert_rejected(requested: &Params, returned: &Params, policy: NegotiationPolicy) {
assert!(matches!(
validate_negotiated_params(requested, returned, policy),
negotiate_params(requested, returned.clone(), policy),
Err(ClientError::NegotiationRejected { .. })
));
}
fn assert_negotiates(
requested: &Params,
returned: &Params,
policy: NegotiationPolicy,
) -> NegotiatedParams {
negotiate_params(requested, returned.clone(), policy)
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
}
fn rejection_reason(
requested: &Params,
returned: &Params,
policy: NegotiationPolicy,
) -> String {
match validate_negotiated_params(requested, returned, policy) {
match negotiate_params(requested, returned.clone(), policy) {
Err(ClientError::NegotiationRejected { reason }) => reason,
other => panic!("expected negotiation rejection, got {other:?}"),
}
@@ -150,27 +411,65 @@ mod tests {
let mut returned = requested.clone();
returned.length = 128;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::LengthReduced {
requested: requested.length,
negotiated: returned.length,
}
.message()
);
let mut returned = requested.clone();
returned.dscp = 8;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
returned.dscp = 0;
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::DscpChanged {
requested: requested.dscp,
negotiated: returned.dscp,
}
.message()
);
let mut returned = requested.clone();
returned.received_stats = ReceivedStats::Count;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ReceivedStatsChanged {
requested: requested.received_stats,
negotiated: returned.received_stats,
}
.message()
);
let mut returned = requested.clone();
returned.stamp_at = StampAt::Midpoint;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::StampAtChanged {
requested: requested.stamp_at,
negotiated: returned.stamp_at,
}
.message()
);
let mut returned = requested.clone();
returned.clock = Clock::Wall;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ClockChanged {
requested: requested.clock,
negotiated: returned.clock,
}
.message()
);
let mut returned = requested.clone();
returned.server_fill = None;
assert_rejected(&requested, &returned, NegotiationPolicy::Strict);
assert_eq!(
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
NegotiationRestriction::ServerFillChanged.message()
);
}
#[test]
@@ -179,8 +478,13 @@ mod tests {
let mut returned = requested.clone();
returned.duration_ns = requested.duration_ns / 2;
assert!(
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose).is_ok()
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
assert_eq!(
negotiated.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: requested.duration_ns,
negotiated_ns: returned.duration_ns,
}]
);
let mut returned = requested.clone();
@@ -201,12 +505,18 @@ mod tests {
continuous_requested.duration_ns = 0;
let mut finite_returned = continuous_requested.clone();
finite_returned.duration_ns = 1_000_000_000;
assert!(validate_negotiated_params(
let negotiated = assert_negotiates(
&continuous_requested,
&finite_returned,
NegotiationPolicy::Loose
)
.is_ok());
NegotiationPolicy::Loose,
);
assert_eq!(
negotiated.restrictions,
vec![NegotiationRestriction::DurationReduced {
requested_ns: 0,
negotiated_ns: finite_returned.duration_ns,
}]
);
assert_rejected(
&continuous_requested,
@@ -214,9 +524,9 @@ mod tests {
NegotiationPolicy::Strict,
);
assert!(validate_negotiated_params(
&continuous_requested,
assert!(negotiate_params(
&continuous_requested,
continuous_requested.clone(),
NegotiationPolicy::Strict
)
.is_ok());
+8
View File
@@ -1,12 +1,20 @@
use std::time::{Instant, SystemTime};
/// Client-side timestamp captured in both wall-clock and monotonic time.
///
/// Wall-clock time is used for protocol timestamp comparison and one-way delay
/// calculations. Monotonic time is used for elapsed client-side timing such as
/// RTT, scheduling, and timeouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientTimestamp {
/// System wall-clock time.
pub wall: SystemTime,
/// Monotonic timestamp sampled alongside [`wall`](Self::wall).
pub mono: Instant,
}
impl ClientTimestamp {
/// Capture the current wall-clock and monotonic timestamps.
pub fn now() -> Self {
Self {
wall: SystemTime::now(),
+42 -85
View File
@@ -3,10 +3,9 @@ mod support;
use std::time::Duration;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, ClientTimestamp, OpenOutcome, SocketConfig,
WarningKind,
Client, ClientConfig, ClientError, ClientEvent, OpenOutcome, SocketConfig, WarningKind,
};
use irtt_proto::{echo_packet_len, ProtoError, TimestampFields};
use irtt_proto::{ProtoError, TimestampFields};
use support::{
config_for_params, default_params, params_for_modes, run_one_probe_with_config,
@@ -14,6 +13,8 @@ use support::{
start_hmac_required_open_drop_server, BackendPeer, ServerObservation, TOKEN,
};
use crate::support::test_echo_packet_len;
#[test]
fn hmac_open_success_negotiates_without_warnings() {
let key = b"compat-secret".to_vec();
@@ -63,7 +64,7 @@ fn hmac_echo_success_verifies_request_and_accepts_reply() {
token,
sequence,
} => {
assert_eq!(*len, echo_packet_len(true, &params));
assert_eq!(*len, test_echo_packet_len(true, &params));
assert!(*hmac);
assert_eq!(*token, TOKEN);
assert_eq!(*sequence, 0);
@@ -74,14 +75,12 @@ fn hmac_echo_success_verifies_request_and_accepts_reply() {
match &run.reply {
ClientEvent::EchoReply {
seq,
logical_seq,
bytes,
server_timing,
..
} => {
assert_eq!(*seq, 0);
assert_eq!(*logical_seq, 0);
assert_eq!(*bytes, echo_packet_len(true, &params));
assert_eq!(*bytes, test_echo_packet_len(true, &params));
assert!(server_timing.is_some());
}
other => panic!("expected EchoReply, got {other:?}"),
@@ -98,10 +97,10 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
config.hmac_key = Some(key);
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
let events = client.close(ClientTimestamp::now()).unwrap();
let events = client.close().unwrap();
assert!(matches!(
events.as_slice(),
[ClientEvent::SessionClosed { token: TOKEN, .. }]
@@ -123,53 +122,27 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
}
#[test]
fn missing_client_key_against_hmac_required_server_times_out() {
let key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_server(key, Duration::from_millis(250));
let mut config = config_for_params(server.addr, &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = None;
fn hmac_required_server_rejects_missing_or_wrong_client_key() {
for (hmac_key, expected_hmac_flag) in [(None, false), (Some(b"wrong-secret".to_vec()), true)] {
let server_key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
let mut config = config_for_params(server.addr, &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac: false,
bad_hmac: true
}]
));
server.join();
}
#[test]
fn wrong_client_key_against_hmac_required_server_times_out() {
let server_key = b"compat-secret".to_vec();
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
let mut config = config_for_params(server.addr, &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = Some(b"wrong-secret".to_vec());
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac: true,
bad_hmac: true
}]
));
server.join();
let observations = server.observations(1);
assert!(matches!(
observations.as_slice(),
[ServerObservation::RejectedHmac {
hmac,
bad_hmac: true
}] if *hmac == expected_hmac_flag
));
server.join();
}
}
#[test]
@@ -185,7 +158,7 @@ fn bad_hmac_echo_reply_is_rejected_without_echo_reply_event() {
};
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
let sent = client.send_probe().unwrap();
@@ -229,7 +202,7 @@ fn hmac_open_reply_with_bad_hmac_fails_with_protocol_error() {
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
client.open(),
Err(ClientError::Protocol(ProtoError::BadHmac))
));
server.join();
@@ -247,7 +220,7 @@ fn non_hmac_client_open_does_not_set_hmac_flag() {
config.hmac_key = None;
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert_started(outcome, &params);
let observations = server.observations(1);
@@ -304,7 +277,7 @@ fn backend_hmac_correct_key_succeeds() {
config.hmac_key = Some(key);
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
@@ -314,35 +287,19 @@ fn backend_hmac_correct_key_succeeds() {
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
}
#[test]
fn backend_hmac_wrong_key_fails() {
let server_key = b"compat-secret".to_vec();
let peer = BackendPeer::start_hmac_required(server_key);
let mut config = config_for_params(peer.addr(), &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = Some(b"wrong-secret".to_vec());
fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
for hmac_key in [None, Some(b"wrong-secret".to_vec())] {
let server_key = b"compat-secret".to_vec();
let peer = BackendPeer::start_hmac_required(server_key);
let mut config = config_for_params(peer.addr(), &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = hmac_key;
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
}
#[test]
fn backend_hmac_missing_key_fails() {
let server_key = b"compat-secret".to_vec();
let peer = BackendPeer::start_hmac_required(server_key);
let mut config = config_for_params(peer.addr(), &default_params());
config.open_timeouts = vec![Duration::from_millis(200)];
config.hmac_key = None;
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::OpenTimeout)
));
let mut client = Client::connect(config).unwrap();
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
}
}
+14 -12
View File
@@ -1,10 +1,12 @@
mod support;
use irtt_client::{Client, ClientConfig, ClientEvent, ClientTimestamp, NegotiationPolicy};
use irtt_proto::{echo_header_len, echo_packet_len, Params, TimestampFields};
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiationPolicy};
use irtt_proto::{echo_header_len, Params, TimestampFields};
use support::{config_for_params, default_params, run_one_probe, BackendPeer, ServerObservation};
use crate::support::test_echo_packet_len;
fn params_with_length(length: i64) -> Params {
Params {
length,
@@ -42,7 +44,7 @@ fn assert_reply_bytes(event: &ClientEvent, expected: usize) {
#[test]
fn length_zero_baseline() {
let params = params_with_length(0);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
let header = echo_header_len(false, &params);
assert_eq!(expected, header);
}
@@ -50,21 +52,21 @@ fn length_zero_baseline() {
#[test]
fn length_one_accepted() {
let params = params_with_length(1);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert!(expected >= 1);
}
#[test]
fn length_1472_accepted() {
let params = params_with_length(1472);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_eq!(expected, 1472);
}
#[test]
fn length_4096_accepted() {
let params = params_with_length(4096);
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_eq!(expected, 4096);
}
@@ -74,7 +76,7 @@ fn length_4096_accepted() {
fn echo_request_size_matches_echo_packet_len_for_zero_length() {
let params = params_with_length(0);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_echo_size(&run, expected);
assert_sent_bytes(&run.sent, expected);
}
@@ -84,7 +86,7 @@ fn echo_request_size_matches_echo_packet_len_above_header() {
for length in [16, 60, 128, 1472, 4096] {
let params = params_with_length(length);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_eq!(
expected,
echo_header_len(false, &params).max(length as usize)
@@ -100,7 +102,7 @@ fn echo_request_size_matches_echo_packet_len_above_header() {
fn echo_reply_decoded_for_large_length() {
let params = params_with_length(4096);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_reply(&run.reply);
assert_reply_bytes(&run.reply, expected);
}
@@ -110,7 +112,7 @@ fn echo_reply_bytes_consistent_with_echo_packet_len_for_various_lengths() {
for length in [0, 1472, 4096] {
let params = params_with_length(length);
let run = run_one_probe(params.clone(), TimestampFields::default());
let expected = echo_packet_len(false, &params);
let expected = test_echo_packet_len(false, &params);
assert_reply_bytes(&run.reply, expected);
}
}
@@ -128,7 +130,7 @@ fn backend_large_packet_smoke() {
})
.unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
client.send_probe().unwrap();
@@ -136,5 +138,5 @@ fn backend_large_packet_smoke() {
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
}
+23 -180
View File
@@ -1,23 +1,26 @@
mod support;
mod protocol_options {
pub mod backend_smoke;
}
use irtt_client::ClientTimestamp;
use std::time::Duration;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
RttSample, ServerTiming,
RttSample, ServerTiming, SignedDuration,
};
use irtt_proto::{echo_packet_len, Clock, Params, ReceivedStats, StampAt, TimestampFields};
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
use support::{
config_for_params, default_params, params_for_modes, run_one_probe, run_one_probe_with_config,
server_fill, standard_timestamps, start_open_server, BackendPeer, OneProbeRun,
ServerObservation, RECV_COUNT, RECV_WINDOW, TOKEN,
server_fill, standard_timestamps, start_open_server, OneProbeRun, ServerObservation,
RECV_COUNT, RECV_WINDOW, TOKEN,
};
use crate::support::test_echo_packet_len;
struct ReplyView<'a> {
seq: u32,
logical_seq: u64,
rtt: &'a RttSample,
server_timing: Option<&'a ServerTiming>,
one_way: Option<&'a OneWayDelaySample>,
@@ -39,8 +42,7 @@ fn received_stats_modes_drive_negotiated_echo_and_reply_events() {
let reply = expect_echo_reply(&run.reply);
assert_eq!(reply.seq, 0);
assert_eq!(reply.logical_seq, 0);
assert_eq!(reply.bytes, echo_packet_len(false, &params));
assert_eq!(reply.bytes, test_echo_packet_len(false, &params));
assert!(reply.server_timing.is_none());
assert!(reply.one_way.is_none());
assert_received_stats(reply.received_stats, expected_count, expected_window);
@@ -67,7 +69,10 @@ fn timestamp_modes_drive_reply_event_timing_fields() {
assert!(reply.server_timing.is_none());
assert!(reply.one_way.is_none());
assert!(reply.rtt.adjusted.is_none());
assert_eq!(reply.rtt.effective, reply.rtt.raw);
assert_eq!(
reply.rtt.effective,
SignedDuration::from_duration(reply.rtt.raw)
);
assert!(reply.rtt.raw > Duration::ZERO);
}
StampAt::Send => {
@@ -149,7 +154,7 @@ fn hmac_rich_mode_uses_negotiated_echo_layout_and_decodes_reply() {
assert_negotiated_echo_use(&run, &params, true);
let reply = expect_echo_reply(&run.reply);
assert_eq!(reply.bytes, echo_packet_len(true, &params));
assert_eq!(reply.bytes, test_echo_packet_len(true, &params));
assert_received_stats(reply.received_stats, Some(RECV_COUNT), Some(RECV_WINDOW));
assert_timing_fields(reply.server_timing, true, true, false, false, true, true);
}
@@ -167,7 +172,7 @@ fn strict_open_rejects_changed_compatibility_params_from_server() {
config.server_addr = server.addr.to_string();
let mut client = Client::connect(config).unwrap();
assert!(matches!(
client.open(irtt_client::ClientTimestamp::now()),
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
@@ -186,7 +191,7 @@ fn loose_open_uses_returned_params_for_echo_layout_and_reply_parsing() {
returned.received_stats = ReceivedStats::Count;
returned.stamp_at = StampAt::Receive;
returned.clock = Clock::Wall;
returned.dscp = 8;
returned.dscp = 0;
returned.server_fill = None;
let requested_for_config = requested.clone();
@@ -246,21 +251,15 @@ fn assert_negotiated_echo_use(run: &OneProbeRun, params: &Params, hmac: bool) {
fn assert_echo_uses_params(run: &OneProbeRun, params: &Params, hmac: bool) {
let (echo_len, echo_hmac, token, sequence) = echo_observation(run);
assert_eq!(echo_len, echo_packet_len(hmac, params));
assert_eq!(echo_len, test_echo_packet_len(hmac, params));
assert_eq!(echo_hmac, hmac);
assert_eq!(token, TOKEN);
assert_eq!(sequence, 0);
match &run.sent {
ClientEvent::EchoSent {
seq,
logical_seq,
bytes,
..
} => {
ClientEvent::EchoSent { seq, bytes, .. } => {
assert_eq!(*seq, 0);
assert_eq!(*logical_seq, 0);
assert_eq!(*bytes, echo_packet_len(hmac, params));
assert_eq!(*bytes, test_echo_packet_len(hmac, params));
}
other => panic!("expected EchoSent, got {other:?}"),
}
@@ -289,7 +288,6 @@ fn expect_echo_reply(event: &ClientEvent) -> ReplyView<'_> {
match event {
ClientEvent::EchoReply {
seq,
logical_seq,
rtt,
server_timing,
one_way,
@@ -298,7 +296,6 @@ fn expect_echo_reply(event: &ClientEvent) -> ReplyView<'_> {
..
} => ReplyView {
seq: *seq,
logical_seq: *logical_seq,
rtt,
server_timing: server_timing.as_ref(),
one_way: one_way.as_ref(),
@@ -355,7 +352,7 @@ fn assert_processing_subtracted(reply: ReplyView<'_>) {
assert_eq!(processing, Duration::from_nanos(1));
let adjusted = reply.rtt.adjusted.unwrap();
assert_eq!(reply.rtt.effective, adjusted);
assert!(adjusted < reply.rtt.raw);
assert!(adjusted < SignedDuration::from_duration(reply.rtt.raw));
}
fn assert_one_way_presence(
@@ -373,46 +370,6 @@ fn assert_one_way_presence(
assert_eq!(sample.server_to_client.is_some(), server_to_client);
}
#[test]
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
}
#[test]
fn dscp_configured_open_close_smoke() {
let mut params = default_params();
@@ -420,122 +377,8 @@ fn dscp_configured_open_close_smoke() {
let server = start_open_server(params.clone(), None);
let mut client = Client::connect(config_for_params(server.addr, &params)).unwrap();
let outcome = client.open(ClientTimestamp::now()).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
client.close(ClientTimestamp::now()).unwrap();
client.close().unwrap();
server.join();
}
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open(ClientTimestamp::now()).unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close(ClientTimestamp::now()).unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open(ClientTimestamp::now()).unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close(ClientTimestamp::now()).unwrap();
}
}
#[test]
fn backend_clock_smoke() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
let params = params_for_modes(ReceivedStats::None, StampAt::Both, clock);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open(ClientTimestamp::now()).unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close(ClientTimestamp::now()).unwrap();
}
}
@@ -0,0 +1,158 @@
use irtt_client::{Client, ClientEvent};
use irtt_proto::{Clock, ReceivedStats, StampAt};
use crate::support::{config_for_params, default_params, params_for_modes, BackendPeer};
#[test]
fn backend_basic_open_echo_close() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_ttl_smoke() {
let params = default_params();
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut config = config_for_params(peer.addr(), &params);
config.socket_config.ttl = Some(64);
let mut client = Client::connect(config).unwrap();
let outcome = client.open().unwrap();
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
let sent = client.send_probe().unwrap();
assert_eq!(sent.len(), 1);
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
#[test]
fn backend_received_stats_smoke() {
for mode in [
ReceivedStats::None,
ReceivedStats::Count,
ReceivedStats::Window,
ReceivedStats::Both,
] {
let params = params_for_modes(mode, StampAt::None, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClientEvent::EchoReply { .. }));
client.close().unwrap();
}
}
#[test]
fn backend_timestamp_smoke() {
for mode in [
StampAt::None,
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let params = params_for_modes(ReceivedStats::None, mode, Clock::Both);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply {
server_timing,
one_way,
..
} => match mode {
StampAt::None => {
assert!(server_timing.is_none());
assert!(one_way.is_none());
}
StampAt::Send => {
let st = server_timing.as_ref().unwrap();
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Receive => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
}
StampAt::Both => {
let st = server_timing.as_ref().unwrap();
assert!(st.receive_wall_ns.is_some() || st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some() || st.send_mono_ns.is_some());
}
StampAt::Midpoint => {
let st = server_timing.as_ref().unwrap();
assert!(st.midpoint_wall_ns.is_some() || st.midpoint_mono_ns.is_some());
}
},
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
#[test]
fn backend_clock_smoke() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
let params = params_for_modes(ReceivedStats::None, StampAt::Both, clock);
let peer = BackendPeer::start_open_echo(params.clone(), None);
let mut client = Client::connect(config_for_params(peer.addr(), &params)).unwrap();
client.open().unwrap();
client.send_probe().unwrap();
let events = client.recv_once().unwrap();
assert_eq!(events.len(), 1);
match &events[0] {
ClientEvent::EchoReply { server_timing, .. } => {
let st = server_timing.as_ref().unwrap();
match clock {
Clock::Wall => {
assert!(st.receive_wall_ns.is_some());
assert!(st.send_wall_ns.is_some());
}
Clock::Monotonic => {
assert!(st.receive_mono_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
Clock::Both => {
assert!(st.receive_wall_ns.is_some());
assert!(st.receive_mono_ns.is_some());
assert!(st.send_wall_ns.is_some());
assert!(st.send_mono_ns.is_some());
}
}
}
other => panic!("expected EchoReply, got {other:?}"),
}
client.close().unwrap();
}
}
+49 -113
View File
@@ -1,15 +1,15 @@
mod support;
use irtt_client::{
Client, ClientConfig, ClientError, ClientEvent, ClientTimestamp, NegotiationPolicy,
};
use irtt_proto::{echo_packet_len, Params, TimestampFields};
use irtt_client::{Client, ClientConfig, ClientError, NegotiationPolicy};
use irtt_proto::{Params, TimestampFields};
use support::{
config_for_params, default_params, run_one_probe, run_one_probe_with_config, server_fill,
start_open_server, OneProbeRun, ServerObservation,
};
use crate::support::test_echo_packet_len;
fn open_observation_params(run: &OneProbeRun) -> &Params {
match &run.observations[0] {
ServerObservation::Open { params, .. } => params,
@@ -32,63 +32,34 @@ fn echo_observation(run: &OneProbeRun) -> (usize, bool, u64, u32) {
// ─── Config mapping ───
#[test]
fn server_fill_none_produces_no_fill_in_open_params() {
let params = default_params();
let run = run_one_probe(params, TimestampFields::default());
assert!(open_observation_params(&run).server_fill.is_none());
fn server_fill_none_short_and_max_map_into_open_params() {
for fill in [None, Some("abc"), Some("0123456789abcdef0123456789abcdef")] {
let mut params = default_params();
params.server_fill = fill.and_then(server_fill);
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
fill
);
}
}
#[test]
fn server_fill_short_value_maps_into_open_params() {
let mut params = default_params();
params.server_fill = server_fill("abc");
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
Some("abc")
);
}
#[test]
fn server_fill_32_byte_value_maps_into_open_params() {
let mut params = default_params();
let fill = "0123456789abcdef0123456789abcdef";
params.server_fill = server_fill(fill);
let run = run_one_probe(params, TimestampFields::default());
assert_eq!(
open_observation_params(&run)
.server_fill
.as_ref()
.map(|f| f.value.as_str()),
Some(fill)
);
}
#[test]
fn server_fill_over_32_bytes_rejected_at_config_boundary() {
let config = ClientConfig {
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
#[test]
fn server_fill_empty_rejected_at_config_boundary() {
let config = ClientConfig {
server_fill: Some("".to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
fn server_fill_empty_and_oversized_values_are_rejected_at_config_boundary() {
for fill in ["", "0123456789abcdef0123456789abcdefx"] {
let config = ClientConfig {
server_fill: Some(fill.to_owned()),
..ClientConfig::default()
};
assert!(matches!(
Client::connect(config),
Err(ClientError::InvalidConfig { .. })
));
}
}
// ─── Open negotiation ───
@@ -102,53 +73,26 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
}
#[test]
fn server_fill_strict_rejects_removed_fill() {
let mut requested = default_params();
requested.server_fill = server_fill("rand");
fn server_fill_strict_rejects_removed_changed_or_unexpected_fill() {
for (requested_fill, returned_fill) in [
(Some("rand"), None),
(Some("abc"), Some("xyz")),
(None, Some("unexpected")),
] {
let mut requested = default_params();
requested.server_fill = requested_fill.and_then(server_fill);
let mut returned = requested.clone();
returned.server_fill = None;
let mut returned = requested.clone();
returned.server_fill = returned_fill.and_then(server_fill);
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
#[test]
fn server_fill_strict_rejects_different_fill_value() {
let mut requested = default_params();
requested.server_fill = server_fill("abc");
let mut returned = requested.clone();
returned.server_fill = server_fill("xyz");
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
#[test]
fn server_fill_strict_rejects_unexpected_fill_from_server() {
let requested = default_params();
let mut returned = requested.clone();
returned.server_fill = server_fill("unexpected");
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(ClientTimestamp::now()),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
let server = start_open_server(returned, None);
let mut client = Client::connect(config_for_params(server.addr, &requested)).unwrap();
assert!(matches!(
client.open(),
Err(ClientError::NegotiationRejected { .. })
));
server.join();
}
}
#[test]
@@ -176,14 +120,6 @@ fn server_fill_loose_allows_server_to_remove_fill() {
// ─── Echo behavior ───
#[test]
fn server_fill_echo_reply_decoded_with_fill_configured() {
let mut params = default_params();
params.server_fill = server_fill("rand");
let run = run_one_probe(params, TimestampFields::default());
assert!(matches!(run.reply, ClientEvent::EchoReply { .. }));
}
// server_fill is a negotiation/config parameter only; it does not affect
// client-observable reply payload layout. The client sends server_fill in
// the open request and expects the server to use it for reply payload
@@ -204,11 +140,11 @@ fn server_fill_does_not_affect_client_side_packet_layout() {
let (echo_len_without, _, _, _) = echo_observation(&run_without);
assert_eq!(echo_len_with, echo_len_without);
assert_eq!(
echo_packet_len(false, &run_with.negotiated.params),
test_echo_packet_len(false, &run_with.negotiated.params),
echo_len_with
);
assert_eq!(
echo_packet_len(false, &run_without.negotiated.params),
test_echo_packet_len(false, &run_without.negotiated.params),
echo_len_without
);
}
+8 -5
View File
@@ -10,9 +10,7 @@ use std::{
time::{Duration, SystemTime},
};
use irtt_client::{
Client, ClientConfig, ClientEvent, ClientTimestamp, NegotiatedParams, OpenOutcome, SocketConfig,
};
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiatedParams, OpenOutcome, SocketConfig};
use irtt_proto::{
compute_hmac_in_place, echo_packet_len, flags, verify_hmac, Clock, Params, ProtoError,
ReceivedStats, ServerFill, StampAt, TimestampFields, HMAC_SIZE, MAGIC, PROTOCOL_VERSION,
@@ -216,7 +214,7 @@ where
{
let server = start_one_probe_server(server_params, timestamps, hmac_key);
let mut client = Client::connect(build_config(server.addr)).unwrap();
let negotiated = assert_started(client.open(ClientTimestamp::now()).unwrap());
let negotiated = assert_started(client.open().unwrap());
let sent_events = client.send_probe().unwrap();
assert_eq!(sent_events.len(), 1);
@@ -522,6 +520,11 @@ fn open_reply(flags: u8, token: u64, params: &Params, hmac_key: Option<&[u8]>) -
packet
}
pub(super) fn test_echo_packet_len(hmac: bool, params: &Params) -> usize {
echo_packet_len(hmac, params)
.expect("managed runner test params must have a non-negative packet length")
}
fn echo_reply_packet(
token: u64,
seq: u32,
@@ -531,7 +534,7 @@ fn echo_reply_packet(
) -> Vec<u8> {
let has_hmac = hmac_key.is_some();
let layout = irtt_proto::PacketLayout::echo(has_hmac, params);
let packet_len = echo_packet_len(has_hmac, params);
let packet_len = test_echo_packet_len(has_hmac, params);
let mut packet = Vec::with_capacity(packet_len);
packet.extend_from_slice(&MAGIC);
@@ -275,61 +275,3 @@ macro_rules! debug_eprintln {
}
use debug_eprintln;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn startup_failure_details_include_bind_command_status_and_output() {
let status = Command::new("sh")
.arg("-c")
.arg("exit 7")
.status()
.expect("shell exits");
let failure = StartupFailure {
bind: "127.0.0.1:12345".to_owned(),
command: "\"irtt\" \"server\"".to_owned(),
output: "stderr: listen udp4 127.0.0.1:12345: bind: address already in use".to_owned(),
exit_status: Some(status),
error: Some("readiness timed out or server exited before ready".to_owned()),
};
let details = failure.details();
assert!(details.contains("bind: 127.0.0.1:12345"));
assert!(details.contains("command: \"irtt\" \"server\""));
assert!(details.contains("exit status:"));
assert!(details.contains("address already in use"));
}
#[test]
fn startup_failure_summary_includes_attempt_count_and_each_bind() {
let failures = vec![
StartupFailure {
bind: "127.0.0.1:11111".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:11111".to_owned(),
output: "stdout: first".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
StartupFailure {
bind: "127.0.0.1:22222".to_owned(),
command: "\"irtt\" \"server\" -b 127.0.0.1:22222".to_owned(),
output: "stderr: second".to_owned(),
exit_status: None,
error: Some("readiness timed out or server exited before ready".to_owned()),
},
];
let message = format_startup_failures("irtt", &failures);
assert!(message.contains("after 2 attempt(s)"));
assert!(message.contains("attempt 1/2"));
assert!(message.contains("attempt 2/2"));
assert!(message.contains("127.0.0.1:11111"));
assert!(message.contains("127.0.0.1:22222"));
assert!(message.contains("stdout: first"));
assert!(message.contains("stderr: second"));
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-proto"
version = "0.1.1"
version = "0.3.0"
description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true
license.workspace = true
-14
View File
@@ -31,20 +31,6 @@ mod tests {
use super::*;
use crate::{hmac, FLAG_HMAC};
#[test]
fn close_request_sizes() {
let request = CloseRequest {
token: 0x7896_b6ab_8771_5213,
};
assert_eq!(encode_close_request(&request, None).unwrap().len(), 12);
assert_eq!(
encode_close_request(&request, Some(b"testkey"))
.unwrap()
.len(),
28
);
}
#[test]
fn hmac_close_request_places_token_after_hmac() {
let packet = encode_close_request(
+4 -4
View File
@@ -1,7 +1,7 @@
use crate::{
flags::{has, FLAG_HMAC, FLAG_OPEN, FLAG_REPLY},
hmac,
layout::{try_echo_packet_len, PacketLayout},
layout::{echo_packet_len, PacketLayout},
open::{check_hmac_presence, reject},
params::Params,
validate_header, write_header, ProtoError, Result, HEADER_SIZE, HMAC_SIZE, RECV_COUNT_SIZE,
@@ -43,7 +43,7 @@ pub fn encode_echo_request(request: &EchoRequest, hmac_key: Option<&[u8]>) -> Re
flags |= FLAG_HMAC;
}
let layout = PacketLayout::echo(hmac_key.is_some(), &request.params);
let len = try_echo_packet_len(hmac_key.is_some(), &request.params)?;
let len = echo_packet_len(hmac_key.is_some(), &request.params)?;
let payload_offset = layout.header_len();
let available_payload_len = len.saturating_sub(payload_offset);
if request.payload.len() > available_payload_len {
@@ -87,7 +87,7 @@ pub fn decode_echo_reply(
actual: packet.len(),
});
}
let expected_len = try_echo_packet_len(has(flags, FLAG_HMAC), params)?;
let expected_len = echo_packet_len(has(flags, FLAG_HMAC), params)?;
if packet.len() != expected_len {
return Err(ProtoError::PacketLengthMismatch {
expected: expected_len,
@@ -371,7 +371,7 @@ mod tests {
};
params.received_stats = ReceivedStats::Both;
let layout = PacketLayout::echo(true, &params);
let mut packet = Vec::with_capacity(try_echo_packet_len(true, &params).unwrap());
let mut packet = Vec::with_capacity(echo_packet_len(true, &params).unwrap());
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
-46
View File
@@ -98,16 +98,6 @@ mod tests {
);
}
#[test]
fn compute_hmac_does_not_mutate_input() {
let packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
let original = packet.clone();
let _digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
assert_eq!(packet, original);
}
#[test]
fn compute_hmac_matches_copy_and_zero_semantics() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
@@ -119,42 +109,6 @@ mod tests {
assert_eq!(digest, reference);
}
#[test]
fn compute_hmac_in_place_does_not_set_hmac_flag() {
let mut packet = Vec::new();
packet.extend_from_slice(&MAGIC);
packet.push(FLAG_OPEN);
packet.extend_from_slice(&[0; HMAC_SIZE]);
packet.extend_from_slice(&[1, 2, 3, 4]);
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(packet[3], FLAG_OPEN);
assert!(packet[hmac_offset()..hmac_offset() + HMAC_SIZE]
.iter()
.any(|byte| *byte != 0));
assert_eq!(&packet[hmac_offset() + HMAC_SIZE..], &[1, 2, 3, 4]);
}
#[test]
fn compute_hmac_in_place_only_writes_hmac_field() {
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
let original = packet.clone();
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
assert_eq!(&packet[..hmac_offset()], &original[..hmac_offset()]);
assert_ne!(
&packet[hmac_offset()..hmac_offset() + HMAC_SIZE],
&original[hmac_offset()..hmac_offset() + HMAC_SIZE]
);
assert_eq!(
&packet[hmac_offset() + HMAC_SIZE..],
&original[hmac_offset() + HMAC_SIZE..]
);
}
#[test]
fn verify_hmac_rejects_modified_authenticated_byte() {
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
+12 -32
View File
@@ -111,13 +111,7 @@ pub fn echo_header_len(hmac: bool, params: &Params) -> usize {
PacketLayout::echo(hmac, params).header_len()
}
pub fn echo_packet_len(hmac: bool, params: &Params) -> usize {
let header_len = echo_header_len(hmac, params);
let requested = usize::try_from(params.length).unwrap_or(0);
header_len.max(requested)
}
pub fn try_echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
let header_len = echo_header_len(hmac, params);
let requested =
usize::try_from(params.length).map_err(|_| ProtoError::NegativePacketLength {
@@ -161,6 +155,12 @@ mod tests {
#[test]
fn verified_layout_lengths() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
assert_eq!(
echo_header_len(
false,
@@ -205,16 +205,6 @@ mod tests {
);
}
#[test]
fn open_and_close_layout_lengths_account_for_hmac() {
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
}
#[test]
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
for stats in [
@@ -286,23 +276,13 @@ mod tests {
}
}
#[test]
fn no_timestamp_layout_ignores_clock() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_eq!(
echo_header_len(false, &params(ReceivedStats::None, StampAt::None, clock)),
16
);
}
}
#[test]
fn negotiated_length_never_smaller_than_header() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 20;
assert_eq!(echo_packet_len(false, &p), 60);
assert_eq!(echo_packet_len(false, &p).unwrap(), 60);
p.length = 92;
assert_eq!(echo_packet_len(false, &p), 92);
assert_eq!(echo_packet_len(false, &p).unwrap(), 92);
}
#[test]
@@ -311,7 +291,7 @@ mod tests {
p.length = -1;
assert_eq!(
try_echo_packet_len(false, &p),
echo_packet_len(false, &p),
Err(ProtoError::NegativePacketLength { length: -1 })
);
}
@@ -320,9 +300,9 @@ mod tests {
fn checked_packet_len_preserves_non_negative_header_floor() {
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
p.length = 0;
assert_eq!(try_echo_packet_len(false, &p), Ok(60));
assert_eq!(echo_packet_len(false, &p), Ok(60));
p.length = 92;
assert_eq!(try_echo_packet_len(false, &p), Ok(92));
assert_eq!(echo_packet_len(false, &p), Ok(92));
}
}
+20 -2
View File
@@ -1,3 +1,21 @@
//! Low-level wire encode/decode support for the IRTT protocol.
//!
//! `irtt-proto` intentionally mirrors protocol fields closely: request/reply
//! structs expose wire-oriented values, flags, counters, timestamps, and
//! parameter enums rather than higher-level policy decisions. Higher-level
//! crates such as `irtt-client` are responsible for session behavior and
//! user-facing validation.
//!
//! [`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`]
//! performs no additional validation, so callers that build `Params` manually
//! are responsible for validating those values before sending them.
//!
//! The crate also provides packet layout calculation, open/echo/close packet
//! encoding and decoding, and optional HMAC placement, computation, and
//! verification helpers.
//!
#![forbid(unsafe_code)]
pub mod close;
@@ -15,9 +33,9 @@ pub use echo::{decode_echo_reply, encode_echo_request, EchoReply, EchoRequest, T
pub use error::{ProtoError, Result};
pub use flags::*;
pub use hmac::{compute_hmac, compute_hmac_in_place, verify_hmac};
pub use layout::{echo_header_len, echo_packet_len, try_echo_packet_len, PacketLayout};
pub use layout::{echo_header_len, echo_packet_len, PacketLayout};
pub use open::{decode_open_reply, encode_open_request, OpenReply, OpenRequest};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt};
pub use params::{Clock, Params, ReceivedStats, ServerFill, StampAt, MAX_SERVER_FILL_BYTES};
pub const MAGIC: [u8; 3] = [0x14, 0xA7, 0x5B];
pub const PROTOCOL_VERSION: i64 = 1;
+57 -7
View File
@@ -1,5 +1,16 @@
use crate::{varint, ProtoError, Result, PROTOCOL_VERSION};
/// Protocol compatibility bound for an encoded `server_fill` parameter.
pub const MAX_SERVER_FILL_BYTES: usize = 32;
/// Low-level wire representation of IRTT open parameters.
///
/// `Params` mirrors the protocol fields closely and can be constructed
/// directly by callers. Direct construction can therefore produce values that
/// should not be sent on the wire, such as an oversized `server_fill` value.
/// Higher-level callers should validate user or configuration input before
/// encoding. The normal `irtt-client` configuration path enforces
/// [`MAX_SERVER_FILL_BYTES`] for `server_fill`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Params {
pub protocol_version: i64,
@@ -21,6 +32,10 @@ impl Params {
}
}
/// Encodes these parameters without performing additional validation.
///
/// Callers that construct `Params` directly are responsible for validating
/// user or configuration input before encoding.
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
push_int(1, self.protocol_version, &mut out);
@@ -39,6 +54,10 @@ impl Params {
out
}
/// Decodes parameters and rejects malformed or incompatible incoming values.
///
/// This includes invalid enum values, malformed UTF-8, and `server_fill`
/// values longer than [`MAX_SERVER_FILL_BYTES`].
pub fn decode(input: &[u8]) -> Result<Self> {
let mut params = Self::default();
let mut pos = 0;
@@ -59,6 +78,12 @@ impl Params {
pos += used;
let len = usize::try_from(len)
.map_err(|_| ProtoError::ParameterLengthTooLarge { tag, length: len })?;
if len > MAX_SERVER_FILL_BYTES {
return Err(ProtoError::ParameterLengthTooLarge {
tag,
length: len as u64,
});
}
if input.len().saturating_sub(pos) < len {
return Err(ProtoError::MalformedParams);
}
@@ -206,6 +231,14 @@ mod tests {
assert_eq!(Params::decode(&params.encode()), Ok(params));
}
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
let mut encoded = Vec::new();
varint::encode_uvarint(9, &mut encoded);
varint::encode_uvarint(value.len() as u64, &mut encoded);
encoded.extend_from_slice(value);
encoded
}
#[test]
fn params_round_trip() {
let params = Params {
@@ -225,7 +258,7 @@ mod tests {
}
#[test]
fn params_round_trip_received_stats_modes() {
fn params_round_trip_negotiated_option_modes() {
for received_stats in [
ReceivedStats::None,
ReceivedStats::Count,
@@ -238,10 +271,7 @@ mod tests {
..Params::default()
});
}
}
#[test]
fn params_round_trip_timestamp_modes() {
for stamp_at in [
StampAt::None,
StampAt::Send,
@@ -256,10 +286,7 @@ mod tests {
..Params::default()
});
}
}
#[test]
fn params_round_trip_clock_modes() {
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
assert_round_trip(Params {
protocol_version: 1,
@@ -323,6 +350,29 @@ mod tests {
});
}
#[test]
fn server_fill_decode_accepts_max_length() {
let value = b"0123456789abcdef0123456789abcdef";
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
assert_eq!(
params.server_fill,
Some(ServerFill {
value: "0123456789abcdef0123456789abcdef".to_owned(),
})
);
}
#[test]
fn server_fill_decode_rejects_oversized_length() {
let value = b"0123456789abcdef0123456789abcdefx";
assert_eq!(
Params::decode(&encode_server_fill_value(value)),
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
);
}
#[test]
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
let params = Params {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-stats"
version = "0.1.1"
version = "0.3.0"
description = "Statistics aggregation for irtt-client events"
edition.workspace = true
license.workspace = true
+258
View File
@@ -0,0 +1,258 @@
use std::time::Duration;
use crate::ipdv::{IpdvSample, IpdvTracker};
use crate::loss::loss_stats;
use crate::normalization::{ReplySample, StatsEvent};
use crate::time_stats::TimeMetric;
use crate::{
EventCounts, EventStatsUpdate, IpdvPairUpdate, IpdvStats, OneWayDelayStats, PacketCounts,
RttStats, SampleMode, ServerProcessingStats, Snapshot,
};
const CONTINUOUS_SEQUENCE_LIMIT: usize = 4096;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct CoreStats {
events: EventCounts,
packets: PacketCounts,
send_call: TimeMetric,
timer_error: TimeMetric,
rtt_primary: TimeMetric,
rtt_raw: TimeMetric,
rtt_adjusted: TimeMetric,
ipdv_round_trip: TimeMetric,
ipdv_send: TimeMetric,
ipdv_receive: TimeMetric,
send_delay: TimeMetric,
receive_delay: TimeMetric,
server_processing: TimeMetric,
ipdv_tracker: IpdvTracker,
}
impl CoreStats {
pub(crate) fn new(sample_mode: SampleMode) -> Self {
let sequence_limit = if sample_mode == SampleMode::Exact {
None
} else {
Some(CONTINUOUS_SEQUENCE_LIMIT)
};
Self {
events: EventCounts::default(),
packets: PacketCounts::default(),
send_call: TimeMetric::new(false),
timer_error: TimeMetric::new(false),
rtt_primary: TimeMetric::new(sample_mode == SampleMode::Exact),
rtt_raw: TimeMetric::new(sample_mode == SampleMode::Exact),
rtt_adjusted: TimeMetric::new(sample_mode == SampleMode::Exact),
ipdv_round_trip: TimeMetric::new(sample_mode == SampleMode::Exact),
ipdv_send: TimeMetric::new(sample_mode == SampleMode::Exact),
ipdv_receive: TimeMetric::new(sample_mode == SampleMode::Exact),
send_delay: TimeMetric::new(sample_mode == SampleMode::Exact),
receive_delay: TimeMetric::new(sample_mode == SampleMode::Exact),
server_processing: TimeMetric::new(false),
ipdv_tracker: IpdvTracker::new(sequence_limit),
}
}
pub(crate) fn apply(&mut self, event: StatsEvent) -> EventStatsUpdate {
match event {
StatsEvent::Sent {
bytes,
send_call_ns,
timer_error_ns,
..
} => {
self.apply_sent(bytes, send_call_ns, timer_error_ns);
EventStatsUpdate::default()
}
StatsEvent::UniqueReply {
is_late, sample, ..
} => self.apply_unique_reply(is_late, *sample),
StatsEvent::DuplicateReply { bytes, .. } => {
self.apply_duplicate_reply(bytes);
EventStatsUpdate::default()
}
StatsEvent::Loss { .. } => {
self.apply_loss();
EventStatsUpdate::default()
}
StatsEvent::Warning { .. } => {
self.apply_warning();
EventStatsUpdate::default()
}
StatsEvent::UntrackedLate { bytes, .. } => {
self.apply_untracked_late(bytes);
EventStatsUpdate::default()
}
}
}
fn apply_sent(&mut self, bytes: usize, send_call_ns: i128, timer_error_ns: i128) {
self.events.sent_events += 1;
self.packets.packets_sent += 1;
self.packets.bytes_sent = self.packets.bytes_sent.saturating_add(bytes as u64);
self.send_call.push_ns(send_call_ns);
self.timer_error.push_ns(timer_error_ns);
}
fn apply_unique_reply(&mut self, is_late: bool, sample: ReplySample) -> EventStatsUpdate {
let mut update = EventStatsUpdate {
contributed_sample: true,
..EventStatsUpdate::default()
};
self.account_unique_reply(is_late, &sample);
self.record_reply_metrics(&sample);
update.ipdv_pairs = self.apply_ipdv_sample(sample.ipdv);
update
}
fn account_unique_reply(&mut self, is_late: bool, sample: &ReplySample) {
self.events.echo_replies += u64::from(!is_late);
self.events.late_unique_replies += u64::from(is_late);
self.packets.packets_received += 1;
self.packets.unique_replies += 1;
self.packets.late_packets += u64::from(is_late);
self.packets.bytes_received = self
.packets
.bytes_received
.saturating_add(sample.bytes as u64);
if let Some(count) = sample.received_count {
let count = u64::from(count);
self.packets.server_packets_received = Some(
self.packets
.server_packets_received
.map_or(count, |current| current.max(count)),
);
}
if let Some(window) = sample.received_window {
self.packets.server_received_window = Some(window);
}
}
fn record_reply_metrics(&mut self, sample: &ReplySample) {
self.rtt_primary.push_ns(sample.ipdv.rtt_primary_ns);
self.rtt_raw.push_ns(sample.rtt_raw_ns);
if let Some(adjusted) = sample.rtt_adjusted_ns {
self.rtt_adjusted.push_ns(adjusted);
}
if let Some(processing) = sample.server_processing_ns {
self.server_processing.push_ns(processing);
}
if let Some(delay) = sample.send_delay_ns {
self.send_delay.push_ns(delay);
}
if let Some(delay) = sample.receive_delay_ns {
self.receive_delay.push_ns(delay);
}
}
fn apply_ipdv_sample(&mut self, sample: IpdvSample) -> Vec<IpdvPairUpdate> {
let mut updates = Vec::new();
for pair in self.ipdv_tracker.insert(sample) {
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(pair.rtt_ipdv_ns) else {
continue;
};
let send_ipdv = pair
.send_ipdv_ns
.and_then(duration_from_non_negative_i128_ns);
let receive_ipdv = pair
.receive_ipdv_ns
.and_then(duration_from_non_negative_i128_ns);
self.ipdv_round_trip.push_ns(pair.rtt_ipdv_ns);
if let Some(value) = pair.send_ipdv_ns {
self.ipdv_send.push_ns(value);
}
if let Some(value) = pair.receive_ipdv_ns {
self.ipdv_receive.push_ns(value);
}
updates.push(IpdvPairUpdate {
previous_seq: pair.previous_seq,
current_seq: pair.current_seq,
rtt_ipdv,
send_ipdv,
receive_ipdv,
});
}
updates
}
fn apply_duplicate_reply(&mut self, bytes: usize) {
self.events.duplicate_replies += 1;
self.packets.packets_received += 1;
self.packets.duplicates += 1;
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
}
fn apply_loss(&mut self) {
self.events.loss_events += 1;
}
fn apply_warning(&mut self) {
self.events.warning_events += 1;
}
fn apply_untracked_late(&mut self, bytes: usize) {
self.events.untracked_late_replies += 1;
self.packets.packets_received += 1;
self.packets.late_packets += 1;
self.packets.bytes_received = self.packets.bytes_received.saturating_add(bytes as u64);
}
pub(crate) fn snapshot(&self) -> Snapshot {
let packets = self.packets;
Snapshot {
events: self.events,
packets,
loss: loss_stats(packets),
send_call: self.send_call.stats(),
timer_error: self.timer_error.stats(),
rtt: self.rtt_stats(),
ipdv: self.ipdv_stats(),
one_way_delay: self.one_way_delay_stats(),
server_processing: self.server_processing_stats(),
}
}
fn rtt_stats(&self) -> RttStats {
RttStats {
primary: self.rtt_primary.stats(),
raw: self.rtt_raw.stats(),
adjusted: self.rtt_adjusted.stats(),
}
}
fn ipdv_stats(&self) -> IpdvStats {
IpdvStats {
round_trip: self.ipdv_round_trip.stats(),
send: self.ipdv_send.stats(),
receive: self.ipdv_receive.stats(),
}
}
fn one_way_delay_stats(&self) -> OneWayDelayStats {
OneWayDelayStats {
send_delay: self.send_delay.stats(),
receive_delay: self.receive_delay.stats(),
}
}
fn server_processing_stats(&self) -> ServerProcessingStats {
ServerProcessingStats {
processing: self.server_processing.stats(),
}
}
}
fn duration_from_non_negative_i128_ns(value: i128) -> Option<Duration> {
u64::try_from(value).ok().map(Duration::from_nanos)
}
+305
View File
@@ -0,0 +1,305 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
time::{Duration, Instant},
};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct IpdvSample {
pub(crate) seq: u32,
pub(crate) rtt_primary_ns: i128,
pub(crate) client_send_mono: Instant,
pub(crate) client_receive_mono: Instant,
pub(crate) client_send_wall_ns: Option<i128>,
pub(crate) client_receive_wall_ns: Option<i128>,
pub(crate) server_receive_mono_ns: Option<i64>,
pub(crate) server_send_mono_ns: Option<i64>,
pub(crate) server_receive_wall_ns: Option<i64>,
pub(crate) server_send_wall_ns: Option<i64>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct IpdvTracker {
samples: HashMap<u32, IpdvSample>,
sample_order: VecDeque<u32>,
completed_pairs: HashSet<u32>,
sequence_limit: Option<usize>,
}
impl IpdvTracker {
pub(crate) fn new(sequence_limit: Option<usize>) -> Self {
Self {
samples: HashMap::new(),
sample_order: VecDeque::new(),
completed_pairs: HashSet::new(),
sequence_limit,
}
}
pub(crate) fn insert(&mut self, sample: IpdvSample) -> Vec<CompletedIpdvPair> {
let seq = sample.seq;
if self.samples.insert(seq, sample).is_some() {
return Vec::new();
}
self.sample_order.push_back(seq);
self.enforce_sequence_limit();
let mut pairs = Vec::with_capacity(2);
if let Some(pair) = self.try_pair(seq) {
pairs.push(pair);
}
if let Some(pair) = self.try_pair(seq.wrapping_add(1)) {
pairs.push(pair);
}
pairs
}
fn enforce_sequence_limit(&mut self) {
let Some(limit) = self.sequence_limit else {
return;
};
while self.samples.len() > limit {
let Some(seq) = self.sample_order.pop_front() else {
break;
};
if self.samples.remove(&seq).is_some() {
self.completed_pairs.remove(&seq);
self.completed_pairs.remove(&seq.wrapping_add(1));
}
}
}
fn try_pair(&mut self, current_seq: u32) -> Option<CompletedIpdvPair> {
let previous_seq = current_seq.wrapping_sub(1);
if !self.completed_pairs.insert(current_seq) {
return None;
}
let Some(previous) = self.samples.get(&previous_seq) else {
self.completed_pairs.remove(&current_seq);
return None;
};
let Some(current) = self.samples.get(&current_seq) else {
self.completed_pairs.remove(&current_seq);
return None;
};
Some(CompletedIpdvPair {
previous_seq,
current_seq,
rtt_ipdv_ns: abs_i128_ns(current.rtt_primary_ns - previous.rtt_primary_ns),
send_ipdv_ns: send_ipdv_ns(previous, current).map(abs_i128_ns),
receive_ipdv_ns: receive_ipdv_ns(previous, current).map(abs_i128_ns),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CompletedIpdvPair {
pub(crate) previous_seq: u32,
pub(crate) current_seq: u32,
pub(crate) rtt_ipdv_ns: i128,
pub(crate) send_ipdv_ns: Option<i128>,
pub(crate) receive_ipdv_ns: Option<i128>,
}
fn send_ipdv_ns(previous: &IpdvSample, current: &IpdvSample) -> Option<i128> {
if let (Some(prev_server), Some(cur_server)) = (
previous.server_receive_mono_ns,
current.server_receive_mono_ns,
) {
return Some(
i128::from(cur_server)
- i128::from(prev_server)
- instant_diff_ns(current.client_send_mono, previous.client_send_mono),
);
}
if let (Some(prev_server), Some(cur_server), Some(prev_client), Some(cur_client)) = (
previous.server_receive_wall_ns,
current.server_receive_wall_ns,
previous.client_send_wall_ns,
current.client_send_wall_ns,
) {
return Some(i128::from(cur_server) - i128::from(prev_server) - (cur_client - prev_client));
}
None
}
fn receive_ipdv_ns(previous: &IpdvSample, current: &IpdvSample) -> Option<i128> {
if let (Some(prev_server), Some(cur_server)) =
(previous.server_send_mono_ns, current.server_send_mono_ns)
{
return Some(
instant_diff_ns(current.client_receive_mono, previous.client_receive_mono)
- (i128::from(cur_server) - i128::from(prev_server)),
);
}
if let (Some(prev_server), Some(cur_server), Some(prev_client), Some(cur_client)) = (
previous.server_send_wall_ns,
current.server_send_wall_ns,
previous.client_receive_wall_ns,
current.client_receive_wall_ns,
) {
return Some(
(cur_client - prev_client) - (i128::from(cur_server) - i128::from(prev_server)),
);
}
None
}
fn instant_diff_ns(current: Instant, previous: Instant) -> i128 {
if let Some(diff) = current.checked_duration_since(previous) {
duration_ns_i128(diff)
} else {
-duration_ns_i128(previous.duration_since(current))
}
}
fn duration_ns_i128(duration: Duration) -> i128 {
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
}
fn abs_i128_ns(value: i128) -> i128 {
value.saturating_abs()
}
#[cfg(test)]
mod tests {
use super::*;
fn ipdv_sample(seq: u32, rtt_primary_ns: i128) -> IpdvSample {
let now = Instant::now();
IpdvSample {
seq,
rtt_primary_ns,
client_send_mono: now,
client_receive_mono: now,
client_send_wall_ns: None,
client_receive_wall_ns: None,
server_receive_mono_ns: None,
server_send_mono_ns: None,
server_receive_wall_ns: None,
server_send_wall_ns: None,
}
}
#[test]
fn ipdv_tracker_completes_adjacent_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
let pairs = tracker.insert(ipdv_sample(1, 14));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: 0,
current_seq: 1,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test]
fn ipdv_tracker_gap_fill_completes_both_adjacent_pairs() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
assert!(tracker.insert(ipdv_sample(2, 20)).is_empty());
let pairs = tracker.insert(ipdv_sample(1, 13));
assert_eq!(
pairs,
vec![
CompletedIpdvPair {
previous_seq: 0,
current_seq: 1,
rtt_ipdv_ns: 3,
send_ipdv_ns: None,
receive_ipdv_ns: None,
},
CompletedIpdvPair {
previous_seq: 1,
current_seq: 2,
rtt_ipdv_ns: 7,
send_ipdv_ns: None,
receive_ipdv_ns: None,
},
]
);
}
#[test]
fn ipdv_tracker_completes_wrapped_adjacent_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(u32::MAX, 10)).is_empty());
let pairs = tracker.insert(ipdv_sample(0, 14));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: u32::MAX,
current_seq: 0,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test]
fn ipdv_tracker_wrap_gap_does_not_complete_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(u32::MAX - 1, 10)).is_empty());
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
}
#[test]
fn ipdv_tracker_late_wrapped_previous_completes_pair() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 14)).is_empty());
let pairs = tracker.insert(ipdv_sample(u32::MAX, 10));
assert_eq!(
pairs,
vec![CompletedIpdvPair {
previous_seq: u32::MAX,
current_seq: 0,
rtt_ipdv_ns: 4,
send_ipdv_ns: None,
receive_ipdv_ns: None,
}]
);
}
#[test]
fn ipdv_tracker_duplicate_sequence_does_not_emit_pair_again() {
let mut tracker = IpdvTracker::new(None);
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
assert_eq!(tracker.insert(ipdv_sample(1, 14)).len(), 1);
assert!(tracker.insert(ipdv_sample(1, 18)).is_empty());
}
#[test]
fn ipdv_tracker_bounded_mode_evicts_old_sequence_state() {
let mut tracker = IpdvTracker::new(Some(2));
assert!(tracker.insert(ipdv_sample(0, 10)).is_empty());
assert_eq!(tracker.insert(ipdv_sample(1, 14)).len(), 1);
let pairs = tracker.insert(ipdv_sample(2, 20));
assert_eq!(pairs.len(), 1);
assert!(!tracker.samples.contains_key(&0));
assert!(!tracker.completed_pairs.contains(&0));
assert!(!tracker.completed_pairs.contains(&1));
assert!(tracker.completed_pairs.contains(&2));
}
}
+146 -1333
View File
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
use crate::PacketCounts;
#[derive(Debug, Clone, Copy, PartialEq)]
/// Packet loss, duplicate, and late-packet statistics.
///
/// Percentage fields are percentages from `0.0` to `100.0` in normal cases.
/// Directional upstream and downstream loss can be negative when
/// server-reported counts exceed local expectations.
pub struct LossStats {
/// Locally inferred total lost packets.
pub lost_packets: u64,
/// Server-assisted upstream loss estimate, when server counts are available.
pub upstream_loss_packets: Option<i128>,
/// Server-assisted downstream loss estimate, when server counts are available.
pub downstream_loss_packets: Option<i128>,
/// Total packet loss percentage.
pub packet_loss_percent: f64,
/// Server-assisted upstream loss percentage.
pub upstream_loss_percent: f64,
/// Server-assisted downstream loss percentage.
pub downstream_loss_percent: f64,
/// Duplicate reply percentage.
pub duplicate_percent: f64,
/// Late reply packet percentage.
pub late_packets_percent: f64,
}
pub(crate) fn loss_stats(packets: PacketCounts) -> LossStats {
let lost = packets.packets_sent.saturating_sub(packets.unique_replies);
let packet_loss_percent = if packets.packets_sent == 0 {
0.0
} else if packets.unique_replies == 0 {
100.0
} else {
percent(lost as f64, packets.packets_sent as f64)
};
let (
upstream_loss_packets,
upstream_loss_percent,
downstream_loss_packets,
downstream_loss_percent,
) = if let Some(server_received) = packets.server_packets_received {
let upstream = i128::from(packets.packets_sent) - i128::from(server_received);
let downstream = i128::from(server_received) - i128::from(packets.packets_received);
let upstream_percent = if packets.packets_sent == 0 {
0.0
} else {
percent(upstream as f64, packets.packets_sent as f64)
};
let downstream_percent = if server_received == 0 {
0.0
} else {
percent(downstream as f64, server_received as f64)
};
(
Some(upstream),
upstream_percent,
Some(downstream),
downstream_percent,
)
} else {
(None, 0.0, None, 0.0)
};
LossStats {
lost_packets: lost,
upstream_loss_packets,
downstream_loss_packets,
packet_loss_percent,
upstream_loss_percent,
downstream_loss_percent,
duplicate_percent: if packets.packets_received == 0 {
0.0
} else {
percent(packets.duplicates as f64, packets.packets_received as f64)
},
late_packets_percent: if packets.packets_received == 0 {
0.0
} else {
percent(packets.late_packets as f64, packets.packets_received as f64)
},
}
}
fn percent(numerator: f64, denominator: f64) -> f64 {
100.0 * numerator / denominator
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duplicate_and_late_percentages_use_packets_received_denominator() {
let loss = loss_stats(PacketCounts {
packets_received: 4,
duplicates: 1,
late_packets: 2,
..PacketCounts::default()
});
assert_eq!(loss.duplicate_percent, 25.0);
assert_eq!(loss.late_packets_percent, 50.0);
}
}
+222
View File
@@ -0,0 +1,222 @@
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, ReceivedStatsSample, RttSample, ServerTiming,
SignedDuration,
};
use crate::ipdv::IpdvSample;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum StatsEvent {
Sent {
at: Instant,
bytes: usize,
send_call_ns: i128,
timer_error_ns: i128,
},
UniqueReply {
at: Instant,
is_late: bool,
sample: Box<ReplySample>,
},
DuplicateReply {
at: Instant,
bytes: usize,
},
Loss {
at: Instant,
},
Warning {
at: Instant,
},
UntrackedLate {
at: Instant,
bytes: usize,
},
}
pub(crate) fn normalize_event(event: &ClientEvent) -> Option<StatsEvent> {
match event {
ClientEvent::EchoSent {
sent_at,
bytes,
send_call,
timer_error,
..
} => Some(StatsEvent::Sent {
at: sent_at.mono,
bytes: *bytes,
send_call_ns: duration_ns_i128(*send_call),
timer_error_ns: duration_ns_i128(*timer_error),
}),
ClientEvent::EchoReply {
seq,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes,
..
} => Some(StatsEvent::UniqueReply {
at: received_at.mono,
is_late: false,
sample: Box::new(ReplySample::from_reply_parts(
*seq,
*sent_at,
*received_at,
*rtt,
*server_timing,
*one_way,
*received_stats,
*bytes,
)),
}),
ClientEvent::LateReply {
seq,
sent_at: Some(sent_at),
received_at,
rtt: Some(rtt),
server_timing,
one_way,
received_stats,
bytes,
..
} => Some(StatsEvent::UniqueReply {
at: received_at.mono,
is_late: true,
sample: Box::new(ReplySample::from_reply_parts(
*seq,
*sent_at,
*received_at,
*rtt,
*server_timing,
*one_way,
*received_stats,
*bytes,
)),
}),
ClientEvent::LateReply {
received_at, bytes, ..
} => Some(StatsEvent::UntrackedLate {
at: received_at.mono,
bytes: *bytes,
}),
ClientEvent::DuplicateReply {
received_at, bytes, ..
} => Some(StatsEvent::DuplicateReply {
at: received_at.mono,
bytes: *bytes,
}),
ClientEvent::EchoLoss { timeout_at, .. } => Some(StatsEvent::Loss { at: *timeout_at }),
ClientEvent::Warning { at, .. } => Some(StatsEvent::Warning { at: at.mono }),
ClientEvent::SessionStarted { .. }
| ClientEvent::NoTestCompleted { .. }
| ClientEvent::SessionClosed { .. } => None,
}
}
impl StatsEvent {
pub(crate) fn at(&self) -> Instant {
match self {
Self::Sent { at, .. }
| Self::UniqueReply { at, .. }
| Self::DuplicateReply { at, .. }
| Self::Loss { at }
| Self::Warning { at }
| Self::UntrackedLate { at, .. } => *at,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ReplySample {
pub(crate) bytes: usize,
pub(crate) rtt_raw_ns: i128,
pub(crate) rtt_adjusted_ns: Option<i128>,
pub(crate) send_delay_ns: Option<i128>,
pub(crate) receive_delay_ns: Option<i128>,
pub(crate) server_processing_ns: Option<i128>,
pub(crate) received_count: Option<u32>,
pub(crate) received_window: Option<u64>,
pub(crate) ipdv: IpdvSample,
}
impl ReplySample {
#[allow(clippy::too_many_arguments)]
fn from_reply_parts(
seq: u32,
sent_at: ClientTimestamp,
received_at: ClientTimestamp,
rtt: RttSample,
server_timing: Option<ServerTiming>,
one_way: Option<OneWayDelaySample>,
received_stats: Option<ReceivedStatsSample>,
bytes: usize,
) -> Self {
let rtt_primary_ns = signed_duration_ns(rtt.effective);
Self {
bytes,
rtt_raw_ns: duration_ns_i128(rtt.raw),
rtt_adjusted_ns: rtt.adjusted.map(signed_duration_ns),
send_delay_ns: one_way
.and_then(|sample| sample.client_to_server)
.map(signed_duration_ns),
receive_delay_ns: one_way
.and_then(|sample| sample.server_to_client)
.map(signed_duration_ns),
server_processing_ns: server_timing
.and_then(|timing| timing.processing)
.map(duration_ns_i128),
received_count: received_stats.and_then(|stats| stats.count),
received_window: received_stats.and_then(|stats| stats.window),
ipdv: IpdvSample {
seq,
rtt_primary_ns,
client_send_mono: sent_at.mono,
client_receive_mono: received_at.mono,
client_send_wall_ns: system_time_ns(sent_at.wall),
client_receive_wall_ns: system_time_ns(received_at.wall),
server_receive_mono_ns: server_timing.and_then(|timing| timing.receive_mono_ns),
server_send_mono_ns: server_timing.and_then(|timing| timing.send_mono_ns),
server_receive_wall_ns: server_timing.and_then(|timing| timing.receive_wall_ns),
server_send_wall_ns: server_timing.and_then(|timing| timing.send_wall_ns),
},
}
}
}
fn system_time_ns(time: SystemTime) -> Option<i128> {
if let Ok(duration) = time.duration_since(UNIX_EPOCH) {
return Some(duration_ns_i128(duration));
}
UNIX_EPOCH
.duration_since(time)
.ok()
.map(|duration| -duration_ns_i128(duration))
}
fn duration_ns_i128(duration: Duration) -> i128 {
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
}
fn signed_duration_ns(duration: SignedDuration) -> i128 {
duration.as_nanos()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_time_before_epoch_is_supported() {
let before = UNIX_EPOCH - Duration::from_nanos(7);
assert_eq!(system_time_ns(before), Some(-7));
let after = UNIX_EPOCH + Duration::from_nanos(7);
assert_eq!(system_time_ns(after), Some(7));
let now = SystemTime::now();
assert!(system_time_ns(now).is_some());
}
}
+57
View File
@@ -0,0 +1,57 @@
use std::{collections::VecDeque, time::Duration};
use crate::{core::CoreStats, normalization::StatsEvent, SampleMode, Snapshot, StatsConfig};
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct RollingEvents {
count_limit: Option<usize>,
time_limit: Option<Duration>,
count_events: Option<VecDeque<StatsEvent>>,
time_events: Option<VecDeque<StatsEvent>>,
}
impl RollingEvents {
pub(crate) fn new(config: StatsConfig) -> Self {
Self {
count_limit: config.rolling_count,
time_limit: config.rolling_time,
count_events: config.rolling_count.map(|_| VecDeque::new()),
time_events: config.rolling_time.map(|_| VecDeque::new()),
}
}
pub(crate) fn push(&mut self, event: StatsEvent) {
if let (Some(limit), Some(window)) = (self.count_limit, self.count_events.as_mut()) {
window.push_back(event.clone());
while window.len() > limit {
window.pop_front();
}
}
if let (Some(duration), Some(window)) = (self.time_limit, self.time_events.as_mut()) {
let cutoff = event.at().checked_sub(duration);
window.push_back(event);
if let Some(cutoff) = cutoff {
while window.front().is_some_and(|event| event.at() < cutoff) {
window.pop_front();
}
}
}
}
pub(crate) fn count_snapshot(&self) -> Option<Snapshot> {
self.count_events.as_ref().map(snapshot_window)
}
pub(crate) fn time_snapshot(&self) -> Option<Snapshot> {
self.time_events.as_ref().map(snapshot_window)
}
}
fn snapshot_window(events: &VecDeque<StatsEvent>) -> Snapshot {
let mut core = CoreStats::new(SampleMode::RunningOnly);
for event in events {
core.apply(event.clone());
}
core.snapshot()
}
+157
View File
@@ -0,0 +1,157 @@
#[derive(Debug, Clone, Copy, PartialEq)]
/// Summary statistics for timing values.
///
/// All fields use nanoseconds except `variance_ns2`, which is nanoseconds
/// squared. Values may represent signed timing quantities such as scheduling
/// error or one-way delay.
pub struct TimeStats {
/// Number of samples included in this summary.
pub count: u64,
/// Sum of all samples, in nanoseconds.
pub total_ns: i128,
/// Smallest sample, in nanoseconds.
pub min_ns: Option<i128>,
/// Largest sample, in nanoseconds.
pub max_ns: Option<i128>,
/// Arithmetic mean, in nanoseconds.
pub mean_ns: f64,
/// Median, in nanoseconds, when exact samples were retained.
pub median_ns: Option<f64>,
/// Sample variance, in nanoseconds squared.
pub variance_ns2: f64,
}
impl TimeStats {
/// Returns the sample standard deviation, in nanoseconds.
pub fn stddev_ns(&self) -> f64 {
self.variance_ns2.sqrt()
}
/// Returns whether this summary contains no samples.
pub fn is_empty(&self) -> bool {
self.count == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TimeMetric {
running: RunningStats,
samples: Option<Vec<i128>>,
}
impl TimeMetric {
pub(crate) fn new(retain_samples: bool) -> Self {
Self {
running: RunningStats::default(),
samples: retain_samples.then(Vec::new),
}
}
pub(crate) fn push_ns(&mut self, value: i128) {
self.running.push(value);
if let Some(samples) = self.samples.as_mut() {
samples.push(value);
}
}
pub(crate) fn stats(&self) -> TimeStats {
self.running
.stats(self.samples.as_ref().and_then(|samples| median_ns(samples)))
}
}
#[derive(Debug, Clone, PartialEq, Default)]
struct RunningStats {
count: u64,
total_ns: i128,
min_ns: Option<i128>,
max_ns: Option<i128>,
mean_ns: f64,
m2_ns2: f64,
}
impl RunningStats {
fn push(&mut self, value: i128) {
self.count += 1;
self.total_ns = self.total_ns.saturating_add(value);
self.min_ns = Some(self.min_ns.map_or(value, |min| min.min(value)));
self.max_ns = Some(self.max_ns.map_or(value, |max| max.max(value)));
let x = value as f64;
let delta = x - self.mean_ns;
self.mean_ns += delta / self.count as f64;
let delta2 = x - self.mean_ns;
self.m2_ns2 += delta * delta2;
}
fn stats(&self, median_ns: Option<f64>) -> TimeStats {
TimeStats {
count: self.count,
total_ns: self.total_ns,
min_ns: self.min_ns,
max_ns: self.max_ns,
mean_ns: if self.count == 0 { 0.0 } else { self.mean_ns },
median_ns,
variance_ns2: sample_variance(self.count, self.m2_ns2),
}
}
}
fn sample_variance(count: u64, m2: f64) -> f64 {
if count < 2 {
0.0
} else {
m2 / (count - 1) as f64
}
}
fn median_ns(samples: &[i128]) -> Option<f64> {
if samples.is_empty() {
return None;
}
let mut sorted = samples.to_vec();
sorted.sort_unstable();
let mid = sorted.len() / 2;
Some(if sorted.len() % 2 == 1 {
sorted[mid] as f64
} else {
(sorted[mid - 1] as f64 + sorted[mid] as f64) / 2.0
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn running_duration_stats_use_sample_variance() {
let mut metric = TimeMetric::new(false);
metric.push_ns(1);
metric.push_ns(2);
metric.push_ns(3);
let stats = metric.stats();
assert_eq!(stats.count, 3);
assert_eq!(stats.total_ns, 6);
assert_eq!(stats.min_ns, Some(1));
assert_eq!(stats.max_ns, Some(3));
assert_eq!(stats.mean_ns, 2.0);
assert_eq!(stats.variance_ns2, 1.0);
assert_eq!(stats.stddev_ns(), 1.0);
}
#[test]
fn exact_median_handles_odd_and_even_samples() {
assert_eq!(median_ns(&[3, 1, 2]), Some(2.0));
assert_eq!(median_ns(&[4, 1, 2, 3]), Some(2.5));
assert_eq!(median_ns(&[-5, 1, 3]), Some(1.0));
assert_eq!(median_ns(&[-5, 1, 3, 7]), Some(2.0));
}
#[test]
fn single_sample_stddev_is_zero() {
let mut metric = TimeMetric::new(false);
metric.push_ns(42);
let stats = metric.stats();
assert_eq!(stats.variance_ns2, 0.0);
assert_eq!(stats.stddev_ns(), 0.0);
}
}
+127
View File
@@ -0,0 +1,127 @@
use std::time::{Duration, Instant, UNIX_EPOCH};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
ServerTiming, SignedDuration,
};
pub fn ts(ms: u64) -> ClientTimestamp {
ClientTimestamp {
mono: Instant::now() + Duration::from_millis(ms),
wall: UNIX_EPOCH + Duration::from_millis(ms),
}
}
pub fn adjusted_rtt(raw_ms: u64, adjusted_ms: i128) -> RttSample {
let adjusted = SignedDuration::from_nanos(adjusted_ms * 1_000_000);
RttSample {
raw: Duration::from_millis(raw_ms),
adjusted: Some(adjusted),
effective: adjusted,
}
}
pub fn unadjusted_rtt(raw_ms: u64) -> RttSample {
let raw = Duration::from_millis(raw_ms);
RttSample {
raw,
adjusted: None,
effective: SignedDuration::from_duration(raw),
}
}
pub fn sent(seq: u32, sent_at: ClientTimestamp) -> ClientEvent {
ClientEvent::EchoSent {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
scheduled_at: sent_at.mono,
sent_at,
bytes: 32,
send_call: Duration::from_micros(10),
timer_error: Duration::from_micros(2),
}
}
pub fn adjusted_reply(seq: u32, raw_ms: u64, adjusted_ms: i128) -> ClientEvent {
reply_with_rtt(seq, raw_ms, adjusted_rtt(raw_ms, adjusted_ms))
}
pub fn unadjusted_reply(seq: u32, raw_ms: u64) -> ClientEvent {
reply_with_rtt(seq, raw_ms, unadjusted_rtt(raw_ms))
}
fn reply_with_rtt(seq: u32, raw_ms: u64, rtt: RttSample) -> ClientEvent {
let sent_at = ts(seq as u64 * 10);
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(raw_ms),
wall: sent_at.wall + Duration::from_millis(raw_ms),
};
ClientEvent::EchoReply {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt,
server_timing: Some(ServerTiming {
receive_wall_ns: Some(unix_time_ns_after_epoch(sent_at.wall) as i64 + 1_000_000),
receive_mono_ns: Some(seq as i64 * 10_000_000 + 1_000_000),
send_wall_ns: Some(unix_time_ns_after_epoch(sent_at.wall) as i64 + 2_000_000),
send_mono_ns: Some(seq as i64 * 10_000_000 + 2_000_000),
midpoint_wall_ns: None,
midpoint_mono_ns: None,
processing: Some(Duration::from_millis(1)),
}),
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(1_000_000)),
server_to_client: Some(SignedDuration::from_nanos(2_000_000)),
}),
received_stats: Some(ReceivedStatsSample {
count: Some(seq + 1),
window: Some(0xff),
}),
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
pub fn unadjusted_late_reply(seq: u32, raw_ms: u64) -> ClientEvent {
late_reply_from(unadjusted_reply(seq, raw_ms))
}
fn late_reply_from(reply: ClientEvent) -> ClientEvent {
let ClientEvent::EchoReply {
seq,
remote,
sent_at,
received_at,
rtt,
server_timing,
one_way,
received_stats,
bytes,
packet_meta,
..
} = reply
else {
unreachable!();
};
ClientEvent::LateReply {
seq,
highest_seen: seq + 1,
remote,
sent_at: Some(sent_at),
received_at,
rtt: Some(rtt),
server_timing,
one_way,
received_stats,
bytes,
packet_meta,
}
}
fn unix_time_ns_after_epoch(time: std::time::SystemTime) -> i128 {
time.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() as i128)
.unwrap()
}
+394
View File
@@ -0,0 +1,394 @@
mod common;
use std::time::{Duration, Instant, UNIX_EPOCH};
use common::{adjusted_reply, sent, ts, unadjusted_late_reply, unadjusted_reply};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, SignedDuration,
};
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate, SampleMode, StatsCollector, StatsConfig};
#[test]
fn running_only_samples_avoid_finite_retention() {
let mut collector = StatsCollector::new(StatsConfig::continuous());
collector.process(&unadjusted_reply(0, 10));
collector.process(&unadjusted_reply(1, 20));
let snapshot = collector.snapshot();
assert_eq!(snapshot.rtt.primary.median_ns, None);
assert_eq!(snapshot.rtt.raw.median_ns, None);
assert_eq!(snapshot.rtt.adjusted.count, 0);
assert_eq!(snapshot.rtt.adjusted.median_ns, None);
assert_eq!(snapshot.ipdv.round_trip.median_ns, None);
assert_eq!(snapshot.one_way_delay.send_delay.median_ns, None);
assert_eq!(snapshot.one_way_delay.receive_delay.median_ns, None);
}
#[test]
fn continuous_mode_tracks_running_samples_without_exact_medians() {
let mut collector = StatsCollector::new(StatsConfig::continuous());
for seq in 0..4104 {
collector.process(&unadjusted_reply(seq, 10));
}
let snapshot = collector.snapshot();
assert_eq!(snapshot.rtt.primary.count, 4104);
assert_eq!(snapshot.ipdv.round_trip.count, 4103);
assert_eq!(snapshot.rtt.primary.median_ns, None);
assert_eq!(snapshot.ipdv.round_trip.median_ns, None);
}
#[test]
fn cumulative_rtt_uses_signed_effective_and_tracks_raw() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&adjusted_reply(0, 1, -2));
collector.process(&adjusted_reply(1, 10, 8));
let snapshot = collector.snapshot();
assert_eq!(snapshot.rtt.primary.count, 2);
assert_eq!(snapshot.rtt.primary.min_ns, Some(-2_000_000));
assert_eq!(snapshot.rtt.primary.median_ns, Some(3_000_000.0));
assert_eq!(snapshot.rtt.raw.total_ns, 11_000_000);
assert_eq!(snapshot.rtt.adjusted.count, 2);
}
#[test]
fn late_unique_counts_and_duplicates_do_not_update_duplicate_measurements() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10)));
collector.process(&unadjusted_reply(1, 10));
collector.process(&unadjusted_late_reply(0, 20));
collector.process(&ClientEvent::DuplicateReply {
seq: 0,
remote: "127.0.0.1:2112".parse().unwrap(),
received_at: ts(50),
bytes: 64,
});
let snapshot = collector.snapshot();
assert_eq!(snapshot.packets.packets_sent, 2);
assert_eq!(snapshot.packets.packets_received, 3);
assert_eq!(snapshot.packets.unique_replies, 2);
assert_eq!(snapshot.packets.duplicates, 1);
assert_eq!(snapshot.packets.late_packets, 1);
assert_eq!(snapshot.packets.bytes_received, 64 + 64 + 64);
assert_eq!(snapshot.rtt.primary.count, 2);
assert_eq!(snapshot.loss.lost_packets, 0);
assert_eq!(snapshot.loss.duplicate_percent, 100.0 / 3.0);
}
#[test]
fn final_loss_uses_sent_minus_unique_replies_not_echo_loss_events() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10)));
collector.process(&ClientEvent::EchoLoss {
seq: 0,
sent_at: ts(0),
timeout_at: Instant::now(),
});
collector.process(&unadjusted_late_reply(0, 10));
let snapshot = collector.snapshot();
assert_eq!(snapshot.events.loss_events, 1);
assert_eq!(snapshot.packets.unique_replies, 1);
assert_eq!(snapshot.loss.lost_packets, 1);
assert_eq!(snapshot.loss.packet_loss_percent, 50.0);
}
fn assert_no_ipdv_pairs(update: &EventStatsUpdate) {
assert!(update.ipdv_pairs.is_empty(), "{update:?}");
}
fn assert_one_ipdv_pair(
update: &EventStatsUpdate,
previous_seq: u32,
current_seq: u32,
rtt_ipdv: Duration,
) -> &IpdvPairUpdate {
assert_eq!(update.ipdv_pairs.len(), 1, "{update:?}");
let pair = &update.ipdv_pairs[0];
assert_eq!(pair.previous_seq, previous_seq);
assert_eq!(pair.current_seq, current_seq);
assert_eq!(pair.rtt_ipdv, rtt_ipdv);
pair
}
fn reply_at(seq: u32, raw_ms: u64, sent_ms: u64) -> ClientEvent {
let sent_at = ClientTimestamp {
mono: Instant::now() + Duration::from_millis(sent_ms),
wall: UNIX_EPOCH + Duration::from_millis(sent_ms),
};
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(raw_ms),
wall: sent_at.wall + Duration::from_millis(raw_ms),
};
let raw = Duration::from_millis(raw_ms);
ClientEvent::EchoReply {
seq,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw,
adjusted: None,
effective: SignedDuration::from_duration(raw),
},
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
}
}
#[test]
fn ipdv_is_sequence_adjacent_and_gap_preserving() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&unadjusted_reply(0, 10));
let gap = collector.process(&unadjusted_reply(2, 15));
let adjacent = collector.process(&unadjusted_reply(3, 12));
let snapshot = collector.snapshot();
assert!(first.contributed_sample);
assert_no_ipdv_pairs(&first);
assert!(gap.contributed_sample);
assert_no_ipdv_pairs(&gap);
assert!(adjacent.contributed_sample);
assert_one_ipdv_pair(&adjacent, 2, 3, Duration::from_millis(3));
assert_eq!(snapshot.ipdv.round_trip.count, 1);
assert_eq!(snapshot.ipdv.round_trip.total_ns, 3_000_000);
}
#[test]
fn ipdv_wraparound_sequence_is_adjacent() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&reply_at(u32::MAX, 10, 0));
let wrapped = collector.process(&reply_at(0, 14, 10));
assert!(first.contributed_sample);
assert_no_ipdv_pairs(&first);
assert!(wrapped.contributed_sample);
assert_one_ipdv_pair(&wrapped, u32::MAX, 0, Duration::from_millis(4));
let snapshot = collector.snapshot();
assert_eq!(snapshot.ipdv.round_trip.count, 1);
assert_eq!(snapshot.ipdv.round_trip.total_ns, 4_000_000);
}
#[test]
fn ipdv_wraparound_gap_is_preserved() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let before_wrap = collector.process(&reply_at(u32::MAX - 1, 10, 0));
let wrapped = collector.process(&reply_at(0, 14, 20));
assert!(before_wrap.contributed_sample);
assert_no_ipdv_pairs(&before_wrap);
assert!(wrapped.contributed_sample);
assert_no_ipdv_pairs(&wrapped);
let snapshot = collector.snapshot();
assert_eq!(snapshot.ipdv.round_trip.count, 0);
}
#[test]
fn late_reply_can_complete_ipdv_pair() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(1, 20));
let update = collector.process(&unadjusted_late_reply(0, 10));
let snapshot = collector.snapshot();
assert!(update.contributed_sample);
assert_one_ipdv_pair(&update, 0, 1, Duration::from_millis(10));
assert_eq!(snapshot.ipdv.round_trip.count, 1);
assert_eq!(snapshot.ipdv.round_trip.total_ns, 10_000_000);
}
#[test]
fn update_exposes_directional_ipdv_when_available() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(0, 10));
let update = collector.process(&unadjusted_reply(1, 13));
assert!(update.contributed_sample);
let pair = assert_one_ipdv_pair(&update, 0, 1, Duration::from_millis(3));
assert!(pair.send_ipdv.is_some());
assert!(pair.receive_ipdv.is_some());
}
#[test]
fn gap_fill_update_exposes_both_completed_ipdv_pairs() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let first = collector.process(&unadjusted_reply(0, 10));
let gap = collector.process(&unadjusted_reply(2, 20));
let fill = collector.process(&unadjusted_reply(1, 13));
assert!(first.contributed_sample);
assert!(first.ipdv_pairs.is_empty());
assert!(gap.contributed_sample);
assert!(gap.ipdv_pairs.is_empty());
assert!(fill.contributed_sample);
assert_eq!(fill.ipdv_pairs.len(), 2);
assert_eq!(fill.ipdv_pairs[0].previous_seq, 0);
assert_eq!(fill.ipdv_pairs[0].current_seq, 1);
assert_eq!(fill.ipdv_pairs[0].rtt_ipdv, Duration::from_millis(3));
assert_eq!(fill.ipdv_pairs[1].previous_seq, 1);
assert_eq!(fill.ipdv_pairs[1].current_seq, 2);
assert_eq!(fill.ipdv_pairs[1].rtt_ipdv, Duration::from_millis(7));
let snapshot = collector.snapshot();
assert_eq!(snapshot.ipdv.round_trip.count, 2);
assert_eq!(snapshot.ipdv.round_trip.total_ns, 10_000_000);
}
#[test]
fn server_processing_and_one_way_require_available_samples() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(0, 10));
collector.process(&ClientEvent::LateReply {
seq: 9,
highest_seen: 10,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at: None,
received_at: ts(100),
rtt: None,
server_timing: None,
one_way: None,
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let snapshot = collector.snapshot();
assert_eq!(snapshot.server_processing.processing.count, 1);
assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
assert_eq!(snapshot.events.untracked_late_replies, 1);
assert_eq!(snapshot.packets.packets_received, 2);
assert_eq!(snapshot.packets.unique_replies, 1);
assert_eq!(snapshot.packets.late_packets, 1);
assert_eq!(snapshot.packets.bytes_received, 128);
}
#[test]
fn negative_one_way_delay_samples_are_included_in_stats() {
let mut collector = StatsCollector::new(StatsConfig::finite());
let sent_at = ts(0);
let received_at = ClientTimestamp {
mono: sent_at.mono + Duration::from_millis(10),
wall: sent_at.wall + Duration::from_millis(10),
};
collector.process(&ClientEvent::EchoReply {
seq: 0,
remote: "127.0.0.1:2112".parse().unwrap(),
sent_at,
received_at,
rtt: RttSample {
raw: Duration::from_millis(10),
adjusted: None,
effective: SignedDuration::from_nanos(10_000_000),
},
server_timing: None,
one_way: Some(OneWayDelaySample {
client_to_server: Some(SignedDuration::from_nanos(-1_000_000)),
server_to_client: Some(SignedDuration::from_nanos(-2_000_000)),
}),
received_stats: None,
bytes: 64,
packet_meta: PacketMeta::default(),
});
let snapshot = collector.snapshot();
assert_eq!(snapshot.one_way_delay.send_delay.count, 1);
assert_eq!(snapshot.one_way_delay.send_delay.min_ns, Some(-1_000_000));
assert_eq!(snapshot.one_way_delay.send_delay.total_ns, -1_000_000);
assert_eq!(snapshot.one_way_delay.receive_delay.count, 1);
assert_eq!(
snapshot.one_way_delay.receive_delay.min_ns,
Some(-2_000_000)
);
assert_eq!(snapshot.one_way_delay.receive_delay.total_ns, -2_000_000);
}
#[test]
fn older_cumulative_server_receive_count_does_not_regress() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&unadjusted_reply(1, 10));
collector.process(&unadjusted_late_reply(0, 10));
let snapshot = collector.snapshot();
assert_eq!(snapshot.packets.server_packets_received, Some(2));
}
#[test]
fn rolling_count_eviction_recomputes_from_bounded_events() {
let mut collector = StatsCollector::new(StatsConfig {
samples: SampleMode::RunningOnly,
rolling_count: Some(2),
rolling_time: None,
});
collector.process(&sent(0, ts(0)));
collector.process(&unadjusted_reply(0, 10));
collector.process(&unadjusted_reply(1, 20));
let rolling = collector.rolling_count().unwrap();
assert_eq!(rolling.packets.packets_sent, 0);
assert_eq!(rolling.packets.unique_replies, 2);
assert_eq!(rolling.rtt.primary.count, 2);
}
#[test]
fn rolling_time_eviction_uses_event_timestamps() {
let mut collector = StatsCollector::new(StatsConfig {
samples: SampleMode::RunningOnly,
rolling_count: None,
rolling_time: Some(Duration::from_millis(15)),
});
collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10)));
collector.process(&sent(2, ts(30)));
let rolling = collector.rolling_time().unwrap();
assert_eq!(rolling.packets.packets_sent, 1);
}
#[test]
fn empty_and_all_lost_edges_are_defined() {
let empty = StatsCollector::new(StatsConfig::finite()).snapshot();
assert_eq!(empty.loss.packet_loss_percent, 0.0);
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&sent(0, ts(0)));
let all_lost = collector.snapshot();
assert_eq!(all_lost.loss.lost_packets, 1);
assert_eq!(all_lost.loss.packet_loss_percent, 100.0);
}
#[test]
fn directional_loss_uses_server_received_count_when_available() {
let mut collector = StatsCollector::new(StatsConfig::finite());
collector.process(&sent(0, ts(0)));
collector.process(&sent(1, ts(10)));
collector.process(&unadjusted_reply(0, 10));
let loss = collector.snapshot().loss;
assert_eq!(loss.lost_packets, 1);
assert_eq!(loss.upstream_loss_packets, Some(1));
assert_eq!(loss.downstream_loss_packets, Some(0));
assert_eq!(loss.packet_loss_percent, 50.0);
assert_eq!(loss.upstream_loss_percent, 50.0);
}
+14
View File
@@ -0,0 +1,14 @@
[workspace]
members = ["cargo:."]
# Config for 'dist'
[dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.32.0"
# CI backends to support
ci = "github"
# The installers to generate for each app
installers = []
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"]
features = ["tui"]
+143
View File
@@ -0,0 +1,143 @@
# Interop Comparison
`scripts/compare_irtt_clients.py` is a black-box comparison harness for running
upstream `irtt` and `irtt-rs` clients concurrently against the same server. It
captures raw stdout/stderr, upstream JSON, parsed RTT samples, per-case
summaries, and comparison JSON under `target/interop-comparisons` by default.
The harness does not inspect upstream source or tests. It only invokes the
upstream executable.
## Remote or Existing Local Server
Build `irtt-rs` and ensure an upstream `irtt` executable is available:
```sh
cargo build -p irtt-cli
go install github.com/heistp/irtt/cmd/irtt@v0.9.1
```
Run the default comparison set:
```sh
python3 scripts/compare_irtt_clients.py --upstream-irtt "$HOME/go/bin/irtt"
```
To compare against a specific target:
```sh
python3 scripts/compare_irtt_clients.py \
--target netperf-eu.bufferbloat.net \
--duration 15s \
--interval 250ms \
--upstream-irtt "$HOME/go/bin/irtt"
```
Remote comparisons are useful for smoke coverage, but Internet paths are noisy
and can make RTT/loss differences hard to interpret.
## Linux Netem Interop
`scripts/run_netem_interop_ci.py` provides deterministic Linux CI coverage using
network namespaces, a veth pair, and `tc netem`. It is intended for GitHub
Actions Ubuntu runners or a Linux host with passwordless `sudo` for `ip` and
`tc`.
The harness creates two namespaces:
- `irtt-client` with `veth-client` at `10.10.0.1/24`
- `irtt-server` with `veth-server` at `10.10.0.2/24`
It runs the upstream server in the server namespace:
```sh
irtt server -b 10.10.0.2:2112 --tstamp=dual
```
Both clients run in the client namespace through the existing comparison
harness:
```sh
sudo ip netns exec irtt-client ...
```
Netem qdiscs are applied on veth egress:
- `veth-client` controls client-to-server delay/loss
- `veth-server` controls server-to-client delay/loss
Cleanup deletes qdiscs, stops the server process, and removes both namespaces in
a `finally` path.
### Scenarios
The CI harness implements:
| scenario | client-to-server | server-to-client | duration | interval | policy |
| --- | --- | --- | --- | --- | --- |
| `baseline-no-netem` | none | none | `5s` | `100ms` | deterministic |
| `symmetric-20ms` | `delay 20ms` | `delay 20ms` | `5s` | `100ms` | deterministic |
| `symmetric-50ms-jitter` | `delay 50ms 5ms` | `delay 50ms 5ms` | `5s` | `100ms` | jitter |
| `asymmetric-delay` | `delay 40ms` | `delay 10ms` | `5s` | `100ms` | deterministic |
| `packet-loss` | `loss 5%` | `loss 5%` | `10s` | `50ms` | stochastic loss |
Asymmetric one-way delay fields include client/server wall-clock offset. Since
both clients run in the same client namespace against the same server namespace,
relative one-way shape is still useful for diagnostics, but the CI pass/fail
policy is based on RTT and packet behavior.
### Pass/Fail Policy
Deterministic scenarios fail when:
- either client exits non-zero
- parsed mean RTT or received packet counts are missing
- either client receives no packets
- unexpected loss exceeds `0.5%`
- mean RTT delta exceeds `max(2000us, 5% of expected RTT)`
The jitter scenario fails on crashes, parser failures, no received packets, or
mean RTT delta above `max(8000us, 10% of expected RTT)`.
The packet-loss scenario fails on crashes, parser failures, or no received
packets. Loss-rate deltas above `10%` are warnings because packet loss is
stochastic and exact percentages are not stable enough for CI.
Thresholds can be adjusted with:
```sh
python3 scripts/run_netem_interop_ci.py \
--mean-abs-tolerance-us 3000 \
--mean-pct-tolerance 0.08 \
--jitter-mean-abs-tolerance-us 10000 \
--loss-rate-warning-pct 12
```
### Manual Linux Usage
On Linux:
```sh
cargo build -p irtt-cli
go install github.com/heistp/irtt/cmd/irtt@v0.9.1
python3 scripts/run_netem_interop_ci.py \
--all \
--skip-build \
--upstream-irtt "$HOME/go/bin/irtt" \
--irtt-rs-command "$PWD/target/debug/irtt-cli"
```
Run one scenario:
```sh
python3 scripts/run_netem_interop_ci.py --scenario symmetric-20ms
```
Artifacts are written under `target/interop-netem/<timestamp>/` by default. Each
scenario contains the existing comparison harness output under
`<scenario>/comparison/<timestamp>/`, plus netem-specific `summary.md` and
`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
workflow for netem coverage.
+1143
View File
File diff suppressed because it is too large. Load diff
+716
View File
@@ -0,0 +1,716 @@
#!/usr/bin/env python3
"""Run local netem-based IRTT client interop comparisons on Linux.
The harness creates two network namespaces connected by a veth pair, runs an
upstream irtt server in the server namespace, and runs the existing paired
client comparison harness in the client namespace.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import platform
import re
import shutil
import shlex
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
COMPARE_SCRIPT = REPO_ROOT / "scripts" / "compare_irtt_clients.py"
DEFAULT_OUTPUT_ROOT = REPO_ROOT / "target" / "interop-netem"
CLIENT_NS = "irtt-client"
SERVER_NS = "irtt-server"
CLIENT_VETH = "veth-client"
SERVER_VETH = "veth-server"
CLIENT_ADDR = "10.10.0.1/24"
SERVER_ADDR = "10.10.0.2/24"
SERVER_TARGET = "10.10.0.2:2112"
@dataclass(frozen=True)
class Scenario:
name: str
duration: str
interval: str
client_to_server: str
server_to_client: str
expected_rtt_us: float | None
mode: str
SCENARIOS = {
scenario.name: scenario
for scenario in [
Scenario("baseline-no-netem", "5s", "100ms", "", "", None, "deterministic"),
Scenario("symmetric-20ms", "5s", "100ms", "delay 20ms", "delay 20ms", 40_000, "deterministic"),
Scenario(
"symmetric-50ms-jitter",
"5s",
"100ms",
"delay 50ms 5ms",
"delay 50ms 5ms",
100_000,
"jitter",
),
Scenario("asymmetric-delay", "5s", "100ms", "delay 40ms", "delay 10ms", 50_000, "deterministic"),
Scenario("packet-loss", "10s", "50ms", "loss 5%", "loss 5%", None, "loss"),
]
}
class CommandError(RuntimeError):
def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str):
self.command = command
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
super().__init__(f"{shlex.join(command)} exited {returncode}")
def utc_now() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run upstream irtt vs irtt-rs comparisons inside netem namespaces."
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--scenario",
action="append",
choices=sorted(SCENARIOS),
help="Scenario to run. Repeatable. Defaults to baseline-no-netem unless --all is set.",
)
group.add_argument("--all", action="store_true", help="Run all netem scenarios.")
parser.add_argument(
"--output-dir",
default=str(DEFAULT_OUTPUT_ROOT),
help=f"Artifact root directory (default: {DEFAULT_OUTPUT_ROOT}).",
)
parser.add_argument(
"--upstream-irtt",
default="irtt",
help="Path to upstream irtt executable (default: irtt from PATH).",
)
parser.add_argument(
"--irtt-rs-command",
default=str(REPO_ROOT / "target" / "debug" / "irtt-cli"),
help="Path or command for irtt-rs client (default: target/debug/irtt-cli).",
)
parser.add_argument(
"--skip-build",
action="store_true",
help="Do not run cargo build -p irtt-cli before comparisons.",
)
parser.add_argument(
"--sudo",
default="sudo",
help="Privilege escalation command for ip/tc namespace operations (default: sudo).",
)
parser.add_argument(
"--mean-abs-tolerance-us",
type=float,
default=2_000.0,
help="Minimum deterministic mean RTT delta tolerance in microseconds.",
)
parser.add_argument(
"--mean-pct-tolerance",
type=float,
default=0.05,
help="Deterministic mean RTT delta tolerance as a fraction of expected RTT.",
)
parser.add_argument(
"--jitter-mean-abs-tolerance-us",
type=float,
default=8_000.0,
help="Minimum jitter scenario mean RTT delta fail tolerance in microseconds.",
)
parser.add_argument(
"--loss-rate-warning-pct",
type=float,
default=10.0,
help="Packet-loss scenario warning threshold for client loss-rate delta.",
)
parser.add_argument(
"--loss-abs-tolerance-pct",
type=float,
default=8.0,
help="Packet-loss scenario absolute tolerance from expected RTT loss percentage.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
selected = select_scenarios(args)
run_dir = Path(args.output_dir) / dt.datetime.now().strftime("%Y%m%d-%H%M%S")
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "run-info.json").write_text(
json.dumps(
{
"created_at": utc_now(),
"scenarios": [scenario.name for scenario in selected],
"client_namespace": CLIENT_NS,
"server_namespace": SERVER_NS,
"target": SERVER_TARGET,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
try:
preflight(args)
upstream_irtt = resolve_command(args.upstream_irtt)
irtt_rs_command = resolve_irtt_rs_command(args.irtt_rs_command)
if not args.skip_build:
run_checked(["cargo", "build", "-p", "irtt-cli"], run_dir / "cargo-build")
cleanup(args, quiet=True)
setup_namespaces(args, run_dir)
server_proc = start_server(args, upstream_irtt, run_dir)
try:
wait_for_server(args, upstream_irtt, run_dir)
results = []
all_ok = True
for scenario in selected:
result = run_scenario(args, scenario, upstream_irtt, irtt_rs_command, run_dir)
results.append(result)
all_ok = all_ok and result["classification"] != "fail"
write_run_summary(run_dir, results)
return 0 if all_ok else 1
finally:
stop_process(server_proc)
except Exception as exc:
(run_dir / "error.txt").write_text(f"{type(exc).__name__}: {exc}\n", encoding="utf-8")
print(f"error: {exc}", file=sys.stderr)
return 2
finally:
cleanup(args, quiet=True)
print(f"[{utc_now()}] wrote netem artifacts to {run_dir}", flush=True)
def select_scenarios(args: argparse.Namespace) -> list[Scenario]:
if args.all:
return [SCENARIOS[name] for name in sorted(SCENARIOS)]
names = args.scenario or ["baseline-no-netem"]
return [SCENARIOS[name] for name in names]
def preflight(args: argparse.Namespace) -> None:
if platform.system() != "Linux":
raise RuntimeError("netem interop requires Linux network namespaces and tc netem")
for binary in ["ip", "tc"]:
if shutil.which(binary) is None:
raise RuntimeError(f"required command not found in PATH: {binary}")
if args.sudo and shutil.which(args.sudo) is None:
raise RuntimeError(f"sudo command not found in PATH: {args.sudo}")
if not COMPARE_SCRIPT.exists():
raise RuntimeError(f"missing comparison harness: {COMPARE_SCRIPT}")
def resolve_command(command: str) -> str:
parts = shlex.split(command)
if len(parts) != 1:
raise RuntimeError(f"expected a single executable path, got: {command}")
executable = parts[0]
if os.sep in executable:
return str(Path(executable).resolve())
resolved = shutil.which(executable)
if resolved is None:
raise RuntimeError(f"executable not found in PATH: {executable}")
return resolved
def resolve_irtt_rs_command(command: str) -> str:
parts = shlex.split(command)
if not parts:
raise RuntimeError("--irtt-rs-command must not be empty")
executable = parts[0]
if os.sep in executable:
parts[0] = str(Path(executable).resolve())
else:
resolved = shutil.which(executable)
if resolved is not None:
parts[0] = resolved
return shlex.join(parts)
def sudo_prefix(args: argparse.Namespace) -> list[str]:
return [args.sudo] if args.sudo else []
def netns(args: argparse.Namespace, namespace: str, command: list[str]) -> list[str]:
return [*sudo_prefix(args), "ip", "netns", "exec", namespace, *command]
def run_checked(
command: list[str],
log_prefix: Path,
check: bool = True,
ignored_stderr_texts: list[str] | None = None,
) -> subprocess.CompletedProcess[str]:
print(f"[{utc_now()}] {shlex.join(command)}", flush=True)
completed = subprocess.run(
command,
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stderr = filter_stderr(completed.stderr, ignored_stderr_texts or [])
log_prefix.parent.mkdir(parents=True, exist_ok=True)
(log_prefix.with_suffix(".stdout")).write_text(completed.stdout, encoding="utf-8")
if stderr or not ignored_stderr_texts:
(log_prefix.with_suffix(".stderr")).write_text(stderr, encoding="utf-8")
(log_prefix.with_suffix(".command")).write_text(shlex.join(command) + "\n", encoding="utf-8")
if check and completed.returncode != 0:
raise CommandError(command, completed.returncode, completed.stdout, stderr)
return completed
def filter_stderr(stderr: str, ignored_texts: list[str]) -> str:
if not stderr or not ignored_texts:
return stderr
kept = [line for line in stderr.splitlines() if not any(text in line for text in ignored_texts)]
if stderr.endswith("\n") and kept:
return "\n".join(kept) + "\n"
return "\n".join(kept)
def setup_namespaces(args: argparse.Namespace, run_dir: Path) -> None:
commands = [
[*sudo_prefix(args), "ip", "netns", "add", CLIENT_NS],
[*sudo_prefix(args), "ip", "netns", "add", SERVER_NS],
[*sudo_prefix(args), "ip", "link", "add", CLIENT_VETH, "type", "veth", "peer", "name", SERVER_VETH],
[*sudo_prefix(args), "ip", "link", "set", CLIENT_VETH, "netns", CLIENT_NS],
[*sudo_prefix(args), "ip", "link", "set", SERVER_VETH, "netns", SERVER_NS],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "addr", "add", CLIENT_ADDR, "dev", CLIENT_VETH],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "addr", "add", SERVER_ADDR, "dev", SERVER_VETH],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", "lo", "up"],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", "lo", "up"],
[*sudo_prefix(args), "ip", "-n", CLIENT_NS, "link", "set", CLIENT_VETH, "up"],
[*sudo_prefix(args), "ip", "-n", SERVER_NS, "link", "set", SERVER_VETH, "up"],
]
for index, command in enumerate(commands):
run_checked(command, run_dir / "setup" / f"{index:02d}")
def start_server(args: argparse.Namespace, upstream_irtt: str, run_dir: Path) -> subprocess.Popen[bytes]:
command = netns(
args,
SERVER_NS,
[upstream_irtt, "server", "-b", SERVER_TARGET, "--tstamp=dual"],
)
(run_dir / "server.command").write_text(shlex.join(command) + "\n", encoding="utf-8")
stdout = (run_dir / "server.stdout").open("wb")
stderr = (run_dir / "server.stderr").open("wb")
print(f"[{utc_now()}] starting server: {shlex.join(command)}", flush=True)
try:
return subprocess.Popen(command, cwd=REPO_ROOT, stdout=stdout, stderr=stderr)
except Exception:
stdout.close()
stderr.close()
raise
def wait_for_server(args: argparse.Namespace, upstream_irtt: str, run_dir: Path) -> None:
command = netns(
args,
CLIENT_NS,
[upstream_irtt, "client", "-n", "-Q", "--timeouts=1s", SERVER_TARGET],
)
deadline = time.monotonic() + 10.0
attempt = 0
last: subprocess.CompletedProcess[str] | None = None
while time.monotonic() < deadline:
attempt += 1
last = run_checked(command, run_dir / "server-ready" / f"attempt-{attempt:02d}", check=False)
if last.returncode == 0:
return
time.sleep(0.5)
details = ""
if last is not None:
details = (last.stderr or last.stdout).strip()
raise RuntimeError(f"upstream irtt server did not become reachable: {details}")
def run_scenario(
args: argparse.Namespace,
scenario: Scenario,
upstream_irtt: str,
irtt_rs_command: str,
run_dir: Path,
) -> dict[str, Any]:
scenario_dir = run_dir / scenario.name
scenario_dir.mkdir(parents=True, exist_ok=True)
apply_netem(args, scenario, scenario_dir)
compare_root = scenario_dir / "comparison"
command = [
sys.executable,
str(COMPARE_SCRIPT),
"--target",
SERVER_TARGET,
"--duration",
scenario.duration,
"--interval",
scenario.interval,
"--output-dir",
str(compare_root),
"--upstream-irtt",
upstream_irtt,
"--irtt-rs-command",
irtt_rs_command,
"--client-command-prefix",
shlex.join(netns(args, CLIENT_NS, [])),
"--skip-build",
]
completed = run_checked(command, scenario_dir / "compare", check=False)
comparison = load_latest_comparison(compare_root)
result = classify_scenario(args, scenario, completed.returncode, comparison)
result.update(
{
"scenario": scenario.__dict__,
"qdisc": {
"client_to_server": scenario.client_to_server or "none",
"server_to_client": scenario.server_to_client or "none",
},
"comparison_returncode": completed.returncode,
"comparison_root": str(compare_root),
"comparison_json": comparison.get("_path"),
}
)
write_json(scenario_dir / "netem-summary.json", result)
write_scenario_markdown(scenario_dir / "summary.md", result)
return result
def apply_netem(args: argparse.Namespace, scenario: Scenario, scenario_dir: Path) -> None:
delete_qdiscs(args, scenario_dir)
commands: list[tuple[str, str, str]] = [
(CLIENT_NS, CLIENT_VETH, scenario.client_to_server),
(SERVER_NS, SERVER_VETH, scenario.server_to_client),
]
for namespace, dev, setting in commands:
if not setting:
continue
command = [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
"tc",
"qdisc",
"replace",
"dev",
dev,
"root",
"netem",
*shlex.split(setting),
]
run_checked(command, scenario_dir / "netem" / f"{namespace}-{dev}")
def delete_qdiscs(args: argparse.Namespace, log_dir: Path) -> None:
for namespace, dev in [(CLIENT_NS, CLIENT_VETH), (SERVER_NS, SERVER_VETH)]:
command = [
*sudo_prefix(args),
"ip",
"netns",
"exec",
namespace,
"tc",
"qdisc",
"del",
"dev",
dev,
"root",
]
run_checked(
command,
log_dir / "qdisc-cleanup" / f"{namespace}-{dev}",
check=False,
ignored_stderr_texts=["Cannot delete qdisc with handle of zero"],
)
def load_latest_comparison(compare_root: Path) -> dict[str, Any]:
candidates = sorted(compare_root.glob("*/comparison.json"), key=lambda path: path.stat().st_mtime)
if not candidates:
return {"_path": None, "results": [], "local_error": "comparison.json not found"}
path = candidates[-1]
data = json.loads(path.read_text(encoding="utf-8"))
data["_path"] = str(path)
return data
def classify_scenario(
args: argparse.Namespace,
scenario: Scenario,
returncode: int,
comparison: dict[str, Any],
) -> dict[str, Any]:
notes: list[str] = []
warnings: list[str] = []
failures: list[str] = []
result = first_comparison_result(comparison)
upstream = result.get("metrics", {}).get("upstream", {}) if result else {}
rust = result.get("metrics", {}).get("irtt_rs", {}) if result else {}
if returncode != 0:
failures.append(f"comparison harness exited {returncode}")
if not result:
failures.append("comparison result missing")
for label, metrics in [("upstream", upstream), ("irtt-rs", rust)]:
if metrics.get("rtt_mean_us") is None:
failures.append(f"{label} mean RTT missing")
if (metrics.get("received") or 0) <= 0:
failures.append(f"{label} received no packets")
mean_delta_us = None
if upstream.get("rtt_mean_us") is not None and rust.get("rtt_mean_us") is not None:
mean_delta_us = rust["rtt_mean_us"] - upstream["rtt_mean_us"]
baseline = scenario.expected_rtt_us or max(abs(upstream["rtt_mean_us"]), 1.0)
if scenario.mode == "deterministic":
tolerance = max(args.mean_abs_tolerance_us, args.mean_pct_tolerance * baseline)
if abs(mean_delta_us) > tolerance:
failures.append(
f"mean RTT delta {mean_delta_us:.1f}us exceeds deterministic tolerance {tolerance:.1f}us"
)
elif scenario.mode == "jitter":
tolerance = max(args.jitter_mean_abs_tolerance_us, 0.10 * baseline)
if abs(mean_delta_us) > tolerance:
failures.append(
f"mean RTT delta {mean_delta_us:.1f}us exceeds jitter tolerance {tolerance:.1f}us"
)
if scenario.mode == "deterministic":
for label, metrics in [("upstream", upstream), ("irtt-rs", rust)]:
loss = metrics.get("loss_percent")
if loss is not None and loss > 0.5:
failures.append(f"{label} observed unexpected loss {loss:.3f}%")
elif scenario.mode == "loss":
upstream_loss = upstream.get("loss_percent")
rust_loss = rust.get("loss_percent")
expected_loss = expected_rtt_loss_percent(scenario)
if upstream_loss is not None and rust_loss is not None:
loss_delta = rust_loss - upstream_loss
if abs(loss_delta) > args.loss_rate_warning_pct:
warnings.append(
f"loss-rate delta {loss_delta:.3f}% exceeds warning threshold "
f"{args.loss_rate_warning_pct:.3f}%"
)
for label, observed_loss in [("upstream", upstream_loss), ("irtt-rs", rust_loss)]:
if observed_loss is None:
failures.append(f"{label} loss rate missing")
elif observed_loss <= 0.0:
failures.append(f"{label} observed no loss in loss scenario")
elif abs(observed_loss - expected_loss) > args.loss_abs_tolerance_pct:
failures.append(
f"{label} loss {observed_loss:.3f}% outside expected "
f"{expected_loss:.3f}% +/- {args.loss_abs_tolerance_pct:.3f}%"
)
comparison_notes = result.get("comparison", {}).get("notes", []) if result else []
notes.extend(comparison_notes)
classification = "fail" if failures else ("warning" if warnings else "pass")
return {
"classification": classification,
"failures": failures,
"warnings": warnings,
"notes": sorted(set(notes)),
"mean_rtt_delta_us": mean_delta_us,
"expected_loss_percent": expected_rtt_loss_percent(scenario) if scenario.mode == "loss" else None,
"upstream": upstream,
"irtt_rs": rust,
}
def expected_rtt_loss_percent(scenario: Scenario) -> float:
client_to_server_loss = netem_loss_percent(scenario.client_to_server)
server_to_client_loss = netem_loss_percent(scenario.server_to_client)
success_probability = (1.0 - client_to_server_loss / 100.0) * (1.0 - server_to_client_loss / 100.0)
return 100.0 * (1.0 - success_probability)
def netem_loss_percent(setting: str) -> float:
match = re.search(r"(?:^|\s)loss\s+([0-9]+(?:\.[0-9]+)?)%", setting)
if match is None:
return 0.0
return float(match.group(1))
def first_comparison_result(comparison: dict[str, Any]) -> dict[str, Any] | None:
results = comparison.get("results")
if isinstance(results, list) and results:
first = results[0]
if isinstance(first, dict):
return first
return None
def write_json(path: Path, value: Any) -> None:
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def write_scenario_markdown(path: Path, result: dict[str, Any]) -> None:
lines = [
f"# {result['scenario']['name']}",
"",
f"classification: `{result['classification']}`",
f"client-to-server netem: `{result['qdisc']['client_to_server']}`",
f"server-to-client netem: `{result['qdisc']['server_to_client']}`",
f"comparison_json: `{result.get('comparison_json')}`",
f"mean_rtt_delta_us: `{format_optional(result.get('mean_rtt_delta_us'))}`",
f"expected_loss_percent: `{format_optional(result.get('expected_loss_percent'))}`",
"",
"| client | sent | received | loss % | mean RTT us |",
"| --- | ---: | ---: | ---: | ---: |",
markdown_metric_row("upstream", result["upstream"]),
markdown_metric_row("irtt-rs", result["irtt_rs"]),
]
if result["failures"]:
lines.extend(["", "## Failures"])
lines.extend(f"- {item}" for item in result["failures"])
if result["warnings"]:
lines.extend(["", "## Warnings"])
lines.extend(f"- {item}" for item in result["warnings"])
if result["notes"]:
lines.extend(["", "## Comparison Notes"])
lines.extend(f"- {item}" for item in result["notes"])
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def markdown_metric_row(label: str, metrics: dict[str, Any]) -> str:
return (
f"| {label} | {format_optional(metrics.get('sent'))} | "
f"{format_optional(metrics.get('received'))} | "
f"{format_optional(metrics.get('loss_percent'))} | "
f"{format_optional(metrics.get('rtt_mean_us'))} |"
)
def format_optional(value: Any) -> str:
if value is None:
return "-"
if isinstance(value, float):
return f"{value:.3f}"
return str(value)
def write_run_summary(run_dir: Path, results: list[dict[str, Any]]) -> None:
write_json(run_dir / "netem-summary.json", {"results": results})
(run_dir / "summary.md").write_text(
build_run_summary_markdown(results, link_scenarios=True, include_artifact_note=False),
encoding="utf-8",
)
(run_dir / "github-step-summary.md").write_text(
build_run_summary_markdown(results, link_scenarios=False, include_artifact_note=True),
encoding="utf-8",
)
def build_run_summary_markdown(
results: list[dict[str, Any]],
*,
link_scenarios: bool,
include_artifact_note: bool,
) -> str:
lines = [
"# Netem IRTT Interop Run",
"",
]
if include_artifact_note:
lines.extend(
[
"Detailed per-scenario files are available in the uploaded `interop-netem` artifact.",
"",
]
)
lines.extend(
[
"| scenario | classification | expected RTT us | expected loss % | upstream median RTT us | irtt-rs median RTT us | upstream loss % | irtt-rs loss % | mean RTT delta us |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
]
)
for result in results:
scenario = result["scenario"]
upstream = result["upstream"]
rust = result["irtt_rs"]
scenario_name = scenario["name"]
scenario_cell = f"[{scenario_name}]({scenario_name}/summary.md)" if link_scenarios else scenario_name
lines.append(
f"| {scenario_cell} | "
f"{result['classification']} | "
f"{format_optional(scenario.get('expected_rtt_us'))} | "
f"{format_optional(result.get('expected_loss_percent'))} | "
f"{format_optional(upstream.get('rtt_median_us'))} | "
f"{format_optional(rust.get('rtt_median_us'))} | "
f"{format_optional(upstream.get('loss_percent'))} | "
f"{format_optional(rust.get('loss_percent'))} | "
f"{format_optional(result.get('mean_rtt_delta_us'))} |"
)
failures = [(result["scenario"]["name"], item) for result in results for item in result["failures"]]
warnings = [(result["scenario"]["name"], item) for result in results for item in result["warnings"]]
if failures:
lines.extend(["", "## Failures"])
lines.extend(format_run_summary_item(scenario, item, link_scenarios) for scenario, item in failures)
if warnings:
lines.extend(["", "## Warnings"])
lines.extend(format_run_summary_item(scenario, item, link_scenarios) for scenario, item in warnings)
return "\n".join(lines) + "\n"
def format_run_summary_item(scenario: str, item: str, link_scenarios: bool) -> str:
scenario_label = f"[{scenario}]({scenario}/summary.md)" if link_scenarios else scenario
return f"- {scenario_label}: {item}"
def stop_process(proc: subprocess.Popen[bytes]) -> None:
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.send_signal(signal.SIGKILL)
proc.wait(timeout=5)
def cleanup(args: argparse.Namespace, quiet: bool) -> None:
if shutil.which("ip") is None:
return
if args.sudo and shutil.which(args.sudo) is None:
return
commands = [
[*sudo_prefix(args), "ip", "netns", "exec", CLIENT_NS, "tc", "qdisc", "del", "dev", CLIENT_VETH, "root"],
[*sudo_prefix(args), "ip", "netns", "exec", SERVER_NS, "tc", "qdisc", "del", "dev", SERVER_VETH, "root"],
[*sudo_prefix(args), "ip", "netns", "delete", CLIENT_NS],
[*sudo_prefix(args), "ip", "netns", "delete", SERVER_NS],
]
for command in commands:
completed = subprocess.run(
command,
cwd=REPO_ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if not quiet and completed.returncode != 0:
print(f"cleanup command failed: {shlex.join(command)}", file=sys.stderr)
if __name__ == "__main__":
sys.exit(main())