Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b61386abd6 | ||
|
|
38e912632d | ||
|
|
39cbf7b6b5 | ||
|
|
eaa37d7568 | ||
|
|
da7472e10a | ||
|
|
9e6c10c083 | ||
|
|
988a360fef | ||
|
|
c28e63e67a | ||
|
|
c0a4f7a6db | ||
|
|
5bc27ef894 | ||
|
|
4c941a7d78 | ||
|
|
b98d1001de | ||
|
|
8e4424764d | ||
|
|
1e61f4168d | ||
|
|
22cead20dc | ||
|
|
e8de15d60e | ||
|
|
0a928dcf8e | ||
|
|
e13802ccdb | ||
|
|
d81784f0f6 | ||
|
|
b1b0b62848 | ||
|
|
5067ceeb1c | ||
|
|
66abb792ce | ||
|
|
1f7df1ebc9 | ||
|
|
1eed942423 | ||
|
|
0e32c76c78 | ||
|
|
e65d415c7d | ||
|
|
e7de8bdb30 | ||
|
|
b9cc3e2779 | ||
|
|
b7890f1190 | ||
|
|
bc9ea0fe99 | ||
|
|
2a2dd47e38 | ||
|
|
236b80db1d | ||
|
|
2cd8a7d60d | ||
|
|
784ce77354 | ||
|
|
6ebecaec85 | ||
|
|
664ef56b6e | ||
|
|
5cffd2e56a | ||
|
|
c9e539933b | ||
|
|
a6ba335302 | ||
|
|
eebe83986a | ||
|
|
d467211f41 | ||
|
|
f62e6a9953 | ||
|
|
20960e1b8d | ||
|
|
c436bfb686 | ||
|
|
156db4c720 | ||
|
|
9c703ecff2 | ||
|
|
798efaf360 | ||
|
|
4b05c3027b | ||
|
|
596dd20bcf | ||
|
|
ebc1f2d0b1 | ||
|
|
a45396c236 | ||
|
|
743a050416 | ||
|
|
524d56f72c | ||
|
|
ccdf4ea39c | ||
|
|
1d211c81ad | ||
|
|
ded61b58e2 | ||
|
|
bdc5d152d4 | ||
|
|
8986caecb0 | ||
|
|
edff75e55a | ||
|
|
48a50d7f3a | ||
|
|
74e7532965 | ||
|
|
dc5b19059e | ||
|
|
50fe83a4c8 | ||
|
|
f2fce2f330 | ||
|
|
f350c82dae | ||
|
|
22d3f6cb27 | ||
|
|
85238682b8 | ||
|
|
9240152e4b | ||
|
|
7bbc89e1c0 | ||
|
|
b6043e4961 | ||
|
|
b36842ee98 | ||
|
|
cf48988170 | ||
|
|
41aee2a551 | ||
|
|
722539c8d7 | ||
|
|
8369ec3502 | ||
|
|
1df69969db | ||
|
|
4908921fb8 | ||
|
|
0d69255e9e | ||
|
|
d7d6e76682 | ||
|
|
328ff17901 | ||
|
|
19782dd3fd | ||
|
|
b3241958d9 | ||
|
|
8644b70b89 | ||
|
|
d335ad78c9 | ||
|
|
bc2bf34ed4 |
No files matched your search
@@ -28,7 +28,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
components: rustfmt
|
||||
cache: false
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
components: clippy
|
||||
cache: false
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
sudo apt-get install --no-install-recommends musl-tools
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache: false
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# (native builds, no emulation), but buildx still needs a driver that
|
||||
# can assemble a multi-platform manifest list.
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
# below), never a separate build, so the hash matches everywhere.
|
||||
- name: Build and push to Gitea
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Proto Fuzz Smoke
|
||||
name: Fuzz Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install stable Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
# regression; this is not meant to be exhaustive fuzzing.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint; do
|
||||
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
|
||||
echo "::group::fuzz smoke: ${target}"
|
||||
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
|
||||
-max_total_time=10 \
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# It remains here while portable actionlint runs with Gitea CI, but scans
|
||||
# both workflow directories through its GitHub Actions rule set.
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3
|
||||
uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
|
||||
with:
|
||||
persona: regular
|
||||
inputs: |
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
sudo apt-get install --no-install-recommends musl-tools
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
|
||||
# in addition to a PR check which fails if new vulnerabilities are introduced.
|
||||
# Scan all dependencies on main and compare newly introduced vulnerabilities on PRs.
|
||||
#
|
||||
# For more examples and options, including how to ignore specific vulnerabilities,
|
||||
# see https://google.github.io/osv-scanner/github-action/
|
||||
@@ -20,6 +19,7 @@ on:
|
||||
- cron: "45 8 * * 1"
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
# Require writing security events to upload SARIF file to security tab
|
||||
@@ -29,21 +29,12 @@ permissions:
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Both pinned workflows default to a recursive scan of ./, including fuzz/.
|
||||
# OSV v2 excludes the root Git repository unless --include-git-root is set;
|
||||
# the old --skip-git flag is no longer supported.
|
||||
scan-scheduled:
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
./
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
|
||||
scan-pr:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
./
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
components: clippy
|
||||
cache-key: clippy-sarif
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
exit 0
|
||||
|
||||
- name: Upload Clippy SARIF
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
|
||||
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: rust-clippy-results.sarif
|
||||
|
||||
@@ -96,6 +96,11 @@ Prefer production interfaces and component/integration tests at the lowest archi
|
||||
|
||||
Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them.
|
||||
|
||||
Concurrency correctness is a production requirement, but deterministic coverage
|
||||
does not by itself justify a test-only synchronization hook. Prefer simplifying
|
||||
the concurrency machinery or observing receipts/status/events through the public
|
||||
control surface before introducing or restoring internal race gates.
|
||||
|
||||
### Every persistent test must earn its place
|
||||
|
||||
Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation.
|
||||
|
||||
+128
@@ -10,6 +10,12 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-proto
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
|
||||
- Added a crate-specific README and runnable API doctests to make the published crate's protocol types and codecs easier to discover and use.
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Added
|
||||
@@ -28,6 +34,49 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-client
|
||||
|
||||
### 0.6.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Added `ManagedClientHandle::subscribe_status()` and `ManagedStatusSubscription` for observing durable lifecycle and completion state independently of the lossy event stream. The current snapshot is immediately readable, change notifications track subsequent updates, and terminal target status includes its completed outcome.
|
||||
- Managed targets can override the shared address-family policy while retaining the original hostname for resolution on each new generation.
|
||||
|
||||
#### Changed
|
||||
|
||||
- `ClientConfig` is now reusable across endpoints: `Client::connect` and `AsyncClient::connect` take the endpoint separately, with configuration grouped into `socket`, `request` (`SessionRequest`), and `open` (`OpenPolicy`). `AddressFamily` replaces the separate socket family flags, and blocking receive timeouts are configured through `Client::set_recv_timeout`.
|
||||
- Explicit `Authentication`, `HmacKey`, and managed `TargetAuth` replace `ClientConfig.hmac_key` and `ClientAuthConfig`. Key bytes are shared across clones and redacted from debug output. Switching between inherited and explicit authentication or address-family settings starts a new target generation, even when the effective settings are equal.
|
||||
- `NegotiationResult` replaces `NegotiatedParams`, separating typed `AcceptedSessionParameters` from exact peer-returned wire parameters and accepted `NegotiationChange` records (formerly `NegotiationRestriction`). Open outcomes and lifecycle events now use tuple variants carrying `SessionStarted` or `NoTestCompleted` payloads.
|
||||
- Low-level clients now leave probe cadence and run duration entirely to the caller. `next_send_deadline` and `is_run_complete` were removed; managed clients continue to provide scheduling and finite-run completion. Low-level callers can inspect `negotiation()`, `has_pending_probes()`, and `next_probe_timeout_deadline()` to drive their own event loop.
|
||||
- Probe sends now return `Result<SendReceipt, SendProbeError>`, distinguishing failures before socket acceptance from failures after a probe was committed. Receipts convert to `ClientEvent::EchoSent`; its `scheduled_at` and `timer_error` fields are now optional and absent for low-level sends.
|
||||
- Stop receipts resolve when the stop request is durably observed or the task becomes terminal; callers must still await task completion to observe finished cleanup.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Finite managed runs now reach their negotiated end under socket backpressure, even when no pending probe timeout remains to wake the driver. Staggered pacing no longer repeatedly wakes idle or draining targets that have no send deadline.
|
||||
- Blocking receive operations now return lifecycle errors immediately when no session is open, instead of blocking for a datagram or returning empty success after a timeout.
|
||||
|
||||
#### Performance
|
||||
|
||||
- Managed drain-deadline lookup no longer scans retained timed-out probes, keeping lookup work bounded as retained probe state grows.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- The configuration, authentication, negotiation, lifecycle-event, and send APIs above are breaking changes without compatibility aliases. Low-level callers must enforce their own cadence and duration or use a managed client.
|
||||
- `AcceptedSessionParameters::dscp` is a six-bit codepoint; `NegotiationResult::peer_params.dscp` remains the raw traffic-class byte. Accepted durations and intervals use Rust duration types rather than wire nanosecond integers.
|
||||
- Tokio remains optional, and the default client build remains runtime-free. These API changes do not introduce a wire-protocol migration.
|
||||
|
||||
### 0.5.3
|
||||
|
||||
#### Added
|
||||
|
||||
- `SocketConfig` can bind UDP sockets to a network interface and set a packet mark on Linux, Android, and Fuchsia, or select a routing FIB on FreeBSD.
|
||||
|
||||
### 0.5.2
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
@@ -56,6 +105,12 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-server
|
||||
|
||||
### 0.5.3
|
||||
|
||||
#### Added
|
||||
|
||||
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
|
||||
|
||||
### 0.5.2
|
||||
|
||||
#### Changed
|
||||
@@ -95,6 +150,35 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-stats
|
||||
|
||||
### 0.5.2
|
||||
|
||||
#### Changed
|
||||
|
||||
- `TimeStats` is now re-exported from `measurement-stats`, retaining the existing `irtt_stats::TimeStats` import path, fields, and methods.
|
||||
- Timer-error statistics now omit sends without scheduling metadata instead of recording an artificial measurement.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Time-based rolling windows now expire backdated events correctly, including expired events inside retained arrival history. Older timestamps cannot move the expiry anchor backwards or restore expired history.
|
||||
- Rolling upstream and downstream loss now use the server receive-count increase over the retained packet-event interval instead of comparing window counts against a cumulative server count.
|
||||
- Rolling directional-loss estimates are unavailable when counter observations are missing, stale, discontinuous, or separated by an interior gap in the time window, rather than reporting misleading values.
|
||||
|
||||
#### Performance
|
||||
|
||||
- Indexed rolling-window expiry avoids full-window scans on insertion and reclaims expired entries without accumulating tombstones.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- This release consumes `irtt-client` 0.6 events; applications using both crates must upgrade them together.
|
||||
- Unavailable rolling directional-loss packet estimates are `None`; their percentage fields remain `0.0`. Valid signed estimates remain possible, and raw server receive counts retain their cumulative meaning.
|
||||
- Time-based rolling windows expire when new events arrive, retain events exactly at the cutoff, and have no event-count cap. Use `rolling_count` when event storage needs a fixed bound.
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
|
||||
- Added a crate-specific README, a finite-versus-continuous statistics example, and runnable API doctests for the published crate.
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Fixed
|
||||
@@ -108,6 +192,50 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-rs
|
||||
|
||||
### 0.8.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Added mutually exclusive `-4` / `--ipv4`, `-6` / `--ipv6`, and `--dual-stack` options to the client and TUI. Dual-stack hostnames expand into independent `/v4` and `/v6` targets with separate sessions, statistics, and failures; explicit IP literals remain single targets.
|
||||
- Continuous TUI runs now reconnect independently after target failures, peer closure, or server-limited session completion while healthy targets continue uninterrupted. The TUI remains running even when every target fails.
|
||||
- Added target selection and a scrollable details sheet showing negotiation, counters, timing statistics, warnings, and recent events.
|
||||
- Graph axes now display local wall-clock timestamps, including fractional seconds or dates where appropriate.
|
||||
|
||||
#### Changed
|
||||
|
||||
- Replaced separate Graph and Dashboard views with one dashboard comparing all targets. Target rows show effective RTT, cumulative loss, jitter, and the age of the last primary reply. `Tab` / `Shift-Tab` selects a target; `d` opens details, with `g` retained as an alias.
|
||||
- Reconnecting targets retain their logical row and graph history, reset session statistics for each new generation, and leave gaps between generations.
|
||||
- Increased graph retention from 100,000 to 500,000 samples per target and extended the maximum graph window from one hour to 24 hours.
|
||||
- Clearing graph history now clears all targets and returns to live view while preserving latest samples and statistics.
|
||||
- Client and TUI frontends now react asynchronously to measurements, status changes, input, and shutdown, removing periodic polling delays.
|
||||
- The application library's `run_stream`, `run_tui`, and `prepare_managed_run` entry points are now asynchronous.
|
||||
|
||||
#### Performance
|
||||
|
||||
- Graph rendering selects samples within the visible viewport instead of scanning the full retained history, with interpolation at viewport boundaries.
|
||||
- Reduced graph-history memory usage per sample substantially, allowing retention to increase from 100,000 to 500,000 samples per target without proportional memory growth.
|
||||
- Increased regular TUI refresh frequency to 100 ms for smoother updates.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- Automatic reconnect applies to continuous TUI runs, not ordinary `irtt-client` runs or finite TUI runs. Explicit stops and no-test completion do not trigger retries.
|
||||
- Dual-stack expansion changes target labels and can double probe traffic per hostname. The stdin limit of 128 desired targets applies after expansion; generated labels must remain unique.
|
||||
- Address families are discovered when a declaration is added. Each new session resolves the original hostname within its assigned family; newly appearing families are not added automatically. Unchanged stdin declarations retain their discovered families, while removal and re-addition performs fresh discovery.
|
||||
- New stdin declarations are prepared asynchronously while existing measurements continue. Preparation errors stop the stream gracefully without applying a partial target set.
|
||||
- The larger graph cap permits higher eventual memory use: approximately 42 MiB per target for retained graph samples at the cap on 64-bit macOS, excluding statistics and rendering buffers.
|
||||
- Application-library callers must await client/TUI run and preparation functions and supply the new shutdown argument to the run functions.
|
||||
- `ClientArgs::prepare` and `TuiArgs::prepare` are also asynchronous; `TargetPreparation::prepare` replaces `prepare_managed_targets`, and `parse_stdin_target_set` now returns `TargetSpec` values for preparation.
|
||||
- The application's former `TargetAuth` type was replaced by `irtt_client::managed::TargetAuth`.
|
||||
- The public output wrappers `EventRenderStats` and `IpdvPair` were removed in favor of `irtt_stats::EventStatsUpdate` and `IpdvPairUpdate`.
|
||||
- `DEFAULT_RECV_TIMEOUT`, `is_shutdown_requested`, and `TuiArgs::timestamp_mode` were removed. `install_signal_handler` is now available only with the `server` feature; client/TUI embedding uses `install_async_signal_handler`.
|
||||
- MSRV remains Rust 1.88. Existing output column names and formats are preserved.
|
||||
|
||||
### 0.7.1
|
||||
|
||||
#### Fixed
|
||||
|
||||
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
|
||||
|
||||
### 0.7.0
|
||||
|
||||
#### Added
|
||||
|
||||
Generated
+260
-113
@@ -17,6 +17,15 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -141,9 +150,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -200,10 +209,20 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
@@ -212,10 +231,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.6"
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -223,9 +253,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.6"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -235,21 +265,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.4"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
@@ -292,6 +322,12 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -313,10 +349,11 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"futures-core",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
@@ -376,9 +413,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -403,7 +440,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -414,7 +451,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -479,7 +516,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -496,9 +533,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -553,10 +590,16 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
version = "1.4.0"
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
@@ -678,13 +721,37 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -702,27 +769,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.13"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
|
||||
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-client"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"nix 0.31.3",
|
||||
"proptest",
|
||||
"socket2",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -738,47 +804,51 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-proto"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"md-5",
|
||||
"proptest",
|
||||
"subtle",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"futures-core",
|
||||
"irtt-client",
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"irtt-stats",
|
||||
"nix 0.31.3",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-server"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"irtt-proto",
|
||||
"nix 0.31.3",
|
||||
"socket2",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-stats"
|
||||
version = "0.5.0"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"irtt-client",
|
||||
"measurement-stats",
|
||||
"proptest",
|
||||
]
|
||||
|
||||
@@ -805,9 +875,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -822,7 +892,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -833,15 +903,15 @@ checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@@ -855,7 +925,7 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -887,21 +957,21 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.3"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||
checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a"
|
||||
dependencies = [
|
||||
"nix 0.29.0",
|
||||
"winapi",
|
||||
"nix 0.30.1",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -914,6 +984,12 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "measurement-stats"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edbfa45a060f44b15568637075ecebbe258c22f86ce6f2b5d58d7a64cbdb689"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -943,9 +1019,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -959,7 +1035,19 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -972,7 +1060,7 @@ version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1026,9 +1114,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
@@ -1118,9 +1206,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.9.0"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
|
||||
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
@@ -1128,9 +1216,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.9.0"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
|
||||
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -1138,9 +1226,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.9.0"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
|
||||
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -1151,9 +1239,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.9.0"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
|
||||
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
@@ -1224,9 +1312,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
@@ -1254,7 +1342,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
@@ -1367,7 +1455,7 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"compact_str",
|
||||
"critical-section",
|
||||
"hashbrown 0.17.1",
|
||||
@@ -1377,7 +1465,7 @@ dependencies = [
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
@@ -1432,7 +1520,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"hashbrown 0.17.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
@@ -1452,7 +1540,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1495,11 +1583,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -1569,7 +1657,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1583,6 +1671,12 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
@@ -1616,9 +1710,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
@@ -1628,9 +1722,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.2"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -1705,9 +1799,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1733,7 +1827,7 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
@@ -1769,7 +1863,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"fancy-regex",
|
||||
"filedescriptor",
|
||||
"finl_unicode",
|
||||
@@ -1814,11 +1908,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.20",
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1834,13 +1928,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1866,9 +1960,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
version = "1.53.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
@@ -1886,7 +1980,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1909,9 +2003,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
@@ -1944,9 +2038,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.4.3",
|
||||
@@ -1995,9 +2089,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -2008,9 +2102,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -2018,22 +2112,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -2132,12 +2226,65 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -2155,18 +2302,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+2
-2
@@ -18,9 +18,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
|
||||
|
||||
[workspace.dependencies]
|
||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
|
||||
irtt-client = { version = "0.6.0", path = "crates/irtt-client" }
|
||||
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
|
||||
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
||||
irtt-stats = { version = "0.5.2", path = "crates/irtt-stats" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
||||
|
||||
FROM --platform=$BUILDPLATFORM rust:1.98-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder
|
||||
|
||||
COPY --from=xx / /
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
description = "IRTT-compatible client, TUI, and server applications"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -42,24 +42,29 @@ doc = false
|
||||
|
||||
[features]
|
||||
default = ["client", "server", "tui"]
|
||||
client = ["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
||||
client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
||||
server = ["dep:irtt-server", "dep:tokio"]
|
||||
tui =["dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"]
|
||||
tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.6", features = ["derive", "env"] }
|
||||
crossterm = { version = "0.29.0", optional = true }
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
|
||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
||||
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
||||
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||
irtt-client = { workspace = true, optional = true }
|
||||
irtt-proto = { workspace = true, optional = true }
|
||||
irtt-server = { workspace = true, optional = true }
|
||||
irtt-stats = { workspace = true, optional = true }
|
||||
ratatui = { version = "0.30.2", optional = true }
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "time"], optional = true }
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
nix = { version = "0.31.3", features = ["term", "poll", "signal", "process"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
@@ -180,148 +180,3 @@ fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
||||
argv.extend(args.iter().cloned());
|
||||
argv
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn applet_detection_maps_binary_names() {
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("/usr/bin/irtt-client"),
|
||||
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("irttd"), None);
|
||||
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
|
||||
assert_eq!(detect_applet_from_argv0("irtt-cli"), None);
|
||||
assert_eq!(detect_applet_from_argv0("/usr/bin/irtt-cli"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applet_detection_ignores_the_platform_executable_suffix() {
|
||||
let exe = std::env::consts::EXE_SUFFIX;
|
||||
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("/opt/irtt/irtt-server{exe}")),
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("irtt-client{exe}")),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
|
||||
// The dispatcher's own name must not be taken for an unknown applet.
|
||||
let dispatcher = format!("irtt-rs{exe}");
|
||||
assert_eq!(detect_applet_from_argv0(&dispatcher), None);
|
||||
assert!(dispatch_from_argv(vec![dispatcher.into(), "--help".into()]).is_ok());
|
||||
|
||||
// A name that merely ends in the suffix letters keeps them.
|
||||
assert!(detect_applet_from_argv0("irtt-serverexe").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_executable_suffix_handling_is_assertable_from_any_host() {
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case("IRTT-RS.EXE", ".exe"), "IRTT-RS");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-serverexe", ".exe"),
|
||||
"irtt-serverexe"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case(".exe", ".exe"), ".exe");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ""),
|
||||
"irtt-server.exe"
|
||||
);
|
||||
// A multi-byte tail must not split mid-character.
|
||||
assert_eq!(strip_suffix_ignoring_case("irtt-så", ".exe"), "irtt-så");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_help_renders_dispatcher_help() {
|
||||
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
|
||||
let AppletDispatch::Help(help) = dispatch else {
|
||||
panic!("expected dispatcher help");
|
||||
};
|
||||
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
|
||||
assert!(help.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(help.contains("Run the terminal UI applet"));
|
||||
} else {
|
||||
assert!(help.contains("Terminal UI applet is not available in this build"));
|
||||
}
|
||||
assert!(help.contains("server"));
|
||||
if cfg!(feature = "server") {
|
||||
assert!(help.contains("Run the UDP server applet"));
|
||||
} else {
|
||||
assert!(help.contains("UDP server applet is not available in this build"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_without_applet_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
|
||||
assert!(err.contains("choose an applet"));
|
||||
assert!(err.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("tui"));
|
||||
}
|
||||
assert!(err.contains("server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_subcommands_dispatch_to_applet_argv() {
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
|
||||
.unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
argv: vec!["irtt-client".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Tui,
|
||||
argv: vec!["irtt-tui".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Server,
|
||||
argv: vec!["irtt-server".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_irtt_binary_name_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
|
||||
assert!(err.contains("unknown applet name 'irtt-typo'"));
|
||||
assert!(err.contains("irtt-client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("irtt-tui"));
|
||||
}
|
||||
assert!(err.contains("irtt-server"));
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ impl ClientArgs {
|
||||
///
|
||||
/// Targets are only required here. `--list-columns` returns before this is
|
||||
/// called, so listing columns still needs no target.
|
||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
if self.targets_stdin && !self.is_continuous() {
|
||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||
}
|
||||
@@ -105,6 +105,7 @@ impl ClientArgs {
|
||||
stdin_controlled: self.targets_stdin,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
@@ -151,195 +152,3 @@ pub enum HeaderMode {
|
||||
/// Never print headers.
|
||||
Never,
|
||||
}
|
||||
|
||||
#[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-client"];
|
||||
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.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
||||
assert_eq!(args.interval, Duration::from_secs(1));
|
||||
assert_eq!(args.format, OutputFormat::Table);
|
||||
assert_eq!(args.columns, None);
|
||||
assert_eq!(args.header, HeaderMode::Auto);
|
||||
assert!(!args.targets_stdin);
|
||||
assert!(!args.is_continuous());
|
||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
||||
|
||||
let config = args.prepare().unwrap().client;
|
||||
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_formats_and_rejects_tui_output() {
|
||||
for format in ["table", "csv", "tsv", "jsonl"] {
|
||||
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
|
||||
}
|
||||
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_columns_does_not_require_server() {
|
||||
let args = parse(&["--list-columns"]).unwrap();
|
||||
assert!(args.targets.is_empty());
|
||||
assert!(args.list_columns);
|
||||
// The parser accepts no target; preparation is where a target becomes
|
||||
// mandatory, and the stream command returns before preparing when
|
||||
// listing columns.
|
||||
let err = args.prepare().unwrap_err();
|
||||
assert!(err.contains("--list-columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_positional_targets_parse_as_targets() {
|
||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-b:2112");
|
||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_labeled_targets_parse() {
|
||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "ams");
|
||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
||||
assert_eq!(specs[1].label, "sg");
|
||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_labeled_and_unlabeled_targets_preserve_argument_order() {
|
||||
let args = parse(&["local.example", "eu=a.example", "backup.example"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
let labels: Vec<_> = specs.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local.example", "eu", "backup.example"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_labels_are_rejected() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
|
||||
let err = args.prepare().unwrap_err();
|
||||
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_target_endpoints_are_allowed() {
|
||||
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
||||
assert_eq!(args.prepare().unwrap().target_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_target_option_is_rejected() {
|
||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_option_accepts_supported_values() {
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Staggered
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Burst
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_help_lists_shared_protocol_options() {
|
||||
let help = ClientArgs::command().render_help().to_string();
|
||||
assert!(help.contains("--format <FORMAT>"));
|
||||
assert!(!help.contains("--target "));
|
||||
assert!(help.contains("--pacing <PACING>"));
|
||||
assert!(help.contains("--columns <COLUMNS>"));
|
||||
assert!(help.contains("--list-columns"));
|
||||
assert!(help.contains("--header <HEADER>"));
|
||||
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 an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_empty_hmac_key_is_accepted() {
|
||||
let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap();
|
||||
let config = args.prepare().unwrap().client;
|
||||
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
|
||||
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
|
||||
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert!(setup.targets.is_empty());
|
||||
assert!(setup.stdin_controlled);
|
||||
let config = setup.managed_config();
|
||||
assert_eq!(
|
||||
config.completion,
|
||||
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
|
||||
);
|
||||
assert_eq!(config.max_live_target_generations, 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_format_summary_policy_is_table_only() {
|
||||
assert!(OutputFormat::Table.prints_summary());
|
||||
assert!(!OutputFormat::Csv.prints_summary());
|
||||
assert!(!OutputFormat::Tsv.prints_summary());
|
||||
assert!(!OutputFormat::Jsonl.prints_summary());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -121,257 +121,3 @@ fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
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)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_summary_omits_optional_metric_sections() {
|
||||
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
|
||||
let output = format_summary(&summary);
|
||||
|
||||
assert!(output.contains("Metric"));
|
||||
assert!(output.contains("Min"));
|
||||
assert!(output.contains("Mean"));
|
||||
assert!(output.contains("Median"));
|
||||
assert!(output.contains("Max"));
|
||||
assert!(output.contains("Stddev"));
|
||||
assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
|
||||
assert!(!output.contains("RTT 0"));
|
||||
assert!(!output.contains("server processing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_formats_counts_and_available_metrics() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
let sent_at = test_timestamp(Duration::from_secs(1));
|
||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
||||
|
||||
collector.process(&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::from_micros(2),
|
||||
});
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
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: None,
|
||||
receive_mono_ns: None,
|
||||
send_wall_ns: None,
|
||||
send_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
midpoint_mono_ns: None,
|
||||
processing: Some(Duration::from_micros(300)),
|
||||
}),
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
|
||||
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"));
|
||||
assert!(output.contains("RTT"));
|
||||
assert!(output.contains("1.2ms"));
|
||||
assert!(!output.contains("raw RTT"));
|
||||
assert!(!output.contains("adjusted RTT"));
|
||||
assert!(output.contains("server processing"));
|
||||
assert!(output.contains("300.0µs"));
|
||||
assert!(output.contains("send call"));
|
||||
assert!(output.contains("10.0µs"));
|
||||
assert!(output.contains("timer error"));
|
||||
assert!(output.contains("2.0µs"));
|
||||
assert!(!output.contains("medians unavailable"));
|
||||
}
|
||||
|
||||
/// Returns the Median cell of the row with this label.
|
||||
fn median_cell(output: &str, label: &str) -> String {
|
||||
let row = output
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(label))
|
||||
.unwrap_or_else(|| panic!("summary should contain a {label:?} row:\n{output}"));
|
||||
// Metric, Count, Min, Mean, Median, ...
|
||||
// Counting from the right keeps multi-word labels out of the way.
|
||||
row.split_whitespace()
|
||||
.rev()
|
||||
.nth(2)
|
||||
.unwrap_or_else(|| panic!("row {row:?} should have a Median cell"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Collects probes whose send-call, timer-error, and server-processing
|
||||
/// values each have a median distinct from their min, mean, and max, so
|
||||
/// the rendered cell can only come from a real median calculation.
|
||||
fn spread_finite_summary(config: StatsConfig) -> Snapshot {
|
||||
let mut collector = StatsCollector::new(config);
|
||||
for (seq, (send_call_us, timer_error_us, processing_us)) in
|
||||
[(10_u64, 2_u64, 100_u64), (40, 12, 700), (20, 4, 300)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let seq = seq as u32;
|
||||
let offset = Duration::from_secs(1) + Duration::from_millis(u64::from(seq));
|
||||
let sent_at = test_timestamp(offset);
|
||||
let received_at = test_timestamp(offset + Duration::from_micros(1500));
|
||||
|
||||
collector.process(&ClientEvent::EchoSent {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
scheduled_at: sent_at.mono,
|
||||
sent_at,
|
||||
bytes: 64,
|
||||
send_call: Duration::from_micros(send_call_us),
|
||||
timer_error: Duration::from_micros(timer_error_us),
|
||||
});
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_nanos(1_500_000),
|
||||
},
|
||||
server_timing: Some(ServerTiming {
|
||||
receive_wall_ns: None,
|
||||
receive_mono_ns: None,
|
||||
send_wall_ns: None,
|
||||
send_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
midpoint_mono_ns: None,
|
||||
processing: Some(Duration::from_micros(processing_us)),
|
||||
}),
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
}
|
||||
collector.snapshot()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_summary_shows_medians_for_send_call_timer_error_and_processing() {
|
||||
let output = format_summary(&spread_finite_summary(StatsConfig::finite()));
|
||||
|
||||
// Medians of 10/20/40, 2/4/12, and 100/300/700 µs. None of these
|
||||
// equals the row's min, mean, or max.
|
||||
assert_eq!(median_cell(&output, "send call"), "20.0µs");
|
||||
assert_eq!(median_cell(&output, "timer error"), "4.0µs");
|
||||
assert_eq!(median_cell(&output, "server processing"), "300.0µs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_summary_still_shows_no_medians_for_those_rows() {
|
||||
let output = format_summary(&spread_finite_summary(StatsConfig::continuous()));
|
||||
|
||||
for label in ["send call", "timer error", "server processing"] {
|
||||
assert_eq!(
|
||||
median_cell(&output, label),
|
||||
"-",
|
||||
"continuous mode retains no exact samples for {label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
sent_at: test_timestamp(Duration::from_secs(1)),
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
|
||||
let output = format_summary_with_options(
|
||||
&collector.snapshot(),
|
||||
SummaryFormatOptions {
|
||||
verbose: true,
|
||||
..SummaryFormatOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(output.contains("raw RTT"));
|
||||
assert!(output.contains("adjusted RTT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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_time_row(&mut out, "RTT", &stats);
|
||||
assert!(out.contains("-500ns"));
|
||||
}
|
||||
}
|
||||
@@ -115,93 +115,3 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
||||
fn absent() -> String {
|
||||
ABSENT.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
|
||||
assert_eq!(format_ns_f64(0.0), "0ns");
|
||||
assert_eq!(format_ns_f64(999.0), "999ns");
|
||||
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
|
||||
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
|
||||
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
|
||||
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
|
||||
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
|
||||
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_nanosecond_scalars_keep_their_sign() {
|
||||
assert_eq!(format_ns_f64(-500.0), "-500ns");
|
||||
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
|
||||
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
|
||||
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
|
||||
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_and_floating_nanosecond_scalars_agree() {
|
||||
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
|
||||
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn microseconds_use_the_micro_sign() {
|
||||
assert!(format_ns_f64(1_500.0).ends_with("µs"));
|
||||
assert!(!format_ns_f64(1_500.0).contains("us"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
||||
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn signed_durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(
|
||||
format_signed_duration(SignedDuration::from_nanos(-1_500)),
|
||||
"-1.5µs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_i128(None), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
|
||||
assert_eq!(format_percent(f64::NAN), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn absent_optional_ns_f64_renders_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_f64(None), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn absent_counts_and_ratios_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_count(None), ABSENT);
|
||||
assert_eq!(format_percent_ratio(1, 0), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentages_have_one_spelling() {
|
||||
assert_eq!(format_percent(0.0), "0.00%");
|
||||
assert_eq!(format_percent(12.345), "12.35%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn percent_ratios_and_counts_have_one_spelling() {
|
||||
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
||||
assert_eq!(format_percent_ratio(5, 4), "100.00%");
|
||||
assert_eq!(format_count(42), "42");
|
||||
assert_eq!(format_optional_count(Some(0)), "0");
|
||||
}
|
||||
}
|
||||
@@ -257,394 +257,3 @@ fn parse_duration_units(input: &str) -> Result<Duration, String> {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
||||
with_env_lock(|| {
|
||||
let mut argv = vec!["irtt-server"];
|
||||
argv.extend_from_slice(args);
|
||||
ServerArgs::try_parse_from(argv)
|
||||
})
|
||||
}
|
||||
|
||||
fn bound(args: &[&str]) -> ServerArgs {
|
||||
let mut argv = vec!["--bind", "127.0.0.1:2112"];
|
||||
argv.extend_from_slice(args);
|
||||
parse(&argv).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_optional_and_parses_both_families() {
|
||||
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
|
||||
|
||||
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(ipv6.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||
|
||||
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
|
||||
assert!(parse(&["--bind", "localhost:2112"]).is_err());
|
||||
// Still one address per option: comma-separated syntax is not a thing
|
||||
// here, and accepting it silently would be worse than rejecting it.
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"the empty list is the parsed representation of \"no explicit bind\""
|
||||
);
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[
|
||||
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
|
||||
],
|
||||
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
|
||||
"one explicit bind means exactly that one listener, not that listener plus defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.resolve_binds(), args.bind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_repeatable_and_keeps_the_requested_order() {
|
||||
let args = parse(&[
|
||||
"--bind",
|
||||
"[::1]:2112",
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--bind",
|
||||
"127.0.0.1:2113",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args.bind,
|
||||
[
|
||||
SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2113)),
|
||||
],
|
||||
"listeners are served in the order they were requested"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_configuration_is_one_policy_however_many_listeners_there_are() {
|
||||
// Addresses never enter the server configuration: every listener is
|
||||
// built from a clone of this one value.
|
||||
let one = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
let several = parse(&[
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--bind",
|
||||
"[::1]:2112",
|
||||
"--max-sessions",
|
||||
"4",
|
||||
])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
|
||||
assert_eq!(one, several);
|
||||
assert_eq!(several.max_sessions(), 4, "and it is a per-listener bound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_policy_options_keep_the_library_defaults() {
|
||||
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_options_map_onto_the_server_config() {
|
||||
let config = bound(&[
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--max-sessions",
|
||||
"512",
|
||||
"--max-packet-length",
|
||||
"1472",
|
||||
"--min-interval",
|
||||
"20ms",
|
||||
"--burst",
|
||||
"3",
|
||||
"--idle-timeout",
|
||||
"30s",
|
||||
"--max-duration",
|
||||
"2m",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_restrictions_map_onto_the_server_config() {
|
||||
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
|
||||
|
||||
assert_eq!(
|
||||
config.timestamp_allowance(),
|
||||
TimestampAllowance::Single,
|
||||
"a request for both timestamps is negotiated to midpoint"
|
||||
);
|
||||
assert!(!config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_timestamp_allowance_mode_parses() {
|
||||
for (argument, expected) in [
|
||||
("dual", TimestampAllowance::Dual),
|
||||
("single", TimestampAllowance::Single),
|
||||
("none", TimestampAllowance::None),
|
||||
] {
|
||||
assert_eq!(
|
||||
bound(&["--timestamp-allowance", argument])
|
||||
.server_config()
|
||||
.timestamp_allowance(),
|
||||
expected,
|
||||
"--timestamp-allowance {argument}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(parse(&[
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--timestamp-allowance",
|
||||
"midpoint"
|
||||
])
|
||||
.is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
|
||||
// Explicitly, and not only through the whole-config comparison above:
|
||||
// these are the two values that would silently change every existing
|
||||
// deployment's negotiation if the CLI restated them.
|
||||
let config = bound(&["--idle-timeout", "30s"]).server_config();
|
||||
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
|
||||
assert!(config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_zero_policy_values_are_preserved() {
|
||||
let config = bound(&[
|
||||
"--min-interval",
|
||||
"0",
|
||||
"--idle-timeout",
|
||||
"0ms",
|
||||
"--burst",
|
||||
"0",
|
||||
"--max-sessions",
|
||||
"0",
|
||||
"--max-packet-length",
|
||||
"0",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.min_send_interval(), Duration::ZERO);
|
||||
assert_eq!(config.idle_timeout(), Duration::ZERO);
|
||||
assert_eq!(config.burst_allowance(), 0);
|
||||
assert_eq!(config.max_sessions(), 0);
|
||||
assert_eq!(config.max_packet_length(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_require_a_supported_unit() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whitespace_only_hmac_key_is_accepted_literally() {
|
||||
let config = bound(&["--hmac", " "]).server_config();
|
||||
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
||||
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
||||
}
|
||||
|
||||
/// Runs `body` with the given environment variables set, restoring
|
||||
/// whatever was there before (present or absent) once `body` returns.
|
||||
/// Held under `with_env_lock` for the whole span, including `body`, so a
|
||||
/// `parse` call elsewhere never observes a partially-applied environment.
|
||||
fn with_env<T>(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
|
||||
with_env_lock(|| {
|
||||
let previous: Vec<(&str, Option<String>)> = vars
|
||||
.iter()
|
||||
.map(|(k, _)| (*k, std::env::var(k).ok()))
|
||||
.collect();
|
||||
for (k, v) in vars {
|
||||
std::env::set_var(k, v);
|
||||
}
|
||||
let result = body();
|
||||
for (k, v) in previous {
|
||||
match v {
|
||||
Some(v) => std::env::set_var(k, v),
|
||||
None => std::env::remove_var(k),
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_variables_supply_policy_when_no_flag_is_given() {
|
||||
with_env(
|
||||
&[
|
||||
("IRTT_SERVER_MAX_SESSIONS", "512"),
|
||||
("IRTT_SERVER_MAX_PACKET_LENGTH", "1472"),
|
||||
("IRTT_SERVER_MIN_INTERVAL", "20ms"),
|
||||
("IRTT_SERVER_BURST", "3"),
|
||||
("IRTT_SERVER_IDLE_TIMEOUT", "30s"),
|
||||
("IRTT_SERVER_MAX_DURATION", "2m"),
|
||||
("IRTT_SERVER_TIMESTAMP_ALLOWANCE", "single"),
|
||||
("IRTT_SERVER_HMAC", "secret"),
|
||||
("IRTT_SERVER_NO_DSCP", "true"),
|
||||
],
|
||||
|| {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert!(!config.dscp_allowed());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// `IRTT_SERVER_NO_DSCP` is the one policy variable whose value is a
|
||||
/// boolean rather than a number, a duration or a string, and the
|
||||
/// container/orchestrator deployments this whole environment surface
|
||||
/// exists for spell booleans `1`/`0`. clap's default parser for a flag
|
||||
/// accepts only the literals `true`/`false` and turns everything else
|
||||
/// into a *parse failure*, so `IRTT_SERVER_NO_DSCP=1` used to stop the
|
||||
/// server from starting at all rather than enabling the flag. Falsey
|
||||
/// semantics are what keeps that from being a startup failure.
|
||||
#[test]
|
||||
fn the_no_dscp_environment_variable_accepts_ordinary_boolean_spellings() {
|
||||
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
|
||||
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.expect("a truthy value must not fail argument parsing")
|
||||
.server_config();
|
||||
assert!(!config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||
});
|
||||
}
|
||||
|
||||
for value in ["0", "false", "no", "off", ""] {
|
||||
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.expect("a falsey value must not fail argument parsing")
|
||||
.server_config();
|
||||
assert!(config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The command-line flag stays a flag: it takes no value, whatever the
|
||||
/// environment parser accepts.
|
||||
#[test]
|
||||
fn the_no_dscp_flag_still_takes_no_command_line_value() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--no-dscp=false"]).is_err());
|
||||
assert!(!bound(&["--no-dscp"]).server_config().dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_flag_overrides_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(
|
||||
config.max_sessions(),
|
||||
4,
|
||||
"the command-line value wins over the environment"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_wins_over_the_built_in_default() {
|
||||
with_env(&[("IRTT_SERVER_BURST", "9")], || {
|
||||
let config = bound(&[]).server_config();
|
||||
assert_eq!(
|
||||
config.burst_allowance(),
|
||||
9,
|
||||
"an unset flag still falls back to the environment before the library default"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irtt_server_bind_supplies_a_single_address_from_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2113))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_flag_overrides_irtt_server_bind() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_irtt_server_bind_unset_keeps_the_wildcard_default() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"with no --bind and no IRTT_SERVER_BIND, resolve_binds still falls back to the wildcard pair"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! Serializes every test in this module tree that touches process
|
||||
//! environment variables.
|
||||
//!
|
||||
//! `ServerArgs` parsing reads `IRTT_SERVER_*` via `clap`'s `env` support, and
|
||||
//! `std::env::set_var`/`remove_var` mutate genuinely process-wide state.
|
||||
//! Every test that parses `ServerArgs` — whether or not it sets an env var
|
||||
//! itself — must go through [`with_env_lock`], or a parse in one test thread
|
||||
//! can observe a value another thread's test set only for the duration of
|
||||
//! its own assertions. Reentrant per-thread, since a test that sets env vars
|
||||
//! typically calls `parse` again from inside its own locked section.
|
||||
|
||||
use std::{cell::Cell, sync::Mutex};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
thread_local! {
|
||||
static ENV_LOCK_HELD: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(crate) fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
if ENV_LOCK_HELD.with(Cell::get) {
|
||||
return f();
|
||||
}
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
ENV_LOCK_HELD.with(|held| held.set(true));
|
||||
let result = f();
|
||||
ENV_LOCK_HELD.with(|held| held.set(false));
|
||||
result
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
pub mod args;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod env_lock;
|
||||
pub mod run;
|
||||
|
||||
pub use args::ServerArgs;
|
||||
|
||||
@@ -105,105 +105,3 @@ async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
/// The ordinary invocation, and the one that keeps the single-listener case
|
||||
/// on the same orchestration path as every other.
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from([
|
||||
"irtt-server",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
fn default_binds() -> Vec<SocketAddr> {
|
||||
with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server"])
|
||||
.unwrap()
|
||||
.resolve_binds()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
|
||||
let binds = default_binds();
|
||||
let failed = binds[0];
|
||||
|
||||
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
|
||||
|
||||
assert_eq!(remaining, [binds[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supported_familys_failure_never_falls_back() {
|
||||
// The family itself works fine here; whatever made this specific
|
||||
// bind fail (port in use, permission, ...) is a real problem this
|
||||
// invocation should surface, not paper over.
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, true, &binds, binds[0]),
|
||||
None,
|
||||
"family_available: true means this is not a family-support problem"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_binds_never_fall_back() {
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(false, false, &binds, binds[0]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_outside_the_requested_set_does_not_fall_back() {
|
||||
let binds = default_binds();
|
||||
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &binds, unrelated),
|
||||
None,
|
||||
"the failing address must actually be one this invocation asked for"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_the_only_remaining_default_address_does_not_fall_back() {
|
||||
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &single, single[0]),
|
||||
None,
|
||||
"no address would be left to retry with"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::shared::client::TimestampArg;
|
||||
use crate::shared::client::{
|
||||
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
||||
ManagedRunSetup, TargetArg, TargetSelection,
|
||||
@@ -51,7 +49,7 @@ impl TuiArgs {
|
||||
///
|
||||
/// The TUI always requires a target: the parser rejects an empty target
|
||||
/// list, and this rejects a target set that cannot be labelled uniquely.
|
||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
@@ -61,16 +59,12 @@ impl TuiArgs {
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -80,208 +74,3 @@ impl std::ops::Deref for TuiArgs {
|
||||
&self.common
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::{CommandFactory, Parser};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct SharedOnlyArgs {
|
||||
#[command(flatten)]
|
||||
common: CommonClientArgs,
|
||||
}
|
||||
|
||||
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_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
|
||||
let mut argv = vec!["shared-only"];
|
||||
argv.extend_from_slice(args);
|
||||
SharedOnlyArgs::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.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||
assert!(args.is_continuous());
|
||||
assert_eq!(args.prepare().unwrap().client.duration, None);
|
||||
|
||||
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(finite.duration, Duration::from_secs(30));
|
||||
assert_eq!(
|
||||
finite.prepare().unwrap().client.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 multiple_positional_targets_parse() {
|
||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-b:2112");
|
||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_labeled_targets_parse() {
|
||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "ams");
|
||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
||||
assert_eq!(specs[1].label, "sg");
|
||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_least_one_target_is_required() {
|
||||
// The TUI has no target-free mode, so this is rejected at parse time
|
||||
// rather than during preparation.
|
||||
assert!(parse(&[]).is_err());
|
||||
assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_labels_are_rejected() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
|
||||
let err = args.prepare().unwrap_err();
|
||||
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_target_endpoints_are_allowed() {
|
||||
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
||||
assert_eq!(args.prepare().unwrap().target_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_target_option_is_rejected() {
|
||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_option_accepts_supported_values() {
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Staggered
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Burst
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_options_match_tui_config_mapping() {
|
||||
let shared = [
|
||||
"--interval",
|
||||
"250ms",
|
||||
"--length",
|
||||
"128",
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--clock",
|
||||
"monotonic",
|
||||
"--tstamp",
|
||||
"receive",
|
||||
"--stats",
|
||||
"count",
|
||||
"--sfill",
|
||||
"abc",
|
||||
"--dscp",
|
||||
"46",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--loose",
|
||||
];
|
||||
let tui_args = [
|
||||
"--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 tui = parse(&tui_args).unwrap().prepare().unwrap().client;
|
||||
let common = parse_shared(&shared).unwrap().common;
|
||||
let shared = common.to_client_config(Duration::from_secs(30));
|
||||
|
||||
assert_eq!(shared.duration, tui.duration);
|
||||
assert_eq!(shared.interval, tui.interval);
|
||||
assert_eq!(shared.length, tui.length);
|
||||
assert_eq!(shared.received_stats, tui.received_stats);
|
||||
assert_eq!(shared.stamp_at, tui.stamp_at);
|
||||
assert_eq!(shared.clock, tui.clock);
|
||||
assert_eq!(shared.dscp, tui.dscp);
|
||||
assert_eq!(shared.hmac_key, tui.hmac_key);
|
||||
assert_eq!(shared.server_fill, tui.server_fill);
|
||||
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
|
||||
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected_via_the_shared_args() {
|
||||
// CommonClientArgs is flattened into both the client and TUI parsers,
|
||||
// so proving rejection once here covers both without duplicating it.
|
||||
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_help_lists_multi_target_options() {
|
||||
let help = TuiArgs::command().render_help().to_string();
|
||||
assert!(!help.contains("--target "));
|
||||
assert!(help.contains("--pacing <PACING>"));
|
||||
}
|
||||
}
|
||||
+216
-241
@@ -1,303 +1,278 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{future::poll_fn, io, time::Duration};
|
||||
|
||||
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||
use super::ui::{TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||
use crate::{
|
||||
cmd::tui::args::TuiArgs,
|
||||
shared::client::{
|
||||
is_shutdown_requested,
|
||||
session::{
|
||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||
request_managed_stop_once, ManagedDrainState,
|
||||
},
|
||||
session::{drain_final_events, request_managed_stop_once},
|
||||
worker::ManagedWorker,
|
||||
},
|
||||
};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use irtt_client::managed::{
|
||||
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
|
||||
ManagedEventTryRecvError, TargetInstance,
|
||||
use crossterm::{
|
||||
event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers},
|
||||
terminal,
|
||||
};
|
||||
use futures_core::Stream;
|
||||
use irtt_client::{
|
||||
managed::{
|
||||
ManagedClient, ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy,
|
||||
ManagedEndReason, ManagedEvent, ManagedTargetEndReason, ManagedTargetLifecycle,
|
||||
},
|
||||
ClientEvent,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::{
|
||||
sync::{broadcast::error::RecvError, watch},
|
||||
time::{sleep, Instant},
|
||||
};
|
||||
|
||||
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||
const INPUT_EVENT_WORK_BUDGET: usize = 128;
|
||||
const RETRY_DELAY: Duration = Duration::from_millis(1500);
|
||||
|
||||
pub fn run_tui(
|
||||
const RENDER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
pub async fn run_tui(
|
||||
args: TuiArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let setup = args
|
||||
.prepare()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let setup = tokio::select! {
|
||||
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||
};
|
||||
let continuous = args.is_continuous();
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut terminal = TuiTerminal::enter()?;
|
||||
let mut state = TuiState::with_target_labels(
|
||||
TuiConfig::from_args(&args, &setup),
|
||||
setup.targets.iter().map(|target| target.label.clone()),
|
||||
);
|
||||
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, &mut next_render, true)?;
|
||||
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(
|
||||
setup.managed_config(),
|
||||
setup.managed_targets(),
|
||||
) {
|
||||
terminal.draw(&state)?;
|
||||
let desired_targets = setup.managed_targets();
|
||||
let mut config = setup.managed_config();
|
||||
if continuous {
|
||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
}
|
||||
let (task, handle) = match ManagedClient::task(config, desired_targets.clone()) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
state.set_error(error.to_string());
|
||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||
terminal.draw(&state)?;
|
||||
return Err(Box::new(error));
|
||||
}
|
||||
};
|
||||
let handle = owner.handle();
|
||||
// Both subscriptions exist before the measurement task can be polled.
|
||||
let mut events = handle.subscribe()?;
|
||||
let mut status = handle.subscribe_status();
|
||||
let worker = match ManagedWorker::start(task, handle.clone()) {
|
||||
Ok(worker) => worker,
|
||||
Err(error) => {
|
||||
state.set_run_error(error.to_string());
|
||||
terminal.draw(&state)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
let completion = worker.join();
|
||||
tokio::pin!(completion);
|
||||
// Input and rendering stay on the frontend executor. EventStream's drop
|
||||
// wakes its reader; no terminal task can outlive the terminal guard.
|
||||
let mut input = EventStream::new();
|
||||
let render = sleep(RENDER_INTERVAL);
|
||||
tokio::pin!(render);
|
||||
let mut interrupted = false;
|
||||
let mut stop_requested = false;
|
||||
let mut terminal_targets = HashSet::new();
|
||||
let mut dropped_events = 0;
|
||||
let mut subscription_closed = false;
|
||||
loop {
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
interrupted = true;
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
let retry = sleep(RETRY_DELAY);
|
||||
tokio::pin!(retry);
|
||||
let mut retry_armed = false;
|
||||
let mut update_receipt = None;
|
||||
let mut dropped_events = 0_u64;
|
||||
let mut events_closed = false;
|
||||
let mut status_closed = false;
|
||||
let outcome = loop {
|
||||
let mut force_render = false;
|
||||
// Default select fairness lets input, status and rendering progress
|
||||
// even when measurement events are continuously ready.
|
||||
tokio::select! {
|
||||
outcome = &mut completion => break outcome,
|
||||
_ = shutdown.changed(), if !interrupted => {
|
||||
interrupted = true;
|
||||
state.set_status(TuiStatus::Interrupted);
|
||||
force_render = true;
|
||||
}
|
||||
}
|
||||
if handle_input(&mut state, shutdown_requested)? {
|
||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||
}
|
||||
if state.quit_requested {
|
||||
interrupted = true;
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
changed = status.changed(), if !status_closed => {
|
||||
status_closed = changed.is_err();
|
||||
state.process_managed_status(&status.borrow_and_update());
|
||||
force_render = true;
|
||||
}
|
||||
event = events.recv(), if !events_closed => match event {
|
||||
Ok(event) => force_render = process_tui_event(
|
||||
event, &mut state,
|
||||
),
|
||||
Err(RecvError::Lagged(count)) => {
|
||||
dropped_events = dropped_events.saturating_add(count);
|
||||
state.mark_dropped_managed_events(dropped_events);
|
||||
force_render = true;
|
||||
}
|
||||
Err(RecvError::Closed) => events_closed = true,
|
||||
},
|
||||
event = poll_fn(|cx| std::pin::Pin::new(&mut input).poll_next(cx)) => {
|
||||
let event = event.ok_or_else(|| io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof, "terminal input closed",
|
||||
))??;
|
||||
match event {
|
||||
Event::Resize(_, _) => force_render = true,
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Char('c')
|
||||
if key.code == KeyCode::Char('q')
|
||||
|| key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
interrupted = true;
|
||||
state.set_status(TuiStatus::Interrupted);
|
||||
force_render = true;
|
||||
}
|
||||
_ => {
|
||||
let (width, height) = terminal::size()?;
|
||||
force_render = state.handle_key(
|
||||
key.code, Rect::new(0, 0, width, height),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = &mut retry, if retry_armed && !interrupted && !*shutdown.borrow() => {
|
||||
state.process_managed_status(&status.borrow());
|
||||
retry_armed = false;
|
||||
update_receipt = match handle.update_targets(desired_targets.clone()) {
|
||||
Ok(receipt) => Some(receipt),
|
||||
Err(ManagedCommandError::Stopping | ManagedCommandError::DriverClosed)
|
||||
if stop_requested || *shutdown.borrow() => None,
|
||||
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
|
||||
};
|
||||
}
|
||||
result = async { update_receipt.as_mut().unwrap().await },
|
||||
if update_receipt.is_some() => {
|
||||
update_receipt = None;
|
||||
match result {
|
||||
Ok(ack) => {
|
||||
state.process_managed_status(&ack.status);
|
||||
force_render = true;
|
||||
}
|
||||
Err(ManagedCommandApplyError::Stopping
|
||||
| ManagedCommandApplyError::AcknowledgementDisconnected)
|
||||
if stop_requested || *shutdown.borrow() => {}
|
||||
Err(error) => return Err(format!("reconnect update failed: {error}").into()),
|
||||
}
|
||||
}
|
||||
_ = &mut render, if !state.paused => force_render = true,
|
||||
}
|
||||
if request_managed_stop_for_peer_close(
|
||||
continuous,
|
||||
interrupted,
|
||||
handle.status().peer_closed_target_outcomes,
|
||||
&mut stop_requested,
|
||||
) {
|
||||
if interrupted && request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
let drain_state = drain_tui_events(
|
||||
&mut events,
|
||||
&mut state,
|
||||
&mut terminal_targets,
|
||||
&mut dropped_events,
|
||||
);
|
||||
match drain_state {
|
||||
ManagedDrainState::Empty => {
|
||||
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
|
||||
}
|
||||
ManagedDrainState::Closed => subscription_closed = true,
|
||||
ManagedDrainState::BudgetExhausted => {}
|
||||
if continuous
|
||||
&& !interrupted
|
||||
&& !stop_requested
|
||||
&& !retry_armed
|
||||
&& update_receipt.is_none()
|
||||
&& status.borrow().targets.iter().any(|target| {
|
||||
target.desired
|
||||
&& target.outcome.as_ref().is_some_and(|outcome| {
|
||||
matches!(
|
||||
outcome.end_reason,
|
||||
ManagedTargetEndReason::Failed(_)
|
||||
| ManagedTargetEndReason::PeerClosed
|
||||
| ManagedTargetEndReason::TestComplete
|
||||
)
|
||||
})
|
||||
})
|
||||
{
|
||||
retry.as_mut().reset(Instant::now() + RETRY_DELAY);
|
||||
retry_armed = true;
|
||||
}
|
||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
||||
break;
|
||||
if force_render {
|
||||
terminal.draw(&state)?;
|
||||
// Schedule from the completed draw, never catch up missed frames.
|
||||
render.as_mut().reset(Instant::now() + RENDER_INTERVAL);
|
||||
}
|
||||
render_if_due(&mut terminal, &state, &mut next_render, false)?;
|
||||
}
|
||||
if interrupted {
|
||||
state.set_status(TuiStatus::Interrupted);
|
||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||
}
|
||||
};
|
||||
state.set_status(TuiStatus::Closing);
|
||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||
let outcome = owner.join()?;
|
||||
drain_final_tui_events(
|
||||
&mut events,
|
||||
&mut state,
|
||||
&mut terminal_targets,
|
||||
&mut dropped_events,
|
||||
);
|
||||
terminal.draw(&state)?;
|
||||
let outcome = match outcome {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
state.set_run_error(error.to_string());
|
||||
terminal.draw(&state)?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||
process_tui_event(event, &mut state);
|
||||
Ok::<(), std::convert::Infallible>(())
|
||||
})?;
|
||||
state.mark_dropped_managed_events(dropped_events);
|
||||
if outcome.discarded_target_outcomes != 0 {
|
||||
if !continuous && outcome.discarded_target_outcomes != 0 {
|
||||
state.set_run_error(format!(
|
||||
"{} final target outcomes were discarded",
|
||||
outcome.discarded_target_outcomes
|
||||
));
|
||||
}
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
if terminal_targets.insert(target.target.clone()) {
|
||||
state.process_target_outcome(target);
|
||||
}
|
||||
state.process_target_outcome(target);
|
||||
}
|
||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||
interrupted |= *shutdown.borrow();
|
||||
let error = match &outcome.end_reason {
|
||||
ManagedEndReason::DriverFailed(failure) => {
|
||||
Some(format!("managed driver failed: {failure}"))
|
||||
}
|
||||
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
||||
.or_else(|| {
|
||||
(!interrupted
|
||||
&& outcome.successful_target_outcomes == 0
|
||||
&& outcome.failed_target_outcomes > 0)
|
||||
.then(|| {
|
||||
format!(
|
||||
"no managed target completed successfully ({} failed)",
|
||||
outcome.failed_target_outcomes
|
||||
)
|
||||
})
|
||||
_ => (!interrupted
|
||||
&& outcome.successful_target_outcomes == 0
|
||||
&& outcome.failed_target_outcomes > 0)
|
||||
.then(|| {
|
||||
format!(
|
||||
"no managed target completed successfully ({} failed)",
|
||||
outcome.failed_target_outcomes
|
||||
)
|
||||
}),
|
||||
};
|
||||
if let Some(error) = error {
|
||||
state.set_run_error(error.clone());
|
||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||
terminal.draw(&state)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
state.set_status(TuiStatus::Complete);
|
||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||
terminal.draw(&state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_tui_event(
|
||||
event: ManagedEvent,
|
||||
state: &mut TuiState,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
) {
|
||||
fn process_tui_event(event: ManagedEvent, state: &mut TuiState) -> bool {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => state.process_target_event(&target, &event),
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let force_render = matches!(
|
||||
event,
|
||||
ClientEvent::SessionStarted(_)
|
||||
| ClientEvent::NoTestCompleted(_)
|
||||
| ClientEvent::SessionClosed { .. }
|
||||
| ClientEvent::Warning { .. }
|
||||
);
|
||||
state.process_target_event(&target, &event);
|
||||
force_render
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome } => {
|
||||
terminal_targets.insert(outcome.target.clone());
|
||||
state.process_target_outcome(&outcome);
|
||||
true
|
||||
}
|
||||
_ => {}
|
||||
ManagedEvent::TargetStateChanged {
|
||||
target,
|
||||
lifecycle:
|
||||
ManagedTargetLifecycle::Pending
|
||||
| ManagedTargetLifecycle::Connecting
|
||||
| ManagedTargetLifecycle::Opening,
|
||||
} => {
|
||||
state.process_target_opening(&target);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn drain_tui_events(
|
||||
events: &mut ManagedEventSubscription,
|
||||
state: &mut TuiState,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
dropped_events: &mut u64,
|
||||
) -> ManagedDrainState {
|
||||
drain_managed_events(events, dropped_events, |event| {
|
||||
process_tui_event(event, state, terminal_targets);
|
||||
Ok::<(), std::convert::Infallible>(())
|
||||
})
|
||||
.expect("processing TUI managed events is infallible")
|
||||
}
|
||||
|
||||
fn drain_final_tui_events(
|
||||
events: &mut ManagedEventSubscription,
|
||||
state: &mut TuiState,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
dropped_events: &mut u64,
|
||||
) {
|
||||
loop {
|
||||
match events.try_recv() {
|
||||
Ok(event) => process_tui_event(event, state, terminal_targets),
|
||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||
*dropped_events = dropped_events.saturating_add(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
|
||||
let mut force_render = false;
|
||||
for _ in 0..INPUT_EVENT_WORK_BUDGET {
|
||||
if !event::poll(Duration::ZERO)? {
|
||||
break;
|
||||
}
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Char('c')
|
||||
if key.code == KeyCode::Char('q')
|
||||
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
|
||||
{
|
||||
state.quit_requested = true;
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
force_render = true;
|
||||
break;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
state.clear_visible_history();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
state.toggle_pause();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
state.toggle_view();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
state.cycle_graph_metric();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
state.pan_graph_left();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
state.pan_graph_right();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
state.pan_graph_page_left();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
state.pan_graph_page_right();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Home => {
|
||||
state.jump_graph_oldest();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::End => {
|
||||
state.jump_graph_live();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
state.zoom_graph_in();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('-') => {
|
||||
state.zoom_graph_out();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('0') => {
|
||||
state.reset_graph_window();
|
||||
force_render = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(force_render)
|
||||
}
|
||||
fn render_if_due(
|
||||
terminal: &mut TuiTerminal,
|
||||
state: &TuiState,
|
||||
next_render: &mut Instant,
|
||||
force: bool,
|
||||
) -> io::Result<()> {
|
||||
let now = Instant::now();
|
||||
if should_render(now, *next_render, state.paused, force) {
|
||||
terminal.draw(state)?;
|
||||
*next_render = now + RENDER_INTERVAL;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
|
||||
let render_wait = if paused {
|
||||
TUI_WAIT_SLICE
|
||||
} else {
|
||||
next_render.saturating_duration_since(Instant::now())
|
||||
};
|
||||
render_wait.min(TUI_WAIT_SLICE)
|
||||
}
|
||||
+1205
-2288
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,9 @@
|
||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use std::{env, ffi::OsString, process::ExitCode};
|
||||
|
||||
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
|
||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||
#[cfg(feature = "server")]
|
||||
use crate::signal::install_signal_handler;
|
||||
|
||||
/// Entry point for the `irtt-rs` multicall dispatcher binary.
|
||||
@@ -75,9 +75,9 @@ fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the shutdown signal handler shared by every applet and hands the
|
||||
/// Installs the shutdown signal handler for synchronous applets and hands the
|
||||
/// resulting flag to `f`.
|
||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
||||
#[cfg(feature = "server")]
|
||||
fn with_shutdown_flag(
|
||||
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -91,10 +91,13 @@ fn with_shutdown_flag(
|
||||
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
with_shutdown_flag(|shutdown_requested| {
|
||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||
crate::cmd::client::run_stream(args, shutdown_requested)
|
||||
})
|
||||
let shutdown = crate::signal::install_async_signal_handler()
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(crate::cmd::client::run_stream(args, shutdown))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "client"))]
|
||||
@@ -121,10 +124,13 @@ fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
|
||||
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
with_shutdown_flag(|shutdown_requested| {
|
||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||
crate::cmd::tui::run_tui(args, shutdown_requested)
|
||||
})
|
||||
let shutdown = crate::signal::install_async_signal_handler()
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(crate::cmd::tui::run_tui(args, shutdown))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
use std::time::Duration;
|
||||
use std::{fmt, 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)]
|
||||
#[derive(Clone, clap::Args)]
|
||||
pub struct CommonClientArgs {
|
||||
/// Use IPv4 only.
|
||||
#[arg(short = '4', long, conflicts_with_all = ["ipv6", "dual_stack"])]
|
||||
pub ipv4: bool,
|
||||
|
||||
/// Use IPv6 only.
|
||||
#[arg(short = '6', long, conflicts_with_all = ["ipv4", "dual_stack"])]
|
||||
pub ipv6: bool,
|
||||
|
||||
/// Probe available IPv4 and IPv6 paths separately for each hostname.
|
||||
#[arg(long, conflicts_with_all = ["ipv4", "ipv6"], long_help = "Probe available IPv4 and IPv6 paths separately for each hostname. Labels gain /v4 or /v6; IP literals remain single targets. Families are discovered when a target is added; reconnects resolve the original hostname again within the chosen family.")]
|
||||
pub dual_stack: bool,
|
||||
|
||||
/// Probe interval.
|
||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||
pub interval: Duration,
|
||||
@@ -63,6 +75,26 @@ pub struct CommonClientArgs {
|
||||
pub loose: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CommonClientArgs {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CommonClientArgs")
|
||||
.field("ipv4", &self.ipv4)
|
||||
.field("ipv6", &self.ipv6)
|
||||
.field("dual_stack", &self.dual_stack)
|
||||
.field("interval", &self.interval)
|
||||
.field("length", &self.length)
|
||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("clock", &self.clock)
|
||||
.field("tstamp", &self.tstamp)
|
||||
.field("stats", &self.stats)
|
||||
.field("server_fill", &self.server_fill)
|
||||
.field("dscp", &self.dscp)
|
||||
.field("ttl", &self.ttl)
|
||||
.field("loose", &self.loose)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum ClockArg {
|
||||
Wall,
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
|
||||
use irtt_client::{
|
||||
AddressFamily, Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy,
|
||||
SessionRequest, SocketConfig,
|
||||
};
|
||||
|
||||
use super::args::CommonClientArgs;
|
||||
|
||||
pub const DEFAULT_RECV_TIMEOUT: Duration = Duration::from_millis(20);
|
||||
|
||||
impl CommonClientArgs {
|
||||
pub fn address_family(&self) -> AddressFamily {
|
||||
if self.ipv4 {
|
||||
AddressFamily::Ipv4
|
||||
} else if self.ipv6 {
|
||||
AddressFamily::Ipv6
|
||||
} else {
|
||||
AddressFamily::Any
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shared client configuration template for a managed run.
|
||||
///
|
||||
/// The template carries no server address: the managed driver replaces it
|
||||
/// per target from that target's configuration, so deriving one here from
|
||||
/// an arbitrary target would be dead data.
|
||||
/// The config is endpoint-independent; managed targets own their addresses.
|
||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
||||
ClientConfig {
|
||||
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
|
||||
address_family: self.address_family(),
|
||||
open: OpenPolicy {
|
||||
negotiation: if self.loose {
|
||||
NegotiationPolicy::Loose
|
||||
} else {
|
||||
NegotiationPolicy::Strict
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
socket_config: SocketConfig {
|
||||
request: SessionRequest {
|
||||
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,
|
||||
server_fill: self.server_fill.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
auth: self
|
||||
.hmac
|
||||
.as_ref()
|
||||
.map_or(Authentication::Unauthenticated, |key| {
|
||||
Authentication::Hmac(HmacKey::new(key.as_bytes()))
|
||||
}),
|
||||
socket: SocketConfig {
|
||||
ttl: self.ttl,
|
||||
recv_timeout: Some(DEFAULT_RECV_TIMEOUT),
|
||||
..SocketConfig::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
|
||||
@@ -3,18 +3,18 @@ pub mod config;
|
||||
pub mod prepare;
|
||||
pub mod session;
|
||||
pub mod targets;
|
||||
pub(crate) mod worker;
|
||||
|
||||
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 config::expected_probe_count;
|
||||
pub use prepare::{
|
||||
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
pub use session::is_shutdown_requested;
|
||||
pub use targets::{
|
||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
||||
PreparedTarget, TargetArg, TargetSpec,
|
||||
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
||||
TargetPreparation, TargetSpec,
|
||||
};
|
||||
@@ -16,7 +16,7 @@ use irtt_client::{
|
||||
|
||||
use super::{
|
||||
args::CommonClientArgs,
|
||||
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
||||
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
|
||||
PreparedTarget,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct ManagedRunSetup {
|
||||
pub pacing: ManagedPacing,
|
||||
/// Whether this stream may replace its desired set from stdin.
|
||||
pub stdin_controlled: bool,
|
||||
/// Preparation policy and family selections for later stdin declarations.
|
||||
pub preparation: TargetPreparation,
|
||||
}
|
||||
|
||||
impl ManagedRunSetup {
|
||||
@@ -98,7 +100,7 @@ impl ManagedRunSetup {
|
||||
///
|
||||
/// Targets are validated before any configuration is built, so a bad target set
|
||||
/// fails before the caller can act on a half-prepared run.
|
||||
pub fn prepare_managed_run(
|
||||
pub async fn prepare_managed_run(
|
||||
common: &CommonClientArgs,
|
||||
duration: Duration,
|
||||
selection: TargetSelection<'_>,
|
||||
@@ -113,164 +115,19 @@ pub fn prepare_managed_run(
|
||||
} else {
|
||||
target_specs(selection.targets)?
|
||||
};
|
||||
let targets = prepare_managed_targets(specs)?;
|
||||
let (targets, preparation) = TargetPreparation::new(common.address_family(), common.dual_stack)
|
||||
.prepare(
|
||||
specs,
|
||||
selection
|
||||
.stdin_controlled
|
||||
.then_some(STDIN_MAX_DESIRED_TARGETS),
|
||||
)
|
||||
.await?;
|
||||
Ok(ManagedRunSetup {
|
||||
targets,
|
||||
client: common.to_client_config(duration),
|
||||
pacing: selection.pacing.into(),
|
||||
stdin_controlled: selection.stdin_controlled,
|
||||
preparation,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct CommonOnlyArgs {
|
||||
#[command(flatten)]
|
||||
common: CommonClientArgs,
|
||||
}
|
||||
|
||||
fn common(args: &[&str]) -> CommonClientArgs {
|
||||
let mut argv = vec!["common-only"];
|
||||
argv.extend_from_slice(args);
|
||||
CommonOnlyArgs::try_parse_from(argv).unwrap().common
|
||||
}
|
||||
|
||||
fn unlabeled(addr: &str) -> TargetArg {
|
||||
TargetArg::new(addr)
|
||||
}
|
||||
|
||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
||||
TargetArg::new(format!("{label}={addr}"))
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
targets: &[TargetArg],
|
||||
pacing: GroupPacingArg,
|
||||
duration: Duration,
|
||||
) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(
|
||||
&common(&[]),
|
||||
duration,
|
||||
TargetSelection {
|
||||
targets,
|
||||
pacing,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_target_prepares_one_managed_target_and_a_finite_duration() {
|
||||
let setup = prepare(
|
||||
&[unlabeled("127.0.0.1:2112")],
|
||||
GroupPacingArg::Staggered,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(setup.target_count(), 1);
|
||||
assert!(!setup.is_multi_target());
|
||||
assert_eq!(setup.targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(setup.targets[0].managed.server_addr, "127.0.0.1:2112");
|
||||
assert_eq!(setup.client.duration, Some(Duration::from_secs(30)));
|
||||
assert_eq!(setup.pacing, ManagedPacing::Staggered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_duration_prepares_a_continuous_client_config() {
|
||||
let setup = prepare(
|
||||
&[unlabeled("127.0.0.1:2112")],
|
||||
GroupPacingArg::Staggered,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(setup.client.duration, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_keep_argument_order_and_share_one_client_config() {
|
||||
let setup = prepare(
|
||||
&[
|
||||
unlabeled("host-a:2112"),
|
||||
unlabeled("host-b:2112"),
|
||||
labeled("sg", "sg.example.test:2112"),
|
||||
],
|
||||
GroupPacingArg::Burst,
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(setup.is_multi_target());
|
||||
let labels: Vec<_> = setup
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.label.as_str())
|
||||
.collect();
|
||||
assert_eq!(labels, ["host-a:2112", "host-b:2112", "sg"]);
|
||||
assert_eq!(setup.targets[2].managed.server_addr, "sg.example.test:2112");
|
||||
assert_eq!(setup.pacing, ManagedPacing::Burst);
|
||||
|
||||
let config = setup.managed_config();
|
||||
assert_eq!(config.outcome_history_limit, 3);
|
||||
assert_eq!(config.max_live_target_generations, 3);
|
||||
assert_eq!(config.event_capacity, MANAGED_EVENT_CAPACITY);
|
||||
assert_eq!(config.pacing, ManagedPacing::Burst);
|
||||
assert_eq!(config.client, setup.client);
|
||||
assert_eq!(setup.managed_targets().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shared_client_config_carries_no_target_of_its_own() {
|
||||
// The managed driver replaces the address, and the CLI's key, per
|
||||
// target, so the template must not pin either to one target.
|
||||
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-b:2112")];
|
||||
let setup = prepare_managed_run(
|
||||
&common(&["--hmac", "secret"]),
|
||||
Duration::from_secs(10),
|
||||
TargetSelection {
|
||||
targets: &targets,
|
||||
pacing: GroupPacingArg::Staggered,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
setup.client.server_addr,
|
||||
ClientConfig::default().server_addr
|
||||
);
|
||||
assert_eq!(setup.client.hmac_key, Some(b"secret".to_vec()));
|
||||
// Per-target authentication is not exposed on the command line, so no
|
||||
// target carries an override of the shared key.
|
||||
assert!(setup
|
||||
.targets
|
||||
.iter()
|
||||
.all(|target| target.managed.auth.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_target_set_is_rejected_with_the_list_columns_hint() {
|
||||
let err = prepare(&[], GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
|
||||
|
||||
assert!(err.contains("at least one target is required"));
|
||||
assert!(err.contains("--list-columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_label_is_rejected_before_any_configuration_is_built() {
|
||||
let targets = vec![
|
||||
unlabeled("host-a:2112"),
|
||||
labeled("host-a:2112", "host-b:2112"),
|
||||
];
|
||||
let err =
|
||||
prepare(&targets, GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
|
||||
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
|
||||
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
pub(crate) const MANAGED_EVENT_WORK_BUDGET: usize = 128;
|
||||
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ManagedDrainState {
|
||||
Empty,
|
||||
Closed,
|
||||
BudgetExhausted,
|
||||
}
|
||||
|
||||
/// Drain a bounded amount of lossy presentation work before returning to the
|
||||
/// frontend control loop.
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
pub(crate) fn drain_managed_events<E>(
|
||||
pub(crate) fn drain_final_events<E>(
|
||||
events: &mut ManagedEventSubscription,
|
||||
dropped_events: &mut u64,
|
||||
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
|
||||
) -> Result<ManagedDrainState, E> {
|
||||
for _ in 0..MANAGED_EVENT_WORK_BUDGET {
|
||||
) -> Result<(), E> {
|
||||
loop {
|
||||
match events.try_recv() {
|
||||
Ok(event) => process(event)?,
|
||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||
*dropped_events = dropped_events.saturating_add(count);
|
||||
}
|
||||
Err(ManagedEventTryRecvError::Empty) => return Ok(ManagedDrainState::Empty),
|
||||
Err(ManagedEventTryRecvError::Closed) => return Ok(ManagedDrainState::Closed),
|
||||
}
|
||||
}
|
||||
Ok(ManagedDrainState::BudgetExhausted)
|
||||
}
|
||||
|
||||
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
||||
shutdown_requested.load(Ordering::Relaxed)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
||||
@@ -81,61 +59,3 @@ pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
|
||||
*stop_requested = true;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
|
||||
assert_eq!(peer_close_run_error(false, false, 1), None);
|
||||
assert_eq!(peer_close_run_error(true, false, 0), None);
|
||||
assert_eq!(peer_close_run_error(true, true, 1), None);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 1).as_deref(),
|
||||
Some("continuous run ended because of peer closure (1 target session)")
|
||||
);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 3).as_deref(),
|
||||
Some("continuous run ended because of peer closure (3 target sessions)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() {
|
||||
let mut stop_requested = false;
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!stop_requested);
|
||||
|
||||
assert!(request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(stop_requested);
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
use std::{collections::HashSet, fmt};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt, io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
|
||||
ClientAuthConfig,
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||
AddressFamily, Authentication, HmacKey,
|
||||
};
|
||||
|
||||
/// One raw positional target captured by Clap.
|
||||
@@ -35,23 +39,6 @@ pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||
Ok(TargetArg::new(input))
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum TargetAuth {
|
||||
Inherit,
|
||||
Override(Vec<u8>),
|
||||
Disable,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetAuth {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Inherit => f.write_str("Inherit"),
|
||||
Self::Override(_) => f.write_str("Override(<redacted>)"),
|
||||
Self::Disable => f.write_str("Disable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TargetSpec {
|
||||
pub label: String,
|
||||
@@ -80,16 +67,8 @@ impl fmt::Debug for PreparedTarget {
|
||||
f.debug_struct("PreparedTarget")
|
||||
.field("label", &self.label)
|
||||
.field("server_addr", &self.managed.server_addr)
|
||||
.field(
|
||||
"auth",
|
||||
&match &self.managed.auth {
|
||||
None => TargetAuth::Inherit,
|
||||
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
|
||||
TargetAuth::Override(Vec::new())
|
||||
}
|
||||
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
|
||||
},
|
||||
)
|
||||
.field("address_family", &self.managed.address_family)
|
||||
.field("auth", &self.managed.auth)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -216,9 +195,9 @@ fn parse_target_syntax(
|
||||
Some(index) => {
|
||||
let value = unescape(&input[index + "@hmac=".len()..])?;
|
||||
let auth = if value.is_empty() {
|
||||
TargetAuth::Disable
|
||||
TargetAuth::Override(Authentication::Unauthenticated)
|
||||
} else {
|
||||
TargetAuth::Override(value.into_bytes())
|
||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
|
||||
};
|
||||
(&input[..index], auth)
|
||||
}
|
||||
@@ -283,23 +262,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs_with_empty(targets, false)
|
||||
}
|
||||
|
||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||
managed.auth = match spec.auth {
|
||||
TargetAuth::Inherit => None,
|
||||
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
|
||||
hmac_key: Some(hmac_key),
|
||||
}),
|
||||
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
|
||||
};
|
||||
targets.push(PreparedTarget {
|
||||
label: spec.label,
|
||||
managed,
|
||||
});
|
||||
/// Family selections for the current desired declarations, never resolved IPs.
|
||||
/// Unchanged stdin declarations retain their selections; removed declarations
|
||||
/// are forgotten, so adding them again performs fresh discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TargetPreparation {
|
||||
family: AddressFamily,
|
||||
dual_stack: bool,
|
||||
discovered: HashMap<String, (String, [bool; 2])>,
|
||||
}
|
||||
|
||||
impl TargetPreparation {
|
||||
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
|
||||
Self {
|
||||
family,
|
||||
dual_stack,
|
||||
discovered: HashMap::new(),
|
||||
}
|
||||
}
|
||||
Ok(targets)
|
||||
|
||||
pub async fn prepare(
|
||||
mut self,
|
||||
specs: Vec<TargetSpec>,
|
||||
maximum_targets: Option<usize>,
|
||||
) -> Result<(Vec<PreparedTarget>, Self), String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
let mut discovered = HashMap::new();
|
||||
let mut labels = HashSet::new();
|
||||
for (index, spec) in specs.into_iter().enumerate() {
|
||||
let families = if let Some(ip) = literal_ip(&spec.addr) {
|
||||
match (self.family, ip) {
|
||||
(AddressFamily::Ipv4, IpAddr::V6(_)) => {
|
||||
return Err(format!(
|
||||
"target {}: IPv6 literal conflicts with --ipv4",
|
||||
index + 1
|
||||
))
|
||||
}
|
||||
(AddressFamily::Ipv6, IpAddr::V4(_)) => {
|
||||
return Err(format!(
|
||||
"target {}: IPv4 literal conflicts with --ipv6",
|
||||
index + 1
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else if self.dual_stack {
|
||||
let families = match self.discovered.get(&spec.label) {
|
||||
Some((addr, families)) if *addr == spec.addr => *families,
|
||||
_ => {
|
||||
let endpoint = discovery_endpoint(&spec.addr)
|
||||
.map_err(|error| format!("target {}: {error}", index + 1))?;
|
||||
discovery_families(tokio::net::lookup_host(endpoint).await)
|
||||
.map_err(|error| format!("target {}: {error}", index + 1))?
|
||||
}
|
||||
};
|
||||
discovered.insert(spec.label.clone(), (spec.addr.clone(), families));
|
||||
Some(families)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for target in expand_target(spec, families) {
|
||||
if !labels.insert(target.label.clone()) {
|
||||
return Err("duplicate target label after address-family expansion".to_owned());
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
if let Some(maximum) = maximum_targets.filter(|maximum| targets.len() > *maximum) {
|
||||
return Err(format!(
|
||||
"target set exceeds the {maximum}-target limit after address-family expansion"
|
||||
));
|
||||
}
|
||||
}
|
||||
self.discovered = discovered;
|
||||
Ok((targets, self))
|
||||
}
|
||||
}
|
||||
|
||||
fn literal_ip(endpoint: &str) -> Option<IpAddr> {
|
||||
endpoint
|
||||
.parse::<SocketAddr>()
|
||||
.map(|addr| addr.ip())
|
||||
.ok()
|
||||
.or_else(|| endpoint.parse().ok())
|
||||
.or_else(|| {
|
||||
if endpoint.starts_with('[') && endpoint.ends_with(']') {
|
||||
format!("{endpoint}:2112")
|
||||
.parse::<SocketAddr>()
|
||||
.ok()
|
||||
.map(|addr| addr.ip())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn discovery_endpoint(endpoint: &str) -> Result<(&str, u16), String> {
|
||||
match endpoint.rsplit_once(':') {
|
||||
Some((host, port)) => {
|
||||
let port = port.parse().map_err(|_| "invalid server port".to_owned())?;
|
||||
if host.is_empty() || host.contains(':') || host.starts_with('[') {
|
||||
return Err("invalid server endpoint".to_owned());
|
||||
}
|
||||
Ok((host, port))
|
||||
}
|
||||
None => Ok((endpoint, 2112)),
|
||||
}
|
||||
}
|
||||
|
||||
fn discovery_families(
|
||||
result: io::Result<impl Iterator<Item = SocketAddr>>,
|
||||
) -> Result<[bool; 2], String> {
|
||||
let mut families = [false; 2];
|
||||
for addr in result.map_err(|_| "DNS lookup failed".to_owned())? {
|
||||
families[usize::from(addr.is_ipv6())] = true;
|
||||
}
|
||||
if families == [false; 2] {
|
||||
return Err("DNS lookup returned no usable addresses".to_owned());
|
||||
}
|
||||
Ok(families)
|
||||
}
|
||||
|
||||
fn expand_target(spec: TargetSpec, families: Option<[bool; 2]>) -> Vec<PreparedTarget> {
|
||||
let selections = match families {
|
||||
None => vec![(spec.label, None)],
|
||||
Some(families) => [("v4", AddressFamily::Ipv4), ("v6", AddressFamily::Ipv6)]
|
||||
.into_iter()
|
||||
.zip(families)
|
||||
.filter(|(_, exists)| *exists)
|
||||
.map(|((suffix, family), _)| (format!("{}/{suffix}", spec.label), Some(family)))
|
||||
.collect(),
|
||||
};
|
||||
selections
|
||||
.into_iter()
|
||||
.map(|(label, family)| {
|
||||
let mut managed =
|
||||
ManagedTargetConfig::new(TargetId::from(label.clone()), spec.addr.clone());
|
||||
managed.address_family = family;
|
||||
managed.auth = spec.auth.clone();
|
||||
PreparedTarget { label, managed }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse one complete stdin target set after its line terminator was removed.
|
||||
@@ -309,7 +411,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
|
||||
pub fn parse_stdin_target_set(
|
||||
record: &str,
|
||||
maximum_targets: usize,
|
||||
) -> Result<Vec<PreparedTarget>, String> {
|
||||
) -> Result<Vec<TargetSpec>, String> {
|
||||
if record == "[]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -320,172 +422,5 @@ pub fn parse_stdin_target_set(
|
||||
));
|
||||
}
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn target(input: &str) -> TargetArg {
|
||||
TargetArg::new(input)
|
||||
}
|
||||
|
||||
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_target_syntax_and_ipv6_remain_supported() {
|
||||
let parsed = specs(&[
|
||||
"host.example",
|
||||
"host.example:2112",
|
||||
"[::1]:2112",
|
||||
"eu=host.example",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(parsed[0].label, "host.example");
|
||||
assert_eq!(parsed[1].label, "host.example:2112");
|
||||
assert_eq!(parsed[2].label, "[::1]:2112");
|
||||
assert_eq!(parsed[3].label, "eu");
|
||||
assert!(parsed
|
||||
.iter()
|
||||
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
|
||||
let parsed = specs(&[
|
||||
"default=default.example",
|
||||
"private=private.example@hmac=YWJjZA==",
|
||||
"public=public.example@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
|
||||
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
|
||||
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delimiters_are_escaped_contextually() {
|
||||
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "eu=west");
|
||||
assert_eq!(parsed[0].addr, "host@hmac=literal");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_commas_are_not_reserved() {
|
||||
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
|
||||
assert_eq!(parsed[0].addr, "host,region");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
|
||||
let secret = "very-secret-value";
|
||||
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
|
||||
assert!(!error.contains(secret));
|
||||
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
|
||||
assert!(
|
||||
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "host-a:2112");
|
||||
assert_eq!(parsed[1].label, "host-a:2112#2");
|
||||
assert_eq!(
|
||||
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
|
||||
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
|
||||
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
|
||||
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
|
||||
assert_eq!(
|
||||
addresses,
|
||||
["local", "eu.example", "foo", "us.example", "foo"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
assert_eq!(
|
||||
specs(&[
|
||||
"host.example",
|
||||
"host.example",
|
||||
"host.example#2=other.example"
|
||||
])
|
||||
.unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_target_syntax_is_rejected_without_echoing_input() {
|
||||
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
|
||||
assert!(specs(&[input]).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_maps_auth_to_managed_config() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
prepared[0]
|
||||
.managed
|
||||
.auth
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hmac_key
|
||||
.as_deref(),
|
||||
Some(b"key".as_slice())
|
||||
);
|
||||
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_preserves_duplicate_endpoints() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
|
||||
assert_eq!(prepared[0].managed.server_addr, "example.test");
|
||||
assert_eq!(prepared[1].managed.server_addr, "example.test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_target_sets_frame_commas_without_trimming_payload() {
|
||||
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
|
||||
assert_eq!(
|
||||
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
|
||||
Some(b"x,y".as_slice())
|
||||
);
|
||||
assert_eq!(parsed[1].label, "b");
|
||||
assert_eq!(
|
||||
parse_stdin_target_set(" []", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
" []"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_stdin_target_set("[] ", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
"[] "
|
||||
);
|
||||
assert!(parse_stdin_target_set("", 128).is_err());
|
||||
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_empty_set_is_exactly_bracket_pair() {
|
||||
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
|
||||
}
|
||||
target_specs_with_empty(&args, true)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::{io, thread, time::Duration};
|
||||
|
||||
use irtt_client::managed::{ManagedClientHandle, ManagedClientTask, ManagedOutcome};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// Keeps measurement scheduling separate from frontend output and statistics.
|
||||
/// Drop also stops and joins on frontend errors or cancellation.
|
||||
pub(crate) struct ManagedWorker {
|
||||
handle: ManagedClientHandle,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
completion: oneshot::Receiver<io::Result<ManagedOutcome>>,
|
||||
}
|
||||
|
||||
impl ManagedWorker {
|
||||
pub(crate) fn start(task: ManagedClientTask, handle: ManagedClientHandle) -> io::Result<Self> {
|
||||
let (completed, completion) = oneshot::channel();
|
||||
let thread = thread::Builder::new()
|
||||
.name("irtt-managed".to_owned())
|
||||
.spawn(move || {
|
||||
// Construct and destroy the runtime on its owning thread, including
|
||||
// startup failures; never drop a runtime inside the frontend executor.
|
||||
let outcome = (|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
runtime.block_on(task)
|
||||
}));
|
||||
runtime.shutdown_timeout(Duration::from_millis(100));
|
||||
match outcome {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
}
|
||||
})();
|
||||
let _ = completed.send(outcome);
|
||||
})?;
|
||||
Ok(Self {
|
||||
handle,
|
||||
thread: Some(thread),
|
||||
completion,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn join(mut self) -> Result<ManagedOutcome, Box<dyn std::error::Error>> {
|
||||
let outcome = (&mut self.completion).await;
|
||||
self.join_thread()?;
|
||||
Ok(outcome
|
||||
.map_err(|_| io::Error::other("managed worker completed without an outcome"))??)
|
||||
}
|
||||
|
||||
fn join_thread(&mut self) -> io::Result<()> {
|
||||
self.thread
|
||||
.take()
|
||||
.expect("managed worker is joined once")
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("managed worker thread panicked"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedWorker {
|
||||
fn drop(&mut self) {
|
||||
if self.thread.is_some() {
|
||||
drop(self.handle.stop());
|
||||
let _ = self.join_thread();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
||||
ctrlc::set_handler(move || {
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// Preserve ctrlc's SIGINT/SIGTERM policy while waking the async frontends.
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
pub fn install_async_signal_handler() -> Result<tokio::sync::watch::Receiver<bool>, ctrlc::Error> {
|
||||
let (shutdown, receiver) = tokio::sync::watch::channel(false);
|
||||
ctrlc::set_handler(move || {
|
||||
shutdown.send_replace(true);
|
||||
})?;
|
||||
Ok(receiver)
|
||||
}
|
||||
@@ -2,6 +2,42 @@
|
||||
|
||||
Repository-wide guidance in the root `AGENTS.md` also applies here.
|
||||
|
||||
## Probe-send commitment API
|
||||
|
||||
`Client::send_probe` and `AsyncClient::send_probe` return
|
||||
`Result<SendReceipt, SendProbeError>`; the Tokio poll driver returns the same
|
||||
result inside `Poll`. A receipt represents an Echo datagram accepted by the
|
||||
socket and already committed in session state: sequence and sent count advanced,
|
||||
and the probe entered pending tracking. It contains `seq`, `remote`, the public
|
||||
post-send `sent_at`, accepted `bytes`, and `send_call`, without schedule metadata.
|
||||
`ClientEvent::from(receipt)` constructs `EchoSent` with no schedule annotations.
|
||||
|
||||
Classify every failure explicitly: `NotCommitted(ClientError)` means no datagram
|
||||
was accepted and no probe committed; `AfterCommit { receipt, source }` preserves
|
||||
the authoritative accepted send when length validation or TX error-queue
|
||||
processing fails. There is no blanket `From<ClientError>` conversion. Do not
|
||||
roll back a committed probe or classify a post-send error with an indiscriminate
|
||||
`?`.
|
||||
|
||||
All fallible state/capacity/counter/allocation and timeout preparation precedes
|
||||
submission. Immediately after `send`/`try_send` returns `Ok(bytes)`, capture the
|
||||
public post-send timestamp, commit infallibly, and construct the receipt before
|
||||
fallible post-send processing. No suspension, readiness wait, fallible allocation,
|
||||
or fallible state operation may intervene between acceptance and commitment;
|
||||
the Tokio poll must never return `Pending` after acceptance.
|
||||
|
||||
The managed driver consumes receipts for cadence/stagger acceptance and constructs
|
||||
`EchoSent` with its scheduled timestamp and timer error. A post-commit failure
|
||||
advances cadence and publishes that send before failure cleanup; the underlying
|
||||
cause remains a sending failure. `NotCommitted` and `Pending` advance neither
|
||||
cadence nor stagger gating. Counters are cumulative accounting, never evidence
|
||||
of one operation's commitment.
|
||||
|
||||
Tokio retains one prepared probe across cancellation/readiness false positives
|
||||
and `WouldBlock`, samples a fresh private anchor on each retry, and clears the
|
||||
prepared probe after commitment even when post-send processing fails. Failed
|
||||
submissions still invalidate kernel TX ID correlation as documented below.
|
||||
|
||||
## Linux kernel TX timestamp capture
|
||||
|
||||
On Linux, with the `ancillary` feature, the client best-effort upgrades its
|
||||
@@ -84,7 +120,9 @@ backlog. `Client`/`AsyncClient` call it after a successful probe send,
|
||||
before processing an inbound reply, and before evaluating timeouts; it never
|
||||
waits for a timestamp; an absent timestamp after all of those opportunities
|
||||
simply stays absent. A genuine `SocketError` record stops the drain and is
|
||||
surfaced through the ordinary `ClientError::Socket` path.
|
||||
surfaced as `ClientError::Socket`. During post-send processing it is wrapped in
|
||||
`SendProbeError::AfterCommit` with the accepted send's receipt; receive/timeout
|
||||
operations retain the ordinary `ClientError` path.
|
||||
|
||||
The Tokio adapter uses the same raw nonblocking `recvmsg(MSG_ERRQUEUE |
|
||||
MSG_DONTWAIT)` rather than `try_io`/`poll_recv_ready`: error-queue readiness
|
||||
@@ -93,6 +131,29 @@ risks clearing readiness state that the normal Echo receive path depends on.
|
||||
There is no background task or second `AsyncFd` around the Tokio-owned
|
||||
socket.
|
||||
|
||||
### Testing kernel timestamp behavior
|
||||
|
||||
The requirements above describe production behavior, not a required unit-test
|
||||
architecture.
|
||||
|
||||
Prefer Linux tests that exercise the real socket and kernel timestamp path
|
||||
through production APIs. A test intended to prove TX timestamp support must
|
||||
actually require and observe a kernel TX timestamp when running on a supported
|
||||
Linux environment; accepting the userspace fallback does not prove that path.
|
||||
|
||||
Likewise, prefer observable socket/client behavior for genuine error-queue
|
||||
errors and bounded draining where it can reasonably be induced.
|
||||
|
||||
Do not restore synthetic `ControlMessageOwned` permutations, classifier-unit
|
||||
suites, raw ancillary fixtures, test-only socket hooks, or other internal seams
|
||||
solely to cover every `MSG_ERRQUEUE` branch. `nix` owns ancillary-message
|
||||
decoding and ABI safety; `irtt-client` tests should primarily prove its own
|
||||
observable interpretation and behavior through the real path.
|
||||
|
||||
If some invariant cannot reasonably be induced through a production interface,
|
||||
a small focused internal test may be retained, but the burden is on that test to
|
||||
justify why integration coverage is insufficient.
|
||||
|
||||
## Retained state
|
||||
|
||||
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
|
||||
@@ -127,7 +188,8 @@ remains anchored to before the send, on purpose, so the existing pre-send
|
||||
timeout/overflow/no-send contract is unchanged. Only the *measurement*
|
||||
`sent_at` moved to after the send. A `WouldBlock` `try_send` (Tokio) never
|
||||
samples a `sent_at`, never calls `finalize_probe_commit`'s matching
|
||||
`commit_probe_sent`, and never advances the machine or schedule; each retry
|
||||
`commit_probe_sent`, and never advances the machine; the managed runner advances cadence only
|
||||
after an accepted send; each retry
|
||||
gets its own fresh pre-send anchor, and only a successful attempt's anchor
|
||||
and `sent_at` are ever retained.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-client"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
description = "IRTT-compatible Rust client library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -14,8 +14,8 @@ categories = ["network-programming"]
|
||||
[dependencies]
|
||||
irtt-proto.workspace = true
|
||||
socket2 = { version = "0.6.5", features = ["all"] }
|
||||
thiserror = "2.0.20"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true }
|
||||
@@ -25,6 +25,9 @@ default = []
|
||||
ancillary = ["dep:nix"]
|
||||
tokio = ["dep:tokio"]
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||
@@ -42,10 +45,3 @@ doc-scrape-examples = true
|
||||
name = "managed_client"
|
||||
required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Keep the packaged client testable against the latest published server while
|
||||
# workspace tests continue to exercise the local source through this path.
|
||||
irtt-server = { version = "0.5.1", path = "../irtt-server" }
|
||||
proptest = "1.11.0"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
@@ -22,11 +22,83 @@ binaries are built on.
|
||||
runtime, and `BlockingManagedClient` for synchronous callers, which owns its
|
||||
own dedicated current-thread runtime.
|
||||
|
||||
`Client` and `AsyncClient` send one probe whenever called. They expose the
|
||||
negotiated parameters, pending-probe status, and timeout deadlines; callers
|
||||
own cadence and run duration. The managed driver provides fixed cadence,
|
||||
missed-slot handling, finite runs, and staggered or burst pacing. Low-level
|
||||
sends return `Result<SendReceipt, SendProbeError>` instead of event lists.
|
||||
A `SendReceipt` carries the committed sequence, remote, post-send timestamp,
|
||||
accepted bytes, and send-call duration. `NotCommitted(ClientError)` guarantees
|
||||
no accepted datagram or committed probe; `AfterCommit { receipt, source }`
|
||||
preserves the accepted send when subsequent validation or TX processing fails.
|
||||
Use `ClientEvent::from(receipt)` for an `EchoSent` event with `scheduled_at` and
|
||||
`timer_error` absent; the managed driver supplies those schedule measurements
|
||||
and publishes committed sends even when post-send processing fails.
|
||||
|
||||
Tokio stays optional: the default build has no runtime and no Tokio
|
||||
dependency at all.
|
||||
|
||||
See `examples/` in the repository for runnable examples of each tier.
|
||||
|
||||
## Reusable configuration
|
||||
|
||||
`ClientConfig` contains no endpoint. The low-level APIs take it separately:
|
||||
`Client::connect(endpoint, config)` and
|
||||
`AsyncClient::connect(endpoint, config).await`, accepting `String` or `&str`
|
||||
endpoints. An omitted port defaults to 2112.
|
||||
|
||||
Configuration is grouped by ownership:
|
||||
|
||||
- `address_family`: `AddressFamily::{Any, Ipv4, Ipv6}`, defaulting to `Any`.
|
||||
It filters remote resolution; `Ipv6` also sets `IPV6_V6ONLY`.
|
||||
- `socket`: `SocketConfig` for local bind, device/routing options, and TTL.
|
||||
- `request`: `SessionRequest` for duration, interval, length, received stats,
|
||||
timestamps, clock, DSCP, server fill, and run mode.
|
||||
- `open`: `OpenPolicy` for attempt `timeouts` and `negotiation` policy.
|
||||
- `auth`, `probe_timeout`, and `max_pending_probes`: authentication and local
|
||||
probe tracking policy.
|
||||
|
||||
Clone one config to connect to multiple endpoints. Managed targets retain their
|
||||
own ID, endpoint, address-family override, and authentication inheritance/override; shared
|
||||
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
||||
|
||||
`ManagedTargetConfig.address_family` defaults to `None`, inheriting the shared
|
||||
family policy. `Some(AddressFamily::Ipv4)`, `Some(AddressFamily::Ipv6)`, or
|
||||
`Some(AddressFamily::Any)` overrides it for that target. Every new generation
|
||||
resolves the original endpoint again; callers can constrain a hostname without
|
||||
replacing it with a resolved IP. Changing the family specification replaces
|
||||
that target's generation, including switching between inheritance and an equal
|
||||
explicit override. Hostname expansion and discovery are frontend policies.
|
||||
|
||||
Blocking receive policy belongs to `Client`:
|
||||
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
||||
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
||||
Open attempts use only `open.timeouts`, restoring the adapter setting afterward.
|
||||
Async and managed clients have no blocking receive-timeout setting.
|
||||
|
||||
This breaks the former flat config, endpoint-in-config, socket family flags,
|
||||
and `SocketConfig.recv_timeout` APIs. Use the grouped fields and adapter method
|
||||
directly; defaults and protocol/runtime behavior are preserved.
|
||||
|
||||
## Authentication
|
||||
|
||||
`ClientConfig.auth` is concrete: `Authentication::Unauthenticated` or
|
||||
`Authentication::Hmac(HmacKey::new(bytes))`. Keys share their bytes across
|
||||
clones and print only `HmacKey([REDACTED])` through `Debug`. Borrowing key
|
||||
material requires an explicit `as_bytes()` call. An empty key still selects
|
||||
HMAC authentication; textual key syntax belongs to the application.
|
||||
|
||||
For managed targets, `ManagedTargetConfig.auth` defaults to `TargetAuth::Inherit`.
|
||||
`TargetAuth::Override(Authentication::Unauthenticated)` disables shared
|
||||
authentication, and `TargetAuth::Override(Authentication::Hmac(key))` replaces
|
||||
the shared key. Initial construction and update planning resolve inheritance
|
||||
into concrete session authentication before execution. Switching between
|
||||
inheritance and an override replaces the target generation even when their
|
||||
resolved authentication is equal.
|
||||
|
||||
This replaces the former `ClientConfig.hmac_key` and `ClientAuthConfig` API;
|
||||
there are no compatibility aliases.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
|
||||
|
||||
@@ -12,32 +12,33 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Without a reachable server this exits quickly with an open-timeout error
|
||||
//! instead of hanging, because the example shortens `open_timeouts` for a
|
||||
//! instead of hanging, because the example shortens `open.timeouts` for a
|
||||
//! fast demonstration; a real caller would normally keep the default retry
|
||||
//! schedule.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent};
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest};
|
||||
|
||||
const RUN_DURATION: Duration = Duration::from_secs(2);
|
||||
|
||||
fn main() {
|
||||
let config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
duration: Some(RUN_DURATION),
|
||||
interval: Duration::from_millis(200),
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
open_timeouts: vec![Duration::from_millis(300)],
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(100)),
|
||||
open: OpenPolicy {
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
},
|
||||
request: SessionRequest {
|
||||
duration: Some(RUN_DURATION),
|
||||
interval: Duration::from_millis(200),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut client = match Client::connect(config) {
|
||||
let mut client = match Client::connect("127.0.0.1:2112", config) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("failed to prepare client socket: {err}");
|
||||
@@ -45,33 +46,53 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
match client.open() {
|
||||
Ok(outcome) => println!("session opened: {outcome:?}"),
|
||||
if let Err(err) = client.set_recv_timeout(Some(Duration::from_millis(100))) {
|
||||
eprintln!("failed to configure receive timeout: {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let negotiated = match client.open() {
|
||||
Ok(OpenOutcome::Started(started)) => {
|
||||
println!("session opened: {started:?}");
|
||||
started.negotiation
|
||||
}
|
||||
Ok(OpenOutcome::NoTestCompleted(completed)) => {
|
||||
println!("{completed:?}");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The run itself lasts `duration`, but a lost probe isn't classified as
|
||||
// `EchoLoss` until `probe_timeout()` after it was sent; wait at least
|
||||
// that much past the run's own end so a loss near the end can still be
|
||||
// observed and printed before this example gives up and closes.
|
||||
let deadline = Instant::now() + RUN_DURATION + client.probe_timeout();
|
||||
while Instant::now() < deadline && !client.is_run_complete() {
|
||||
// send_probe is caller-paced: only call it once its own schedule
|
||||
// says a probe is due, rather than as fast as the loop spins.
|
||||
if client
|
||||
.next_send_deadline()
|
||||
.is_some_and(|send_at| Instant::now() >= send_at)
|
||||
{
|
||||
// A failed send retains its prepared probe without advancing the
|
||||
// schedule, so a persistent error (e.g. the interface going
|
||||
// down) would otherwise make this loop retry silently until the
|
||||
// safety deadline instead of reporting it.
|
||||
// This loop owns cadence and duration. The transport sends whenever called.
|
||||
let interval = negotiated.accepted.interval;
|
||||
let start = Instant::now();
|
||||
let end = start
|
||||
+ negotiated
|
||||
.accepted
|
||||
.duration
|
||||
.expect("example requests a finite run");
|
||||
let deadline = end + client.probe_timeout();
|
||||
let mut next_send = start;
|
||||
while Instant::now() < deadline && !client.is_peer_closed() {
|
||||
let now = Instant::now();
|
||||
if now >= end && !client.has_pending_probes() {
|
||||
break;
|
||||
}
|
||||
if now < end && now >= next_send {
|
||||
match client.send_probe() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Ok(receipt) => {
|
||||
print_event(&receipt.into());
|
||||
while next_send <= Instant::now() {
|
||||
next_send += interval;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||
print_event(&(*receipt).into());
|
||||
}
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
}
|
||||
@@ -93,6 +114,9 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
if client.is_peer_closed() {
|
||||
return;
|
||||
}
|
||||
match client.close() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
|
||||
@@ -22,14 +22,20 @@ use irtt_client::managed::{
|
||||
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
|
||||
ManagedTargetConfig,
|
||||
};
|
||||
use irtt_client::ClientConfig;
|
||||
use irtt_client::{ClientConfig, OpenPolicy, SessionRequest};
|
||||
|
||||
fn main() {
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: None,
|
||||
interval: Duration::from_millis(200),
|
||||
open_timeouts: vec![Duration::from_millis(300)],
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
},
|
||||
request: SessionRequest {
|
||||
duration: None,
|
||||
interval: Duration::from_millis(200),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Without a reachable server this exits quickly with an open-timeout error
|
||||
//! instead of hanging, because the example shortens `open_timeouts` for a
|
||||
//! instead of hanging, because the example shortens `open.timeouts` for a
|
||||
//! fast demonstration.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{AsyncClient, ClientConfig, ClientEvent};
|
||||
use irtt_client::{
|
||||
AsyncClient, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest,
|
||||
};
|
||||
|
||||
/// Upper bound on how long the receive wait can go without also calling
|
||||
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
|
||||
@@ -41,16 +43,21 @@ fn main() {
|
||||
|
||||
async fn run() {
|
||||
let config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
duration: Some(Duration::from_secs(2)),
|
||||
interval: Duration::from_millis(200),
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
open_timeouts: vec![Duration::from_millis(300)],
|
||||
open: OpenPolicy {
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
},
|
||||
request: SessionRequest {
|
||||
duration: Some(Duration::from_secs(2)),
|
||||
interval: Duration::from_millis(200),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut client = match AsyncClient::connect(config).await {
|
||||
let mut client = match AsyncClient::connect("127.0.0.1:2112", config).await {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("failed to prepare client socket: {err}");
|
||||
@@ -58,24 +65,45 @@ async fn run() {
|
||||
}
|
||||
};
|
||||
|
||||
match client.open().await {
|
||||
Ok(outcome) => println!("session opened: {outcome:?}"),
|
||||
let negotiated = match client.open().await {
|
||||
Ok(OpenOutcome::Started(started)) => {
|
||||
println!("session opened: {started:?}");
|
||||
started.negotiation
|
||||
}
|
||||
Ok(OpenOutcome::NoTestCompleted(completed)) => {
|
||||
println!("{completed:?}");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while !client.is_run_complete() {
|
||||
// This loop owns cadence and duration. The transport sends whenever called.
|
||||
let interval = negotiated.accepted.interval;
|
||||
let start = Instant::now();
|
||||
let end = start
|
||||
+ negotiated
|
||||
.accepted
|
||||
.duration
|
||||
.expect("example requests a finite run");
|
||||
let deadline = end + client.probe_timeout();
|
||||
let mut next_send = start;
|
||||
while Instant::now() < deadline && !client.is_peer_closed() {
|
||||
if Instant::now() >= end && !client.has_pending_probes() {
|
||||
break;
|
||||
}
|
||||
// Bound the receive wait by the next send/timeout deadline, rather
|
||||
// than awaiting recv() unconditionally: a lost or rate-limited reply
|
||||
// must not stall pacing or timeout classification indefinitely.
|
||||
let wake_at = client
|
||||
.next_send_deadline()
|
||||
let wake_at = (next_send < end)
|
||||
.then_some(next_send)
|
||||
.into_iter()
|
||||
.chain(client.next_probe_timeout_deadline())
|
||||
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
|
||||
.min()
|
||||
.expect("the fixed poll-interval deadline is always present");
|
||||
.unwrap();
|
||||
|
||||
let recv_result = tokio::select! {
|
||||
events = client.recv() => Some(events),
|
||||
@@ -91,17 +119,18 @@ async fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
if client
|
||||
.next_send_deadline()
|
||||
.is_some_and(|send_at| Instant::now() >= send_at)
|
||||
{
|
||||
// A failed send retains its prepared probe without advancing the
|
||||
// schedule, so a persistent error (e.g. the interface going
|
||||
// down) would otherwise make this loop retry forever instead of
|
||||
// reporting it.
|
||||
if Instant::now() < end && Instant::now() >= next_send {
|
||||
match client.send_probe().await {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Ok(receipt) => {
|
||||
print_event(&receipt.into());
|
||||
while next_send <= Instant::now() {
|
||||
next_send += interval;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||
print_event(&(*receipt).into());
|
||||
}
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
}
|
||||
@@ -116,6 +145,9 @@ async fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
if client.is_peer_closed() {
|
||||
return;
|
||||
}
|
||||
match client.close().await {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
|
||||
@@ -7,20 +7,10 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::VecDeque,
|
||||
};
|
||||
|
||||
use tokio::time;
|
||||
|
||||
use crate::{
|
||||
client::{
|
||||
echo_sent_event,
|
||||
schedule::{instant_abs_diff, ProbeSchedule},
|
||||
validate_datagram_length,
|
||||
},
|
||||
client::validate_datagram_length,
|
||||
config::ClientConfig,
|
||||
error::ClientError,
|
||||
event::{ClientEvent, OpenOutcome},
|
||||
@@ -29,9 +19,10 @@ use crate::{
|
||||
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
|
||||
PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
|
||||
},
|
||||
socket::{connect_tokio_udp_socket, resolve_remote_tokio, validate_open_timeouts},
|
||||
socket::{connect_tokio_udp_socket, resolve_remote_tokio},
|
||||
socket_options::{apply_traffic_class_to_tokio_socket, clear_dscp_on_tokio_socket},
|
||||
timing::ClientTimestamp,
|
||||
SendProbeError, SendReceipt,
|
||||
};
|
||||
|
||||
/// Maximum amount of immediately available opening work performed by one poll.
|
||||
@@ -54,13 +45,9 @@ const OPEN_POLL_WORK_BUDGET: usize = 64;
|
||||
/// this crate already bounds open-attempt work keeps that impossible.
|
||||
const RECV_POLL_WORK_BUDGET: usize = 64;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::client::ProbeSendTimestamps;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedAsyncOpen {
|
||||
machine: PreparedOpenAcceptance,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
recv_buffer_len: Option<usize>,
|
||||
negotiated_traffic_class: Option<u8>,
|
||||
}
|
||||
@@ -99,7 +86,7 @@ impl AsyncOpenCleanup {
|
||||
/// Owned state for one asynchronous open transaction.
|
||||
///
|
||||
/// This state deliberately contains no reference to [`AsyncClient`], allowing
|
||||
/// a future managed target to store the client and opening state side by side.
|
||||
/// a managed target to store the client and opening state side by side.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AsyncOpenState {
|
||||
attempt: usize,
|
||||
@@ -156,11 +143,6 @@ impl AsyncOpenState {
|
||||
self.stopped_after_current_attempt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_retained_cleanup(&self) -> bool {
|
||||
self.cleanup.is_some()
|
||||
}
|
||||
|
||||
fn deadline(&self) -> Instant {
|
||||
self.deadline
|
||||
.expect("an open attempt has an active deadline")
|
||||
@@ -173,80 +155,40 @@ impl AsyncOpenState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug)]
|
||||
enum InjectedSend {
|
||||
WouldBlock,
|
||||
Error,
|
||||
ReportedLength(usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
struct AsyncClientTestHooks {
|
||||
sends: RefCell<VecDeque<InjectedSend>>,
|
||||
send_attempts: Cell<usize>,
|
||||
receive_would_block: Cell<usize>,
|
||||
/// Number of subsequent receive attempts (`poll_open` or `poll_recv`)
|
||||
/// that should report `io::ErrorKind::Interrupted` instead of performing
|
||||
/// a real nonblocking receive, deterministically exercising interrupted-
|
||||
/// syscall retry behavior.
|
||||
inject_recv_interrupted: Cell<usize>,
|
||||
fail_open_dscp: Cell<bool>,
|
||||
fail_cleanup_send: Cell<bool>,
|
||||
fail_peer_close_dscp: Cell<bool>,
|
||||
fail_dscp_restore: Cell<bool>,
|
||||
pause_open_cleanup_before_send: Cell<bool>,
|
||||
pause_open_before_writable: Cell<bool>,
|
||||
pause_open_before_readable: Cell<bool>,
|
||||
pause_probe_before_writable: Cell<bool>,
|
||||
pause_close_before_writable: Cell<bool>,
|
||||
pause_close_after_would_block: Cell<bool>,
|
||||
probe_timestamps: RefCell<VecDeque<ProbeSendTimestamps>>,
|
||||
close_sent_at: Cell<Option<ClientTimestamp>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AsyncClientTestHooks {
|
||||
fn try_send(&self, socket: &tokio::net::UdpSocket, packet: &[u8]) -> io::Result<usize> {
|
||||
self.send_attempts.set(self.send_attempts.get() + 1);
|
||||
if let Some(injected) = self.sends.borrow_mut().pop_front() {
|
||||
return match injected {
|
||||
InjectedSend::WouldBlock => Err(io::Error::from(io::ErrorKind::WouldBlock)),
|
||||
InjectedSend::Error => Err(io::Error::other("injected send failure")),
|
||||
InjectedSend::ReportedLength(bytes) => Ok(bytes),
|
||||
};
|
||||
}
|
||||
socket.try_send(packet)
|
||||
}
|
||||
|
||||
fn take_probe_timestamps(&self) -> Option<ProbeSendTimestamps> {
|
||||
self.probe_timestamps.borrow_mut().pop_front()
|
||||
}
|
||||
|
||||
/// Consumes one pending injected interruption, if any, reporting whether
|
||||
/// the caller should report `io::ErrorKind::Interrupted` instead of
|
||||
/// attempting a real receive.
|
||||
fn take_injected_interrupted(&self) -> bool {
|
||||
let remaining = self.inject_recv_interrupted.get();
|
||||
if remaining == 0 {
|
||||
return false;
|
||||
}
|
||||
self.inject_recv_interrupted.set(remaining - 1);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-level Tokio IRTT client for one connected UDP target.
|
||||
///
|
||||
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
|
||||
/// methods are polled by the caller and require a current Tokio runtime with I/O
|
||||
/// and time enabled.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Use this low-level adapter when the application already owns Tokio and wants
|
||||
/// to drive the session lifecycle itself. This example type-checks without
|
||||
/// contacting a server.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{AsyncClient, ClientConfig};
|
||||
///
|
||||
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut client = AsyncClient::connect("127.0.0.1:2112", ClientConfig::default()).await?;
|
||||
/// let outcome = client.open().await?;
|
||||
/// println!("opened: {outcome:?}");
|
||||
/// let sent = client.send_probe().await?;
|
||||
/// println!("sent: {sent:?}");
|
||||
/// let received = client.recv().await?;
|
||||
/// println!("received: {received:?}");
|
||||
/// let timed_out = client.poll_timeouts()?;
|
||||
/// println!("timeouts: {timed_out:?}");
|
||||
/// let closed = client.close().await?;
|
||||
/// println!("closed: {closed:?}");
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct AsyncClient {
|
||||
socket: tokio::net::UdpSocket,
|
||||
machine: SessionMachine,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
remote: SocketAddr,
|
||||
recv_buffer: Vec<u8>,
|
||||
applied_traffic_class: Option<u8>,
|
||||
@@ -255,37 +197,39 @@ pub struct AsyncClient {
|
||||
/// See `Client::tx_timestamping_enabled` in `client.rs` for the
|
||||
/// invariant this tracks; it is the same adapter-owned capability flag.
|
||||
tx_timestamping_enabled: bool,
|
||||
#[cfg(test)]
|
||||
test_hooks: AsyncClientTestHooks,
|
||||
}
|
||||
|
||||
impl AsyncClient {
|
||||
/// Resolve the configured server and construct one connected Tokio UDP
|
||||
/// Resolve the endpoint and construct one connected Tokio UDP
|
||||
/// socket.
|
||||
///
|
||||
/// Polling this future without a current Tokio runtime returns
|
||||
/// [`ClientError::NoTokioRuntime`]. A runtime without enabled I/O or time
|
||||
/// drivers is outside this type's runtime contract.
|
||||
pub async fn connect(config: ClientConfig) -> Result<Self, ClientError> {
|
||||
///
|
||||
/// The endpoint accepts a name or address; an omitted port defaults to 2112.
|
||||
/// IPv6 literals may be bracketed or unbracketed with the default port.
|
||||
/// The reusable config contains no endpoint identity.
|
||||
pub async fn connect(
|
||||
endpoint: impl AsRef<str>,
|
||||
config: ClientConfig,
|
||||
) -> Result<Self, ClientError> {
|
||||
tokio::runtime::Handle::try_current().map_err(|_| ClientError::NoTokioRuntime)?;
|
||||
validate_open_timeouts(&config.open_timeouts)?;
|
||||
let remote = resolve_remote_tokio(&config).await?;
|
||||
config.open.validate()?;
|
||||
let remote = resolve_remote_tokio(endpoint.as_ref(), config.address_family).await?;
|
||||
let machine = SessionMachine::new(config.clone(), remote)?;
|
||||
let prepared_open = machine.prepare_open_request()?;
|
||||
let socket = connect_tokio_udp_socket(&config.socket_config, remote)?;
|
||||
let socket = connect_tokio_udp_socket(&config.socket, remote, config.address_family)?;
|
||||
|
||||
Ok(Self {
|
||||
socket,
|
||||
machine,
|
||||
schedule: None,
|
||||
remote,
|
||||
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
||||
applied_traffic_class: None,
|
||||
prepared_open: Some(prepared_open),
|
||||
prepared_probe: None,
|
||||
tx_timestamping_enabled: false,
|
||||
#[cfg(test)]
|
||||
test_hooks: AsyncClientTestHooks::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,21 +241,26 @@ impl AsyncClient {
|
||||
/// ignored without resending the request.
|
||||
///
|
||||
/// Dropping this future before trusted acceptance leaves the local machine
|
||||
/// connected with no committed schedule or negotiated adapter state. If it
|
||||
/// is dropped during best-effort post-token cleanup, cleanup may remain
|
||||
/// incomplete because this low-level client never detaches cleanup work.
|
||||
/// connected with no negotiated adapter state. If it is dropped during
|
||||
/// best-effort post-token cleanup, cleanup may remain incomplete because
|
||||
/// this low-level client never detaches cleanup work.
|
||||
pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||
let mut state = AsyncOpenState::new();
|
||||
poll_fn(|cx| self.poll_open(&mut state, cx)).await
|
||||
}
|
||||
|
||||
/// Send one caller-paced probe with transactional protocol and schedule
|
||||
/// commits.
|
||||
/// Send one echo probe now, committing protocol state only after acceptance.
|
||||
/// The caller owns pacing and run duration.
|
||||
///
|
||||
/// A prepared packet is retained across readiness false positives,
|
||||
/// cancellation, and socket errors so a later call retries the same logical
|
||||
/// probe without advancing state before kernel acceptance.
|
||||
pub async fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
/// probe without advancing state before kernel acceptance. Cancellation or
|
||||
/// `WouldBlock` before submission is uncommitted. After acceptance there is
|
||||
/// no suspension before infallible commitment, and the prepared probe is
|
||||
/// cleared even if post-send processing returns [`SendProbeError::AfterCommit`].
|
||||
/// That failure retains the authoritative receipt; earlier failures return
|
||||
/// [`SendProbeError::NotCommitted`].
|
||||
pub async fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
|
||||
poll_fn(|cx| self.poll_send_probe(cx)).await
|
||||
}
|
||||
|
||||
@@ -371,7 +320,7 @@ impl AsyncClient {
|
||||
return self.poll_open_cleanup(state, cx);
|
||||
}
|
||||
|
||||
let attempt_count = self.machine.config().open_timeouts.len();
|
||||
let attempt_count = self.machine.config().open.timeouts.len();
|
||||
if state.attempt >= attempt_count {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
@@ -380,17 +329,13 @@ impl AsyncClient {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
if state.deadline.is_none() {
|
||||
let timeout = self.machine.config().open_timeouts[state.attempt];
|
||||
let timeout = self.machine.config().open.timeouts[state.attempt];
|
||||
if let Err(error) = state.start_attempt(timeout) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
}
|
||||
|
||||
if !state.request_submitted {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_open_before_writable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
@@ -405,9 +350,6 @@ impl AsyncClient {
|
||||
.prepared_open
|
||||
.as_ref()
|
||||
.expect("connected clients retain their prepared open request");
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, &request.bytes);
|
||||
#[cfg(not(test))]
|
||||
let send_result = self.socket.try_send(&request.bytes);
|
||||
match send_result {
|
||||
Ok(bytes) => {
|
||||
@@ -421,10 +363,6 @@ impl AsyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_open_before_readable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
@@ -438,10 +376,6 @@ impl AsyncClient {
|
||||
Poll::Ready(Ok(true)) => {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.take_injected_interrupted() {
|
||||
continue;
|
||||
}
|
||||
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
|
||||
Ok(datagram) => datagram,
|
||||
// WouldBlock is a readiness false positive; Interrupted
|
||||
@@ -489,7 +423,7 @@ impl AsyncClient {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let prepared = match self.prepare_async_open(machine, datagram.received_at) {
|
||||
let prepared = match self.prepare_async_open(machine) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(failure) => {
|
||||
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
|
||||
@@ -508,105 +442,55 @@ impl AsyncClient {
|
||||
pub(crate) fn poll_send_probe(
|
||||
&mut self,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
|
||||
) -> Poll<Result<SendReceipt, SendProbeError>> {
|
||||
if let Err(error) = self.machine.ensure_open() {
|
||||
return Poll::Ready(Err(error));
|
||||
return Poll::Ready(Err(SendProbeError::NotCommitted(error)));
|
||||
}
|
||||
if self.prepared_probe.is_none() {
|
||||
match self.machine.prepare_probe() {
|
||||
Ok(prepared) => self.prepared_probe = prepared,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
Ok(prepared) => self.prepared_probe = Some(prepared),
|
||||
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||
}
|
||||
}
|
||||
if self.prepared_probe.is_none() {
|
||||
return Poll::Ready(Ok(Vec::new()));
|
||||
}
|
||||
|
||||
loop {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_probe_before_writable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match self.socket.poll_send_ready(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {}
|
||||
Poll::Ready(Err(error)) => {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
|
||||
error,
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let test_timestamps = self.test_hooks.take_probe_timestamps();
|
||||
#[cfg(not(test))]
|
||||
let permission_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let permission_at = test_timestamps
|
||||
.map(|timestamps| timestamps.permission_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
let schedule = self
|
||||
.schedule
|
||||
.as_mut()
|
||||
.expect("open sessions always have a probe schedule");
|
||||
if !schedule.permit_probe_at(permission_at) {
|
||||
self.prepared_probe = None;
|
||||
return Poll::Ready(Ok(Vec::new()));
|
||||
}
|
||||
let prepared = self
|
||||
.prepared_probe
|
||||
.as_ref()
|
||||
.expect("prepared probe was retained across readiness");
|
||||
let machine_preflight = match self.machine.preflight_probe_commit(prepared) {
|
||||
Ok(preflight) => preflight,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||
};
|
||||
let schedule_commit =
|
||||
match schedule.preflight_caller_commit(machine_preflight.next_packets_sent) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
if let Err(source) = events.try_reserve(1) {
|
||||
return Poll::Ready(Err(ClientError::AllocationFailed {
|
||||
operation: "probe event result",
|
||||
source,
|
||||
}));
|
||||
}
|
||||
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
let scheduled_at = schedule_commit.scheduled_at;
|
||||
|
||||
// Private pre-send anchor: finalizes all fallible commit work
|
||||
// (timeout deadline arithmetic, kernel TX lower bound) before
|
||||
// the send attempt. A WouldBlock retry samples a fresh one on
|
||||
// the next loop iteration; only the successful attempt's anchor
|
||||
// is retained (via `machine_commit`/`commit_probe_sent` below).
|
||||
#[cfg(not(test))]
|
||||
let send_anchor = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let send_anchor = test_timestamps
|
||||
.map(|timestamps| timestamps.send_anchor)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let machine_commit = match self
|
||||
.machine
|
||||
.finalize_probe_commit(machine_preflight, send_anchor)
|
||||
{
|
||||
Ok(commit) => commit,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let send_call_start = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_call_start = test_timestamps
|
||||
.map(|timestamps| timestamps.send_call_start)
|
||||
.unwrap_or_else(Instant::now);
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, &prepared.bytes);
|
||||
#[cfg(not(test))]
|
||||
let send_result = self.socket.try_send(&prepared.bytes);
|
||||
let bytes = match send_result {
|
||||
Ok(bytes) => bytes,
|
||||
// No sent_at measurement, no machine commit, no schedule
|
||||
// commit: the transaction is fully discarded and retried
|
||||
// No measurement or machine commit: retry the transaction
|
||||
// with a fresh send_anchor on the next loop iteration.
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
self.machine.invalidate_kernel_tx_correlation();
|
||||
@@ -614,47 +498,43 @@ impl AsyncClient {
|
||||
}
|
||||
Err(error) => {
|
||||
self.machine.invalidate_kernel_tx_correlation();
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
|
||||
error,
|
||||
))));
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
let send_finished_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_finished_at = test_timestamps
|
||||
.map(|timestamps| timestamps.send_finished_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
// Public post-send measurement, captured only after the
|
||||
// successful send above. The subsequent machine/schedule commit
|
||||
// successful send above. The subsequent machine commit
|
||||
// is infallible.
|
||||
#[cfg(not(test))]
|
||||
let sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let sent_at = test_timestamps
|
||||
.map(|timestamps| timestamps.sent_at)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let sent = self
|
||||
.machine
|
||||
.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||
schedule.commit(schedule_commit);
|
||||
self.prepared_probe = None;
|
||||
|
||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
|
||||
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
if let Err(error) = self.drain_tx_timestamps() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
events.push(echo_sent_event(
|
||||
self.remote,
|
||||
sent,
|
||||
|
||||
let receipt = SendReceipt {
|
||||
seq: sent.seq,
|
||||
remote: self.remote,
|
||||
sent_at: sent.sent_at,
|
||||
bytes: sent.bytes,
|
||||
send_call,
|
||||
scheduled_at,
|
||||
timer_error,
|
||||
));
|
||||
return Poll::Ready(Ok(events));
|
||||
};
|
||||
if let Err(source) = validate_datagram_length(expected_bytes, bytes) {
|
||||
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||
receipt,
|
||||
source: Box::new(source),
|
||||
}));
|
||||
}
|
||||
if let Err(source) = self.drain_tx_timestamps() {
|
||||
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||
receipt,
|
||||
source: Box::new(source),
|
||||
}));
|
||||
}
|
||||
return Poll::Ready(Ok(receipt));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,17 +554,7 @@ impl AsyncClient {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.receive_would_block.get() > 0 {
|
||||
self.test_hooks
|
||||
.receive_would_block
|
||||
.set(self.test_hooks.receive_would_block.get() - 1);
|
||||
continue;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.take_injected_interrupted() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
|
||||
Ok(datagram) => datagram,
|
||||
// See `RECV_POLL_WORK_BUDGET`: both a readiness false
|
||||
@@ -718,9 +588,8 @@ impl AsyncClient {
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
if self.machine.is_peer_closed() {
|
||||
self.schedule = None;
|
||||
self.prepared_probe = None;
|
||||
if self.clear_peer_close_dscp().is_ok() {
|
||||
if clear_dscp_on_tokio_socket(&self.socket, self.remote).is_ok() {
|
||||
self.applied_traffic_class = None;
|
||||
}
|
||||
}
|
||||
@@ -753,10 +622,6 @@ impl AsyncClient {
|
||||
let previous_traffic_class = self.applied_traffic_class;
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_close_before_writable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match self.socket.poll_send_ready(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {}
|
||||
@@ -764,19 +629,11 @@ impl AsyncClient {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
}
|
||||
if let Err(error) = self.clear_close_dscp() {
|
||||
if let Err(error) = clear_dscp_on_tokio_socket(&self.socket, self.remote) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
let mut rollback = DscpRollback::armed(
|
||||
&self.socket,
|
||||
self.remote,
|
||||
previous_traffic_class,
|
||||
#[cfg(test)]
|
||||
&self.test_hooks.fail_dscp_restore,
|
||||
);
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, prepared.bytes);
|
||||
#[cfg(not(test))]
|
||||
let mut rollback =
|
||||
DscpRollback::armed(&self.socket, self.remote, previous_traffic_class);
|
||||
let send_result = self.socket.try_send(prepared.bytes);
|
||||
let bytes = match send_result {
|
||||
Ok(bytes) => bytes,
|
||||
@@ -784,28 +641,18 @@ impl AsyncClient {
|
||||
if let Err(error) = rollback.restore() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_close_after_would_block.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
let close_sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let close_sent_at = self
|
||||
.test_hooks
|
||||
.close_sent_at
|
||||
.take()
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
|
||||
let event = self
|
||||
.machine
|
||||
.commit_local_close(prepared.commit, close_sent_at);
|
||||
rollback.disarm();
|
||||
self.schedule = None;
|
||||
self.prepared_probe = None;
|
||||
self.applied_traffic_class = None;
|
||||
|
||||
@@ -817,33 +664,21 @@ impl AsyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the next scheduled probe deadline.
|
||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
||||
if !self.machine.is_open() {
|
||||
return None;
|
||||
}
|
||||
self.schedule.as_ref()?.next_send_deadline()
|
||||
}
|
||||
|
||||
pub(crate) fn probe_interval(&self) -> Option<Duration> {
|
||||
if !self.machine.is_open() {
|
||||
return None;
|
||||
}
|
||||
self.schedule.as_ref().map(ProbeSchedule::interval)
|
||||
}
|
||||
|
||||
/// Return the configured local probe timeout.
|
||||
pub fn probe_timeout(&self) -> Duration {
|
||||
self.machine.probe_timeout()
|
||||
}
|
||||
|
||||
/// Return whether the current run has completed.
|
||||
pub fn is_run_complete(&self) -> bool {
|
||||
self.machine.is_terminal()
|
||||
|| self
|
||||
.schedule
|
||||
.as_ref()
|
||||
.is_some_and(|schedule| schedule.is_finished() && self.machine.pending_is_empty())
|
||||
/// Accepted semantics and exact peer parameters of the currently live session.
|
||||
///
|
||||
/// Returns `None` before Open, after Close, and after no-test completion.
|
||||
pub fn negotiation(&self) -> Option<&crate::NegotiationResult> {
|
||||
self.machine.negotiation()
|
||||
}
|
||||
|
||||
/// Whether any sent probes still await a reply or timeout.
|
||||
pub fn has_pending_probes(&self) -> bool {
|
||||
!self.machine.pending_is_empty()
|
||||
}
|
||||
|
||||
/// Return whether an authenticated peer close ended the session.
|
||||
@@ -859,7 +694,8 @@ impl AsyncClient {
|
||||
self.machine.packets_sent()
|
||||
}
|
||||
|
||||
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
/// Earliest timeout deadline among probes still awaiting a reply.
|
||||
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.machine.next_probe_timeout_deadline()
|
||||
}
|
||||
|
||||
@@ -871,81 +707,24 @@ impl AsyncClient {
|
||||
self.prepared_probe = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn retain_open_cleanup_once(&self) {
|
||||
self.test_hooks.fail_open_dscp.set(true);
|
||||
self.test_hooks.pause_open_cleanup_before_send.set(true);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn poll_recv_ready_for_test(&self, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.socket.poll_recv_ready(cx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn remove_pending_for_test(
|
||||
&mut self,
|
||||
wire_seq: u32,
|
||||
) -> Option<crate::probe::PendingProbe> {
|
||||
self.machine.remove_pending_for_test(wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn replace_pending_for_test(&mut self, probe: crate::probe::PendingProbe) {
|
||||
self.machine.replace_pending_for_test(probe);
|
||||
}
|
||||
|
||||
pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
|
||||
let Some(schedule) = self.schedule.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(deadline) = schedule.next_send_deadline() else {
|
||||
return Ok(());
|
||||
};
|
||||
if deadline > now {
|
||||
return Ok(());
|
||||
}
|
||||
let commit = schedule.preflight_managed_commit(deadline, now)?;
|
||||
schedule.commit(commit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_async_open(
|
||||
&mut self,
|
||||
machine: PreparedOpenAcceptance,
|
||||
opened_at: ClientTimestamp,
|
||||
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
|
||||
let Some(negotiated) = machine.normal_negotiated() else {
|
||||
return Ok(PreparedAsyncOpen {
|
||||
machine,
|
||||
schedule: None,
|
||||
recv_buffer_len: None,
|
||||
negotiated_traffic_class: None,
|
||||
});
|
||||
};
|
||||
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(primary) => {
|
||||
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
||||
}
|
||||
};
|
||||
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
|
||||
Ok(size) => size,
|
||||
Err(primary) => {
|
||||
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
||||
}
|
||||
};
|
||||
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
|
||||
Ok(traffic_class) => traffic_class,
|
||||
Err(_) => {
|
||||
return Err(Box::new(PreparedAsyncOpenFailure {
|
||||
primary: ClientError::InvalidConfig {
|
||||
reason: "negotiated dscp must be in range 0..=255".to_owned(),
|
||||
},
|
||||
machine,
|
||||
}));
|
||||
}
|
||||
};
|
||||
let negotiated_traffic_class = negotiated.accepted.dscp << 2;
|
||||
let additional = recv_buffer_len.saturating_sub(self.recv_buffer.len());
|
||||
if let Err(source) = self.recv_buffer.try_reserve(additional) {
|
||||
return Err(Box::new(PreparedAsyncOpenFailure {
|
||||
@@ -956,17 +735,7 @@ impl AsyncClient {
|
||||
machine,
|
||||
}));
|
||||
}
|
||||
#[cfg(test)]
|
||||
let dscp_result = if self.test_hooks.fail_open_dscp.replace(false) {
|
||||
Err(ClientError::SocketOption {
|
||||
operation: "set negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected negotiated DSCP failure"),
|
||||
})
|
||||
} else {
|
||||
apply_traffic_class_to_tokio_socket(&self.socket, self.remote, negotiated_traffic_class)
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
|
||||
let dscp_result = apply_traffic_class_to_tokio_socket(
|
||||
&self.socket,
|
||||
self.remote,
|
||||
@@ -979,7 +748,6 @@ impl AsyncClient {
|
||||
|
||||
Ok(PreparedAsyncOpen {
|
||||
machine,
|
||||
schedule: Some(schedule),
|
||||
recv_buffer_len: Some(recv_buffer_len),
|
||||
negotiated_traffic_class: Some(negotiated_traffic_class),
|
||||
})
|
||||
@@ -990,7 +758,7 @@ impl AsyncClient {
|
||||
self.recv_buffer.resize(recv_buffer_len, 0);
|
||||
}
|
||||
let outcome = self.machine.commit_open(prepared.machine);
|
||||
self.schedule = prepared.schedule;
|
||||
|
||||
self.applied_traffic_class = prepared.negotiated_traffic_class;
|
||||
self.prepared_open = None;
|
||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||
@@ -1023,23 +791,6 @@ impl AsyncClient {
|
||||
else {
|
||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
if self
|
||||
.test_hooks
|
||||
.pause_open_cleanup_before_send
|
||||
.replace(false)
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_cleanup_send.replace(false) {
|
||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
||||
}
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, packet);
|
||||
#[cfg(not(test))]
|
||||
let send_result = self.socket.try_send(packet);
|
||||
match send_result {
|
||||
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
||||
@@ -1061,22 +812,6 @@ impl AsyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_peer_close_dscp(&self) -> Result<(), ClientError> {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_peer_close_dscp.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "clear DSCP before close",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected peer-close DSCP cleanup failure"),
|
||||
});
|
||||
}
|
||||
clear_dscp_on_tokio_socket(&self.socket, self.remote)
|
||||
}
|
||||
|
||||
fn clear_close_dscp(&self) -> Result<(), ClientError> {
|
||||
clear_dscp_on_tokio_socket(&self.socket, self.remote)
|
||||
}
|
||||
|
||||
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
||||
let _ = match previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
@@ -1092,8 +827,6 @@ struct DscpRollback<'a> {
|
||||
remote: SocketAddr,
|
||||
previous_traffic_class: Option<u8>,
|
||||
armed: bool,
|
||||
#[cfg(test)]
|
||||
fail_restore: &'a Cell<bool>,
|
||||
}
|
||||
|
||||
impl<'a> DscpRollback<'a> {
|
||||
@@ -1101,15 +834,12 @@ impl<'a> DscpRollback<'a> {
|
||||
socket: &'a tokio::net::UdpSocket,
|
||||
remote: SocketAddr,
|
||||
previous_traffic_class: Option<u8>,
|
||||
#[cfg(test)] fail_restore: &'a Cell<bool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
remote,
|
||||
previous_traffic_class,
|
||||
armed: true,
|
||||
#[cfg(test)]
|
||||
fail_restore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1117,14 +847,7 @@ impl<'a> DscpRollback<'a> {
|
||||
if !self.armed {
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.fail_restore.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "restore negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected DSCP restore failure"),
|
||||
});
|
||||
}
|
||||
|
||||
match self.previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
|
||||
@@ -1177,6 +900,3 @@ fn poll_readable_until(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,82 +0,0 @@
|
||||
//! A real `irtt-server` running in-process, for tests that need to prove
|
||||
//! `AsyncClient` and the blocking `Client` are equivalent against a real peer.
|
||||
//!
|
||||
//! This duplicates `crates/irtt-integration-tests/tests/support/in_tree_server.rs`
|
||||
//! rather than depending on it: this module backs private (`super::*`) unit
|
||||
//! tests here, while the integration crate covers black-box client/server
|
||||
//! behavior, and neither can depend on the other's test-only code.
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::mpsc,
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::{Server, ServerConfig};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub struct InTreeServer {
|
||||
pub addr: SocketAddr,
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
|
||||
}
|
||||
|
||||
impl InTreeServer {
|
||||
pub fn start(config: ServerConfig) -> Self {
|
||||
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let done = thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build in-tree test server runtime");
|
||||
runtime.block_on(async move {
|
||||
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
|
||||
let mut server = Server::bind(bind_addr, config).await?;
|
||||
startup_tx
|
||||
.send(server.local_addr()?)
|
||||
.expect("test stopped waiting for in-tree server startup");
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
})
|
||||
});
|
||||
|
||||
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
|
||||
Ok(addr) => addr,
|
||||
Err(error) => {
|
||||
let _ = shutdown_tx.send(());
|
||||
let result = done.join().expect("in-tree test server thread panicked");
|
||||
panic!("in-tree test server failed before startup ({error}): {result:?}");
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
addr,
|
||||
shutdown: Some(shutdown_tx),
|
||||
done: Some(done),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_inner(&mut self) {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(done) = self.done.take() {
|
||||
done.join()
|
||||
.expect("in-tree test server thread panicked")
|
||||
.expect("in-tree test server stopped with an error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InTreeServer {
|
||||
fn drop(&mut self) {
|
||||
self.stop_inner();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
/// Shared HMAC key bytes. Debug output never includes the bytes or a fingerprint.
|
||||
///
|
||||
/// An empty key is still an explicitly configured HMAC key.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct HmacKey(Arc<[u8]>);
|
||||
|
||||
impl HmacKey {
|
||||
/// Store key bytes without interpreting textual key syntax.
|
||||
pub fn new(bytes: impl Into<Arc<[u8]>>) -> Self {
|
||||
Self(bytes.into())
|
||||
}
|
||||
|
||||
/// Explicitly borrow the key material for authentication.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HmacKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("HmacKey([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete authentication for a low-level client session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Authentication {
|
||||
/// Send and accept packets without HMAC authentication.
|
||||
#[default]
|
||||
Unauthenticated,
|
||||
/// Authenticate packets with this key, including when it is empty.
|
||||
Hmac(HmacKey),
|
||||
}
|
||||
|
||||
impl Authentication {
|
||||
pub(crate) fn hmac_key(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
Self::Unauthenticated => None,
|
||||
Self::Hmac(key) => Some(key.as_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
-466
@@ -4,43 +4,24 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::cell::Cell;
|
||||
|
||||
#[cfg(test)]
|
||||
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, RecvBudget},
|
||||
error::ClientError,
|
||||
event::{ClientEvent, OpenOutcome},
|
||||
receive::{drain_tx_timestamps, recv_datagram, try_enable_tx_timestamping, ReceivedDatagram},
|
||||
session::machine::{
|
||||
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, ProbeSent,
|
||||
SessionMachine, MAX_OPEN_PACKET_SIZE,
|
||||
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, SessionMachine,
|
||||
MAX_OPEN_PACKET_SIZE,
|
||||
},
|
||||
socket::{connect_udp_socket, resolve_remote, validate_open_timeouts},
|
||||
socket::{connect_udp_socket, resolve_remote},
|
||||
socket_options::{apply_traffic_class_to_socket, clear_dscp_on_socket},
|
||||
timing::ClientTimestamp,
|
||||
SendProbeError, SendReceipt,
|
||||
};
|
||||
|
||||
pub(crate) mod schedule;
|
||||
|
||||
use schedule::{instant_abs_diff, ProbeSchedule};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ProbeScheduleMode {
|
||||
CallerPaced,
|
||||
#[cfg(test)]
|
||||
Managed {
|
||||
scheduled_at: Instant,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedClientOpen {
|
||||
machine: PreparedOpenAcceptance,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
recv_buffer_len: Option<usize>,
|
||||
negotiated_traffic_class: Option<u8>,
|
||||
previous_traffic_class: Option<u8>,
|
||||
@@ -53,63 +34,44 @@ struct PreparedClientOpenFailure {
|
||||
machine: PreparedOpenAcceptance,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
struct ClientTestHooks {
|
||||
fail_open_dscp: Cell<bool>,
|
||||
fail_open_timeout_restore: Cell<bool>,
|
||||
fail_close_event_reserve: Cell<bool>,
|
||||
fail_close_dscp_clear: Cell<bool>,
|
||||
fail_close_send: Cell<bool>,
|
||||
close_reported_len: Cell<Option<usize>>,
|
||||
close_send_attempts: Cell<usize>,
|
||||
close_sent_at: Cell<Option<ClientTimestamp>>,
|
||||
fail_cleanup_send: Cell<bool>,
|
||||
recv_buffer_len_override: Cell<Option<usize>>,
|
||||
fail_dscp_restore: Cell<bool>,
|
||||
last_restored_read_timeout: Cell<Option<Duration>>,
|
||||
prepared_active_session_before_dscp: Cell<bool>,
|
||||
/// Number of subsequent receive attempts that should report
|
||||
/// `io::ErrorKind::Interrupted` before a real receive is attempted.
|
||||
/// Decremented on every injected receive; `0` means inject nothing.
|
||||
inject_recv_interrupted: Cell<usize>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct ProbeSendTimestamps {
|
||||
pub(crate) permission_at: Instant,
|
||||
/// Private pre-send anchor: timeout deadline / kernel TX lower bound.
|
||||
pub(crate) send_anchor: ClientTimestamp,
|
||||
pub(crate) send_call_start: Instant,
|
||||
pub(crate) send_finished_at: Instant,
|
||||
/// Public post-send measurement: RTT / OWD fallback / timer_error.
|
||||
pub(crate) sent_at: ClientTimestamp,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::{
|
||||
session::machine::{
|
||||
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
|
||||
unix_epoch_ns_i64, update_highest_received,
|
||||
},
|
||||
session::negotiate_params,
|
||||
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
|
||||
};
|
||||
|
||||
/// Low-level synchronous IRTT client.
|
||||
///
|
||||
/// `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 the unified managed API in
|
||||
/// [`crate::managed`] when the `tokio` feature is enabled.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// `Client` is the runtime-free adapter. The caller drives receives and timeout
|
||||
/// polling between sends; see the standalone example for a complete loop.
|
||||
/// This example type-checks without contacting a server:
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{Client, ClientConfig};
|
||||
///
|
||||
/// # fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut client = Client::connect("127.0.0.1:2112", ClientConfig::default())?;
|
||||
/// let outcome = client.open()?;
|
||||
/// println!("opened: {outcome:?}");
|
||||
/// let sent = client.send_probe()?;
|
||||
/// println!("sent: {sent:?}");
|
||||
/// let received = client.recv_once()?;
|
||||
/// println!("received: {received:?}");
|
||||
/// let timed_out = client.poll_timeouts()?;
|
||||
/// println!("timeouts: {timed_out:?}");
|
||||
/// let closed = client.close()?;
|
||||
/// println!("closed: {closed:?}");
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
runtime: SessionMachine,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
socket: UdpSocket,
|
||||
remote: SocketAddr,
|
||||
recv_buffer: Vec<u8>,
|
||||
recv_timeout: Option<Duration>,
|
||||
applied_traffic_class: Option<u8>,
|
||||
/// Whether the socket's `SO_TIMESTAMPING` flags carry `TX_SOFTWARE` +
|
||||
/// `OPT_ID` + `OPT_TSONLY`. Always `false` off Linux, without the
|
||||
@@ -118,52 +80,56 @@ pub struct Client {
|
||||
/// unnecessary error-queue drains are skipped once it is known there is
|
||||
/// nothing to drain.
|
||||
tx_timestamping_enabled: bool,
|
||||
#[cfg(test)]
|
||||
test_hooks: ClientTestHooks,
|
||||
#[cfg(test)]
|
||||
probe_reported_len: Option<usize>,
|
||||
#[cfg(test)]
|
||||
probe_send_error: bool,
|
||||
#[cfg(test)]
|
||||
probe_send_timestamps: Option<ProbeSendTimestamps>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Resolve the configured server and create a connected UDP socket.
|
||||
/// Resolve the endpoint 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)?;
|
||||
let remote = resolve_remote(&config)?;
|
||||
///
|
||||
/// The endpoint accepts a name or address; an omitted port defaults to 2112.
|
||||
/// IPv6 literals may be bracketed or unbracketed with the default port.
|
||||
/// The reusable config contains no endpoint identity.
|
||||
pub fn connect(endpoint: impl AsRef<str>, config: ClientConfig) -> Result<Self, ClientError> {
|
||||
config.open.validate()?;
|
||||
let remote = resolve_remote(endpoint.as_ref(), config.address_family)?;
|
||||
let runtime = SessionMachine::new(config.clone(), remote)?;
|
||||
let socket = connect_udp_socket(&config.socket_config, remote)?;
|
||||
let socket = connect_udp_socket(&config.socket, remote, config.address_family)?;
|
||||
|
||||
Ok(Self {
|
||||
runtime,
|
||||
schedule: None,
|
||||
socket,
|
||||
remote,
|
||||
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
||||
recv_timeout: None,
|
||||
applied_traffic_class: None,
|
||||
tx_timestamping_enabled: false,
|
||||
#[cfg(test)]
|
||||
test_hooks: ClientTestHooks::default(),
|
||||
#[cfg(test)]
|
||||
probe_reported_len: None,
|
||||
#[cfg(test)]
|
||||
probe_send_error: false,
|
||||
#[cfg(test)]
|
||||
probe_send_timestamps: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the blocking receive timeout used outside open attempts.
|
||||
///
|
||||
/// `None` (the default) lets `recv_once()` block indefinitely. A nonzero
|
||||
/// timeout bounds one logical receive, including retries after EINTR.
|
||||
/// `recv_available()` retains its bounded drain behavior. Open attempts
|
||||
/// use only [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts) and
|
||||
/// restore this setting on success or failure.
|
||||
///
|
||||
/// A zero duration is rejected by the socket; the previous setting is
|
||||
/// retained when applying the new timeout fails.
|
||||
pub fn set_recv_timeout(&mut self, timeout: Option<Duration>) -> Result<(), ClientError> {
|
||||
self.socket.set_read_timeout(timeout)?;
|
||||
self.recv_timeout = timeout;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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`]. Malformed or
|
||||
/// Open attempts use [`OpenPolicy::timeouts`](crate::OpenPolicy::timeouts). Malformed or
|
||||
/// unrelated datagrams are ignored until the current attempt's absolute
|
||||
/// deadline, so one attempt may consume several datagrams without
|
||||
/// retransmitting. Silence or ignored traffic eventually produces
|
||||
@@ -178,8 +144,7 @@ impl Client {
|
||||
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||
let result = self.open_transaction();
|
||||
if result.is_err() {
|
||||
let _ =
|
||||
self.restore_open_read_timeout(self.runtime.config().socket_config.recv_timeout);
|
||||
let _ = self.socket.set_read_timeout(self.recv_timeout);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -189,19 +154,11 @@ impl Client {
|
||||
/// The close event means the client has sent its close packet and stopped
|
||||
/// tracking the session locally; it is not a server acknowledgement. If the
|
||||
/// send fails, the negotiated DSCP is restored best-effort and the session
|
||||
/// and probe schedule remain open.
|
||||
/// remains open.
|
||||
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
let prepared = self.runtime.prepare_close()?;
|
||||
let mut events = Vec::new();
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_close_event_reserve.replace(false) {
|
||||
events
|
||||
.try_reserve(usize::MAX)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "close event result",
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
events
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
@@ -212,94 +169,105 @@ impl Client {
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
self.clear_close_dscp()?;
|
||||
let bytes = match self.send_close_datagram(prepared.bytes) {
|
||||
let bytes = match self.socket.send(prepared.bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
self.restore_dscp_best_effort(previous_traffic_class);
|
||||
return Err(ClientError::Socket(err));
|
||||
}
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let sent_at = self
|
||||
.test_hooks
|
||||
.close_sent_at
|
||||
.take()
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
|
||||
self.schedule = None;
|
||||
self.applied_traffic_class = None;
|
||||
|
||||
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
|
||||
|
||||
self.applied_traffic_class = None;
|
||||
validate_datagram_length(expected_bytes, bytes)?;
|
||||
events.push(event);
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Return the monotonic deadline for the next probe send, if another probe
|
||||
/// is scheduled.
|
||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
||||
if !self.runtime.is_open() {
|
||||
return None;
|
||||
}
|
||||
self.schedule.as_ref()?.next_send_deadline()
|
||||
}
|
||||
|
||||
/// Return the local timeout used to classify pending probes as lost.
|
||||
pub fn probe_timeout(&self) -> Duration {
|
||||
self.runtime.probe_timeout()
|
||||
}
|
||||
|
||||
/// Send one echo probe if the negotiated run is still active.
|
||||
/// Send one echo probe now. The caller owns pacing and run duration.
|
||||
///
|
||||
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
|
||||
/// 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_transaction(ProbeScheduleMode::CallerPaced)
|
||||
/// The negotiated interval and duration do not gate low-level sends.
|
||||
/// A receipt means socket acceptance and infallible session commitment have
|
||||
/// completed. Failures before acceptance return [`SendProbeError::NotCommitted`];
|
||||
/// later validation or TX processing failures preserve the receipt in
|
||||
/// [`SendProbeError::AfterCommit`].
|
||||
pub fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
|
||||
let remote = self.remote;
|
||||
let (runtime, socket) = (&mut self.runtime, &self.socket);
|
||||
let prepared = runtime
|
||||
.prepare_probe()
|
||||
.map_err(SendProbeError::NotCommitted)?;
|
||||
let machine_preflight = runtime
|
||||
.preflight_probe_commit(&prepared)
|
||||
.map_err(SendProbeError::NotCommitted)?;
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
||||
// capacity/collision checks already done above) is finalized from
|
||||
// this PRIVATE pre-send anchor, before the socket send. It is not
|
||||
// the public measurement `sent_at` captured further below.
|
||||
let send_anchor = ClientTimestamp::now();
|
||||
let machine_commit = runtime
|
||||
.finalize_probe_commit(machine_preflight, send_anchor)
|
||||
.map_err(SendProbeError::NotCommitted)?;
|
||||
let send_call_start = Instant::now();
|
||||
|
||||
let bytes = match socket.send(&prepared.bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(SendProbeError::NotCommitted(ClientError::Socket(error)));
|
||||
}
|
||||
};
|
||||
let send_finished_at = Instant::now();
|
||||
|
||||
// The public measurement timestamp: paired wall/monotonic sample
|
||||
// captured immediately after the successful socket send completed.
|
||||
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
|
||||
// endpoint. The infallible machine commit below never fails.
|
||||
let sent_at = ClientTimestamp::now();
|
||||
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||
let receipt = SendReceipt {
|
||||
seq: sent.seq,
|
||||
remote,
|
||||
sent_at: sent.sent_at,
|
||||
bytes: sent.bytes,
|
||||
send_call,
|
||||
};
|
||||
validate_datagram_length(expected_bytes, bytes).map_err(|source| {
|
||||
SendProbeError::AfterCommit {
|
||||
receipt,
|
||||
source: Box::new(source),
|
||||
}
|
||||
})?;
|
||||
self.drain_tx_timestamps()
|
||||
.map_err(|source| SendProbeError::AfterCommit {
|
||||
receipt,
|
||||
source: Box::new(source),
|
||||
})?;
|
||||
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Requires an open session; lifecycle errors are returned before socket I/O.
|
||||
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.socket
|
||||
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
|
||||
self.runtime.ensure_open()?;
|
||||
self.socket.set_read_timeout(self.recv_timeout)?;
|
||||
self.recv_once_inner()
|
||||
}
|
||||
|
||||
/// Receive one datagram into `self.recv_buffer`. In test builds, a
|
||||
/// pending `inject_recv_interrupted` count reports
|
||||
/// `io::ErrorKind::Interrupted` instead of performing a real receive,
|
||||
/// deterministically exercising interrupted-syscall retry behavior.
|
||||
fn recv_datagram_once(&mut self) -> Result<ReceivedDatagram, io::Error> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let remaining = self.test_hooks.inject_recv_interrupted.get();
|
||||
if remaining > 0 {
|
||||
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
|
||||
return Err(io::Error::from(io::ErrorKind::Interrupted));
|
||||
}
|
||||
}
|
||||
recv_datagram(&self.socket, &mut self.recv_buffer)
|
||||
}
|
||||
|
||||
/// Same as [`Self::recv_datagram_once`], receiving into a caller-supplied
|
||||
/// buffer instead of `self.recv_buffer` (used before the negotiated
|
||||
/// receive buffer size is known, i.e. during Open).
|
||||
fn recv_datagram_once_into(&self, buf: &mut [u8]) -> Result<ReceivedDatagram, io::Error> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let remaining = self.test_hooks.inject_recv_interrupted.get();
|
||||
if remaining > 0 {
|
||||
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
|
||||
return Err(io::Error::from(io::ErrorKind::Interrupted));
|
||||
}
|
||||
}
|
||||
recv_datagram(&self.socket, buf)
|
||||
}
|
||||
|
||||
fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
|
||||
return Ok(vec![]);
|
||||
@@ -315,11 +283,8 @@ impl Client {
|
||||
datagram.received_at,
|
||||
datagram.meta,
|
||||
)?;
|
||||
if self.runtime.is_peer_closed() {
|
||||
self.schedule = None;
|
||||
if self.clear_close_dscp().is_ok() {
|
||||
self.applied_traffic_class = None;
|
||||
}
|
||||
if self.runtime.is_peer_closed() && self.clear_close_dscp().is_ok() {
|
||||
self.applied_traffic_class = None;
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
@@ -340,7 +305,7 @@ impl Client {
|
||||
fn recv_datagram_retrying_interrupted(
|
||||
&mut self,
|
||||
) -> Result<Option<ReceivedDatagram>, ClientError> {
|
||||
let configured_timeout = self.runtime.config().socket_config.recv_timeout;
|
||||
let configured_timeout = self.recv_timeout;
|
||||
let deadline = match configured_timeout {
|
||||
Some(timeout) => Some(
|
||||
Instant::now()
|
||||
@@ -352,7 +317,7 @@ impl Client {
|
||||
let mut timeout_narrowed = false;
|
||||
|
||||
let result = loop {
|
||||
match self.recv_datagram_once() {
|
||||
match recv_datagram(&self.socket, &mut self.recv_buffer) {
|
||||
Ok(datagram) => break Ok(Some(datagram)),
|
||||
Err(err)
|
||||
if matches!(
|
||||
@@ -391,9 +356,11 @@ impl Client {
|
||||
|
||||
/// Receive and classify datagrams until a receive produces no events or the
|
||||
/// receive budget is exhausted.
|
||||
///
|
||||
/// Requires an open session; lifecycle errors are returned before socket I/O.
|
||||
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.socket
|
||||
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
|
||||
self.runtime.ensure_open()?;
|
||||
self.socket.set_read_timeout(self.recv_timeout)?;
|
||||
let mut all_events = Vec::new();
|
||||
for _ in 0..budget.max_packets {
|
||||
let events = self.recv_once_inner()?;
|
||||
@@ -425,17 +392,21 @@ impl Client {
|
||||
self.runtime.poll_timeouts_at(now)
|
||||
}
|
||||
|
||||
/// Return whether the current run has completed.
|
||||
/// Accepted semantics and exact peer parameters of the currently live session.
|
||||
///
|
||||
/// 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 {
|
||||
self.runtime.is_terminal()
|
||||
|| self
|
||||
.schedule
|
||||
.as_ref()
|
||||
.is_some_and(|schedule| schedule.is_finished() && self.runtime.pending_is_empty())
|
||||
/// Returns `None` before Open, after Close, and after no-test completion.
|
||||
pub fn negotiation(&self) -> Option<&crate::NegotiationResult> {
|
||||
self.runtime.negotiation()
|
||||
}
|
||||
|
||||
/// Earliest timeout deadline among probes still awaiting a reply.
|
||||
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.runtime.next_probe_timeout_deadline()
|
||||
}
|
||||
|
||||
/// Whether any sent probes still await a reply or timeout.
|
||||
pub fn has_pending_probes(&self) -> bool {
|
||||
!self.runtime.pending_is_empty()
|
||||
}
|
||||
|
||||
/// Return whether the session was closed by a peer close-flagged reply.
|
||||
@@ -448,137 +419,13 @@ impl Client {
|
||||
self.runtime.is_peer_closed()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn send_managed_probe(
|
||||
&mut self,
|
||||
scheduled_at: Instant,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.send_probe_transaction(ProbeScheduleMode::Managed { scheduled_at })
|
||||
}
|
||||
|
||||
fn send_probe_transaction(
|
||||
&mut self,
|
||||
mode: ProbeScheduleMode,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.runtime.ensure_open()?;
|
||||
#[cfg(test)]
|
||||
let test_timestamps = self.probe_send_timestamps.take();
|
||||
#[cfg(test)]
|
||||
let fail_send = std::mem::take(&mut self.probe_send_error);
|
||||
let remote = self.remote;
|
||||
let (runtime, schedule, socket) = (&mut self.runtime, &mut self.schedule, &self.socket);
|
||||
let schedule = schedule
|
||||
.as_mut()
|
||||
.expect("open sessions always have a probe schedule");
|
||||
#[cfg(not(test))]
|
||||
let permission_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let permission_at = test_timestamps
|
||||
.map(|timestamps| timestamps.permission_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
if !schedule.permit_probe_at(permission_at) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let Some(prepared) = runtime.prepare_probe()? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
|
||||
let schedule_commit = match mode {
|
||||
ProbeScheduleMode::CallerPaced => {
|
||||
schedule.preflight_caller_commit(machine_preflight.next_packets_sent)?
|
||||
}
|
||||
#[cfg(test)]
|
||||
ProbeScheduleMode::Managed { scheduled_at } => {
|
||||
schedule.preflight_managed_commit(scheduled_at, permission_at)?
|
||||
}
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
events
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "probe event result",
|
||||
source,
|
||||
})?;
|
||||
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
let scheduled_at = schedule_commit.scheduled_at;
|
||||
#[cfg(test)]
|
||||
let reported_bytes = self.probe_reported_len.take();
|
||||
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
||||
// capacity/collision checks already done above) is finalized from
|
||||
// this PRIVATE pre-send anchor, before the socket send. It is not
|
||||
// the public measurement `sent_at` captured further below.
|
||||
#[cfg(not(test))]
|
||||
let send_anchor = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let send_anchor = test_timestamps
|
||||
.map(|timestamps| timestamps.send_anchor)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
|
||||
#[cfg(not(test))]
|
||||
let send_call_start = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_call_start = test_timestamps
|
||||
.map(|timestamps| timestamps.send_call_start)
|
||||
.unwrap_or_else(Instant::now);
|
||||
#[cfg(test)]
|
||||
if fail_send {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(ClientError::Socket(io::Error::other(
|
||||
"injected probe send failure",
|
||||
)));
|
||||
}
|
||||
let bytes = match socket.send(&prepared.bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(ClientError::Socket(error));
|
||||
}
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let send_finished_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_finished_at = test_timestamps
|
||||
.map(|timestamps| timestamps.send_finished_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
// The public measurement timestamp: paired wall/monotonic sample
|
||||
// captured immediately after the successful socket send completed.
|
||||
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
|
||||
// endpoint. The infallible machine/schedule commit below never fails.
|
||||
#[cfg(not(test))]
|
||||
let sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let sent_at = test_timestamps
|
||||
.map(|timestamps| timestamps.sent_at)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||
schedule.commit(schedule_commit);
|
||||
|
||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
|
||||
#[cfg(test)]
|
||||
let bytes = reported_bytes.unwrap_or(bytes);
|
||||
validate_datagram_length(expected_bytes, bytes)?;
|
||||
self.drain_tx_timestamps()?;
|
||||
|
||||
events.push(echo_sent_event(
|
||||
remote,
|
||||
sent,
|
||||
send_call,
|
||||
scheduled_at,
|
||||
timer_error,
|
||||
));
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||
let request = self.runtime.prepare_open_request()?;
|
||||
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
|
||||
let attempt_count = self.runtime.config().open_timeouts.len();
|
||||
let attempt_count = self.runtime.config().open.timeouts.len();
|
||||
|
||||
for attempt in 0..attempt_count {
|
||||
let timeout = self.runtime.config().open_timeouts[attempt];
|
||||
let timeout = self.runtime.config().open.timeouts[attempt];
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
@@ -595,7 +442,7 @@ impl Client {
|
||||
}
|
||||
self.socket.set_read_timeout(Some(remaining))?;
|
||||
|
||||
let datagram = match self.recv_datagram_once_into(&mut buf) {
|
||||
let datagram = match recv_datagram(&self.socket, &mut buf) {
|
||||
Ok(datagram) => datagram,
|
||||
// WouldBlock/TimedOut mean the attempt's own deadline
|
||||
// elapsed; Interrupted (EINTR) means the syscall was
|
||||
@@ -638,7 +485,7 @@ impl Client {
|
||||
return Err(failure.primary);
|
||||
}
|
||||
};
|
||||
let prepared = match self.prepare_client_open(machine, datagram.received_at) {
|
||||
let prepared = match self.prepare_client_open(machine) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(failure) => {
|
||||
self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
|
||||
@@ -655,51 +502,28 @@ impl Client {
|
||||
fn prepare_client_open(
|
||||
&self,
|
||||
machine: PreparedOpenAcceptance,
|
||||
opened_at: ClientTimestamp,
|
||||
) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
|
||||
let Some(negotiated) = machine.normal_negotiated() else {
|
||||
return Ok(PreparedClientOpen {
|
||||
machine,
|
||||
schedule: None,
|
||||
recv_buffer_len: None,
|
||||
negotiated_traffic_class: None,
|
||||
previous_traffic_class: self.applied_traffic_class,
|
||||
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
|
||||
post_open_recv_timeout: self.recv_timeout,
|
||||
});
|
||||
};
|
||||
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
|
||||
};
|
||||
let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
|
||||
Ok(size) => size,
|
||||
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
|
||||
};
|
||||
#[cfg(test)]
|
||||
let recv_buffer_len = self
|
||||
.test_hooks
|
||||
.recv_buffer_len_override
|
||||
.get()
|
||||
.unwrap_or(recv_buffer_len);
|
||||
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
|
||||
Ok(traffic_class) => traffic_class,
|
||||
Err(_) => {
|
||||
return Err(Box::new(PreparedClientOpenFailure {
|
||||
primary: ClientError::InvalidConfig {
|
||||
reason: "negotiated dscp must be in range 0..=255".to_owned(),
|
||||
},
|
||||
machine,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let negotiated_traffic_class = negotiated.accepted.dscp << 2;
|
||||
Ok(PreparedClientOpen {
|
||||
machine,
|
||||
schedule: Some(schedule),
|
||||
recv_buffer_len: Some(recv_buffer_len),
|
||||
negotiated_traffic_class: Some(negotiated_traffic_class),
|
||||
previous_traffic_class: self.applied_traffic_class,
|
||||
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
|
||||
post_open_recv_timeout: self.recv_timeout,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -709,7 +533,6 @@ impl Client {
|
||||
) -> Result<OpenOutcome, ClientError> {
|
||||
let PreparedClientOpen {
|
||||
machine,
|
||||
schedule,
|
||||
recv_buffer_len,
|
||||
negotiated_traffic_class,
|
||||
previous_traffic_class,
|
||||
@@ -730,17 +553,15 @@ impl Client {
|
||||
}
|
||||
self.recv_buffer.resize(recv_buffer_len, 0);
|
||||
|
||||
#[cfg(test)]
|
||||
self.test_hooks
|
||||
.prepared_active_session_before_dscp
|
||||
.set(machine.has_prepared_active_session());
|
||||
if let Err(primary) = self.apply_open_dscp(negotiated_traffic_class) {
|
||||
if let Err(primary) =
|
||||
apply_traffic_class_to_socket(&self.socket, self.remote, negotiated_traffic_class)
|
||||
{
|
||||
self.recv_buffer.truncate(previous_len);
|
||||
self.restore_dscp_best_effort(previous_traffic_class);
|
||||
self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
|
||||
return Err(primary);
|
||||
}
|
||||
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
|
||||
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
|
||||
let primary = ClientError::ReadTimeoutRestore { source };
|
||||
self.recv_buffer.truncate(previous_len);
|
||||
self.restore_dscp_best_effort(previous_traffic_class);
|
||||
@@ -749,17 +570,16 @@ impl Client {
|
||||
}
|
||||
|
||||
let outcome = self.runtime.commit_open(machine);
|
||||
self.schedule = schedule;
|
||||
|
||||
self.applied_traffic_class = Some(negotiated_traffic_class);
|
||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||
Ok(outcome)
|
||||
} else {
|
||||
debug_assert!(schedule.is_none());
|
||||
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
|
||||
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
|
||||
return Err(ClientError::ReadTimeoutRestore { source });
|
||||
}
|
||||
let outcome = self.runtime.commit_open(machine);
|
||||
self.schedule = None;
|
||||
|
||||
self.applied_traffic_class = None;
|
||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||
Ok(outcome)
|
||||
@@ -784,10 +604,6 @@ impl Client {
|
||||
}
|
||||
|
||||
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_dscp_restore.replace(false) {
|
||||
return;
|
||||
}
|
||||
let _ = match previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
|
||||
@@ -797,86 +613,14 @@ impl Client {
|
||||
}
|
||||
|
||||
fn clear_close_dscp(&self) -> Result<(), ClientError> {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_close_dscp_clear.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "clear negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected negotiated DSCP clear failure"),
|
||||
});
|
||||
}
|
||||
clear_dscp_on_socket(&self.socket, self.remote)
|
||||
}
|
||||
|
||||
fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
|
||||
if let Some(packet) = packet {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_cleanup_send.replace(false) {
|
||||
return;
|
||||
}
|
||||
let _ = self.socket.send(packet);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_open_dscp(&self, traffic_class: u8) -> Result<(), ClientError> {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_open_dscp.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "set negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected negotiated DSCP failure"),
|
||||
});
|
||||
}
|
||||
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
|
||||
}
|
||||
|
||||
fn restore_open_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
|
||||
#[cfg(test)]
|
||||
self.test_hooks.last_restored_read_timeout.set(timeout);
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_open_timeout_restore.replace(false) {
|
||||
return Err(io::Error::other(
|
||||
"injected configured read timeout restoration failure",
|
||||
));
|
||||
}
|
||||
self.socket.set_read_timeout(timeout)
|
||||
}
|
||||
|
||||
fn send_close_datagram(&self, packet: &[u8]) -> io::Result<usize> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.test_hooks
|
||||
.close_send_attempts
|
||||
.set(self.test_hooks.close_send_attempts.get() + 1);
|
||||
if self.test_hooks.fail_close_send.replace(false) {
|
||||
return Err(io::Error::other("injected close send failure"));
|
||||
}
|
||||
}
|
||||
let bytes = self.socket.send(packet)?;
|
||||
#[cfg(test)]
|
||||
if let Some(reported) = self.test_hooks.close_reported_len.take() {
|
||||
return Ok(reported);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn echo_sent_event(
|
||||
remote: SocketAddr,
|
||||
sent: ProbeSent,
|
||||
send_call: Duration,
|
||||
scheduled_at: Instant,
|
||||
timer_error: Duration,
|
||||
) -> ClientEvent {
|
||||
ClientEvent::EchoSent {
|
||||
seq: sent.seq,
|
||||
remote,
|
||||
scheduled_at,
|
||||
sent_at: sent.sent_at,
|
||||
bytes: sent.bytes,
|
||||
send_call,
|
||||
timer_error,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result<(), ClientError> {
|
||||
@@ -886,47 +630,3 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result
|
||||
Err(ClientError::DatagramLengthMismatch { expected, actual })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Client {
|
||||
fn send_probe_at(
|
||||
&mut self,
|
||||
timestamps: ProbeSendTimestamps,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.probe_send_timestamps = Some(timestamps);
|
||||
self.send_probe()
|
||||
}
|
||||
|
||||
fn send_managed_probe_at(
|
||||
&mut self,
|
||||
scheduled_at: Instant,
|
||||
timestamps: ProbeSendTimestamps,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.probe_send_timestamps = Some(timestamps);
|
||||
self.send_managed_probe(scheduled_at)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bounded_receive_timeout(
|
||||
deadline: Option<Instant>,
|
||||
configured_timeout: Option<Duration>,
|
||||
max_wait: Duration,
|
||||
now: Instant,
|
||||
) -> Option<Duration> {
|
||||
if max_wait.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let mut timeout = configured_timeout.unwrap_or(max_wait).min(max_wait);
|
||||
if let Some(deadline) = deadline {
|
||||
let remaining = deadline.checked_duration_since(now)?;
|
||||
if remaining.is_zero() {
|
||||
return None;
|
||||
}
|
||||
timeout = timeout.min(remaining);
|
||||
}
|
||||
(!timeout.is_zero()).then_some(timeout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,318 +0,0 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{error::ClientError, session::NegotiatedParams};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProbeSchedule {
|
||||
start_at: Instant,
|
||||
end_at: Option<Instant>,
|
||||
interval: Duration,
|
||||
next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScheduleCommit {
|
||||
pub(crate) scheduled_at: Instant,
|
||||
pub(crate) next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ProbeSchedule {
|
||||
pub(crate) fn new(
|
||||
start_at: Instant,
|
||||
negotiated: &NegotiatedParams,
|
||||
) -> Result<Self, ClientError> {
|
||||
let interval_ns = u64::try_from(negotiated.params.interval_ns)
|
||||
.expect("validated positive negotiated interval");
|
||||
let interval = Duration::from_nanos(interval_ns);
|
||||
let end_at = if negotiated.params.duration_ns > 0 {
|
||||
let duration_ns = u64::try_from(negotiated.params.duration_ns)
|
||||
.expect("validated positive negotiated duration");
|
||||
Some(
|
||||
start_at
|
||||
.checked_add(Duration::from_nanos(duration_ns))
|
||||
.ok_or_else(|| ClientError::NegotiationRejected {
|
||||
reason: "duration is too large to schedule".to_owned(),
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
start_at,
|
||||
end_at,
|
||||
interval,
|
||||
next_send_at: Some(start_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
||||
self.next_send_at
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub(crate) fn interval(&self) -> Duration {
|
||||
self.interval
|
||||
}
|
||||
|
||||
pub(crate) fn permit_probe_at(&mut self, now: Instant) -> bool {
|
||||
if self.next_send_at.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.end_at.is_some_and(|end| now >= end) {
|
||||
self.next_send_at = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_caller_commit(
|
||||
&self,
|
||||
next_packets_sent: u64,
|
||||
) -> Result<ScheduleCommit, ClientError> {
|
||||
let scheduled_at = self
|
||||
.next_send_at
|
||||
.expect("a caller schedule commit requires a permitted probe");
|
||||
let interval_ns =
|
||||
u64::try_from(self.interval.as_nanos()).map_err(|_| ClientError::DurationOverflow)?;
|
||||
let next_send_at = next_probe_deadline(self.start_at, interval_ns, next_packets_sent)?;
|
||||
Ok(self.finish_commit(scheduled_at, next_send_at))
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
pub(crate) fn preflight_managed_commit(
|
||||
&self,
|
||||
scheduled_at: Instant,
|
||||
permission_at: Instant,
|
||||
) -> Result<ScheduleCommit, ClientError> {
|
||||
let (scheduled_at, next_send_at, _) =
|
||||
advance_cadence(scheduled_at, self.interval, permission_at)?;
|
||||
Ok(self.finish_commit(scheduled_at, next_send_at))
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&mut self, commit: ScheduleCommit) {
|
||||
self.next_send_at = commit.next_send_at;
|
||||
}
|
||||
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
self.next_send_at.is_none()
|
||||
}
|
||||
|
||||
fn finish_commit(&self, scheduled_at: Instant, next_send_at: Instant) -> ScheduleCommit {
|
||||
ScheduleCommit {
|
||||
scheduled_at,
|
||||
next_send_at: if self.end_at.is_some_and(|end| next_send_at >= end) {
|
||||
None
|
||||
} else {
|
||||
Some(next_send_at)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_probe_deadline(
|
||||
start: Instant,
|
||||
interval_ns: u64,
|
||||
packets_sent: u64,
|
||||
) -> Result<Instant, ClientError> {
|
||||
let offset_ns = interval_ns
|
||||
.checked_mul(packets_sent)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
start
|
||||
.checked_add(Duration::from_nanos(offset_ns))
|
||||
.ok_or(ClientError::DurationOverflow)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
pub(crate) fn advance_cadence(
|
||||
deadline: Instant,
|
||||
interval: Duration,
|
||||
now: Instant,
|
||||
) -> Result<(Instant, Instant, u128), ClientError> {
|
||||
if interval.is_zero() {
|
||||
return Err(ClientError::InvalidConfig {
|
||||
reason: "probe interval must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed_slots = now
|
||||
.checked_duration_since(deadline)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos() / interval.as_nanos());
|
||||
let scheduled_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(elapsed_slots)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let next_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(
|
||||
elapsed_slots
|
||||
.checked_add(1)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let scheduled_at = deadline
|
||||
.checked_add(scheduled_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
let next_at = deadline
|
||||
.checked_add(next_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
Ok((scheduled_at, next_at, elapsed_slots))
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
|
||||
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
||||
let seconds =
|
||||
u64::try_from(nanos / NANOS_PER_SECOND).map_err(|_| ClientError::DurationOverflow)?;
|
||||
let subsec_nanos = u32::try_from(nanos % NANOS_PER_SECOND)
|
||||
.expect("nanosecond remainder is always less than one second");
|
||||
Ok(Duration::new(seconds, subsec_nanos))
|
||||
}
|
||||
|
||||
pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||
left.checked_duration_since(right)
|
||||
.or_else(|| right.checked_duration_since(left))
|
||||
.unwrap_or(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_proto::Params;
|
||||
|
||||
fn negotiated(interval_ns: i64, duration_ns: i64) -> NegotiatedParams {
|
||||
NegotiatedParams {
|
||||
params: Params {
|
||||
interval_ns,
|
||||
duration_ns,
|
||||
..Params::default()
|
||||
},
|
||||
restrictions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_deadline_is_the_opening_time() {
|
||||
let opened_at = Instant::now();
|
||||
|
||||
let schedule = ProbeSchedule::new(opened_at, &negotiated(10_000_000, 0)).unwrap();
|
||||
|
||||
assert_eq!(schedule.next_send_deadline(), Some(opened_at));
|
||||
assert!(!schedule.is_finished());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_preflight_keeps_absolute_cadence_without_mutation() {
|
||||
let start = Instant::now();
|
||||
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
|
||||
let commit = schedule.preflight_caller_commit(3).unwrap();
|
||||
|
||||
assert_eq!(commit.scheduled_at, start);
|
||||
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(30)));
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_commit_advances_exactly_one_cadence_slot() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
|
||||
let commit = schedule.preflight_caller_commit(1).unwrap();
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
|
||||
schedule.commit(commit);
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(10))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_duration_uses_an_exclusive_end_boundary() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
|
||||
|
||||
let first = schedule.preflight_caller_commit(1).unwrap();
|
||||
schedule.commit(first);
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(10))
|
||||
);
|
||||
|
||||
let second = schedule.preflight_caller_commit(2).unwrap();
|
||||
schedule.commit(second);
|
||||
|
||||
assert!(schedule.is_finished());
|
||||
assert_eq!(schedule.next_send_deadline(), None);
|
||||
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permit_at_exclusive_end_finishes_schedule_without_a_send() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
|
||||
|
||||
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
|
||||
assert!(schedule.is_finished());
|
||||
assert_eq!(schedule.next_send_deadline(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_preflight_skips_missed_slots_without_mutation() {
|
||||
let start = Instant::now();
|
||||
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
let sent_at = start + Duration::from_millis(45);
|
||||
|
||||
let commit = schedule.preflight_managed_commit(start, sent_at).unwrap();
|
||||
|
||||
assert_eq!(commit.scheduled_at, start + Duration::from_millis(40));
|
||||
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(50)));
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_managed_commit_does_not_replay_catch_up_slots() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
let delayed_send = start + Duration::from_millis(45);
|
||||
|
||||
let commit = schedule
|
||||
.preflight_managed_commit(start, delayed_send)
|
||||
.unwrap();
|
||||
schedule.commit(commit);
|
||||
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(50))
|
||||
);
|
||||
assert!(schedule.next_send_deadline().unwrap() > delayed_send);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_arithmetic_overflow_is_detected_before_commit() {
|
||||
let start = Instant::now();
|
||||
let schedule = ProbeSchedule::new(start, &negotiated(2, 0)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
schedule.preflight_caller_commit(u64::MAX),
|
||||
Err(ClientError::DurationOverflow)
|
||||
));
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_error_is_adapter_owned() {
|
||||
let scheduled_at = Instant::now();
|
||||
let sent_at = scheduled_at + Duration::from_millis(7);
|
||||
|
||||
assert_eq!(
|
||||
instant_abs_diff(sent_at, scheduled_at),
|
||||
Duration::from_millis(7)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_config_default() {
|
||||
let config = ClientConfig::default();
|
||||
assert_eq!(config.duration, Some(Duration::from_secs(3)));
|
||||
assert_eq!(config.interval, Duration::from_secs(1));
|
||||
assert_eq!(config.length, 0);
|
||||
assert_eq!(config.received_stats, ReceivedStats::Both);
|
||||
assert_eq!(config.stamp_at, StampAt::Both);
|
||||
assert_eq!(config.clock, Clock::Both);
|
||||
assert_eq!(config.dscp, 0);
|
||||
assert_eq!(config.hmac_key, None);
|
||||
assert_eq!(config.server_fill, None);
|
||||
assert_eq!(config.open_timeouts, DEFAULT_OPEN_TIMEOUTS);
|
||||
assert_eq!(config.run_mode, RunMode::Normal);
|
||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
||||
assert_eq!(config.probe_timeout, Duration::from_secs(4));
|
||||
assert_eq!(config.max_pending_probes, 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_maps_compatibility_fields() {
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_secs(5)),
|
||||
interval: Duration::from_millis(250),
|
||||
length: 1472,
|
||||
received_stats: ReceivedStats::Window,
|
||||
stamp_at: StampAt::Midpoint,
|
||||
clock: Clock::Wall,
|
||||
dscp: 46,
|
||||
hmac_key: Some(b"secret".to_vec()),
|
||||
server_fill: Some("rand".to_owned()),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
|
||||
let params = params_from_config(&config).unwrap();
|
||||
assert_eq!(params.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(params.duration_ns, 5_000_000_000);
|
||||
assert_eq!(params.interval_ns, 250_000_000);
|
||||
assert_eq!(params.length, 1472);
|
||||
assert_eq!(params.received_stats, ReceivedStats::Window);
|
||||
assert_eq!(params.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(params.clock, Clock::Wall);
|
||||
assert_eq!(
|
||||
params.dscp, 184,
|
||||
"config DSCP codepoint 46 must be shifted into raw wire byte 184"
|
||||
);
|
||||
assert_eq!(
|
||||
params.server_fill.as_ref().map(|fill| fill.value.as_str()),
|
||||
Some("rand")
|
||||
);
|
||||
assert_eq!(config.hmac_key.as_deref(), Some(b"secret".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_accepts_boundary_values() {
|
||||
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
|
||||
let config = ClientConfig {
|
||||
length,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
params_from_config(&config).unwrap().length,
|
||||
i64::from(length)
|
||||
);
|
||||
}
|
||||
|
||||
let config = ClientConfig {
|
||||
dscp: 63,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
params_from_config(&config).unwrap().dscp,
|
||||
252,
|
||||
"boundary codepoint 63 must be shifted into raw wire byte 252"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_encodes_continuous_duration_as_zero() {
|
||||
let config = ClientConfig {
|
||||
duration: None,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(params_from_config(&config).unwrap().duration_ns, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_invalid_values() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
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",
|
||||
),
|
||||
(
|
||||
// The unspecified clock is the absent-tag wire default, not a
|
||||
// client mode; a client that needs timestamps must name a clock.
|
||||
"unspecified clock",
|
||||
ClientConfig {
|
||||
clock: Clock::Unspecified,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"clock must be wall, monotonic, or both",
|
||||
),
|
||||
(
|
||||
"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",
|
||||
),
|
||||
];
|
||||
|
||||
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]
|
||||
fn minimum_open_timeout_under_200ms_is_rejected() {
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(199)],
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::OpenTimeoutTooSmall { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_open_timeouts_is_rejected() {
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![],
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::NoOpenTimeouts)
|
||||
));
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
use super::*;
|
||||
use crate::socket_options::socket_traffic_class;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn normal_open_applies_negotiated_traffic_class_after_open_and_close_clears_it() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF): a
|
||||
// compliant server echoes back the raw byte it received, unchanged.
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
0
|
||||
);
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
184
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
||||
0
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn normal_open_uses_negotiated_traffic_class_not_requested_dscp() {
|
||||
let mut returned = default_params();
|
||||
returned.dscp = 0;
|
||||
let server = open_success_server(returned);
|
||||
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let mut client = Client::connect(config).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
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn failed_close_send_restores_negotiated_traffic_class_and_keeps_session_open() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_send.set(true);
|
||||
|
||||
let error = client.close().unwrap_err();
|
||||
|
||||
assert!(matches!(error, ClientError::Socket(_)));
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(184));
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
184
|
||||
);
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_close_keeps_send_error_primary_when_dscp_restoration_also_fails() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_send.set(true);
|
||||
client.test_hooks.fail_dscp_restore.set(true);
|
||||
|
||||
let error = client.close().unwrap_err();
|
||||
|
||||
assert!(matches!(error, ClientError::Socket(_)));
|
||||
assert!(error.to_string().contains("injected close send failure"));
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn authenticated_peer_close_clears_schedule_and_negotiated_traffic_class() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
socket
|
||||
.send_to(
|
||||
&echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[
|
||||
ClientEvent::EchoReply { .. },
|
||||
ClientEvent::SessionClosed { .. }
|
||||
]
|
||||
));
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
||||
0
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hmac_open_success() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (request, peer) = recv_request(&socket, &tx);
|
||||
verify_packet_hmac(&key, &request).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&missing, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&bad, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let mut returned = params_from_config(&config).unwrap();
|
||||
returned.interval_ns += 1;
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let cleanup = &packets[1];
|
||||
assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, cleanup).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_close_packet_includes_valid_hmac() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).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);
|
||||
verify_packet_hmac(&key, close).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(close[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_hmac_reply_is_dropped() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
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[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&ts,
|
||||
Some(&wrong_key),
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
hmac_key: Some(key),
|
||||
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);
|
||||
assert!(matches!(&events[0], ClientEvent::Warning { .. }));
|
||||
assert!(!client.is_peer_closed());
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_echo_request_reply_works() {
|
||||
let key = b"testkey".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
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();
|
||||
verify_packet_hmac(&server_key, &buf[..size]).unwrap();
|
||||
let seq = u32::from_le_bytes(
|
||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields {
|
||||
recv_mono: Some(100),
|
||||
send_mono: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, Some(&server_key));
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
hmac_key: Some(key),
|
||||
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);
|
||||
assert!(matches!(&events[0], ClientEvent::EchoReply { .. }));
|
||||
server.join();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
|
||||
use irtt_proto::{
|
||||
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock,
|
||||
ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
|
||||
};
|
||||
use std::{thread, time::SystemTime};
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
mod close;
|
||||
mod config;
|
||||
mod dscp;
|
||||
mod hmac;
|
||||
mod negotiation;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod probes_replies;
|
||||
mod sequence;
|
||||
mod ttl;
|
||||
@@ -1,289 +0,0 @@
|
||||
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();
|
||||
let negotiated = assert_negotiates(¶ms, ¶ms, NegotiationPolicy::Strict);
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert!(negotiated.restrictions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_rejects_changed_params() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
assert_eq!(
|
||||
requested.dscp, 184,
|
||||
"codepoint 46 must become raw wire byte 184"
|
||||
);
|
||||
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 loose_negotiation_accepts_server_restricted_params() {
|
||||
let config = ClientConfig::default();
|
||||
let mut requested = params_from_config(&config).unwrap();
|
||||
requested.length = 128;
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns /= 2;
|
||||
returned.length = 0;
|
||||
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]
|
||||
fn loose_negotiation_rejects_non_positive_returned_interval() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
|
||||
for interval_ns in [0, -1] {
|
||||
let mut returned = requested.clone();
|
||||
returned.interval_ns = interval_ns;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"interval must be positive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_accepts_documented_duration_combinations() {
|
||||
let finite_config = ClientConfig::default();
|
||||
let requested_finite = params_from_config(&finite_config).unwrap();
|
||||
|
||||
let returned_same_finite = requested_finite.clone();
|
||||
assert!(assert_negotiates(
|
||||
&requested_finite,
|
||||
&returned_same_finite,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.restrictions
|
||||
.is_empty());
|
||||
|
||||
let mut returned_shorter_finite = requested_finite.clone();
|
||||
returned_shorter_finite.duration_ns /= 2;
|
||||
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,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested_continuous = params_from_config(&continuous_config).unwrap();
|
||||
|
||||
let returned_continuous = requested_continuous.clone();
|
||||
assert!(assert_negotiates(
|
||||
&requested_continuous,
|
||||
&returned_continuous,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.restrictions
|
||||
.is_empty());
|
||||
|
||||
let mut returned_finite = requested_continuous.clone();
|
||||
returned_finite.duration_ns = 1_000_000_000;
|
||||
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]
|
||||
fn loose_negotiation_rejects_finite_request_returned_continuous() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = 0;
|
||||
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned continuous duration for finite request"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_negative_returned_length() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.length = -1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"length must be non-negative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_rejects_negative_returned_length() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.length = -1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
"length must be non-negative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
assert_eq!(
|
||||
requested.dscp, 184,
|
||||
"codepoint 46 must become raw wire byte 184"
|
||||
);
|
||||
|
||||
for dscp in [-1, 256] {
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = dscp;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"dscp must be in range 0..=255"
|
||||
);
|
||||
}
|
||||
|
||||
// A raw returned value of 184 must be accepted even though it exceeds the
|
||||
// 0..=63 codepoint range; it is the correctly negotiated wire value for
|
||||
// codepoint 46 and is not subject to the codepoint bound at all.
|
||||
let returned = requested.clone();
|
||||
assert!(
|
||||
assert_negotiates(&requested, &returned, NegotiationPolicy::Loose)
|
||||
.restrictions
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_test_success_validates_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = no_test_server(params.clone(), 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_rejects_non_close_open_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::UnexpectedNoTestReply)
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_cleanup_send_failure_preserves_unexpected_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
client.test_hooks.fail_cleanup_send.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::UnexpectedNoTestReply)
|
||||
));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_rejects_non_zero_token_with_close_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = no_test_server(params, TOKEN);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_strict_negotiation_rejects_changed_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let mut params = params_from_config(&config).unwrap();
|
||||
params.dscp = 1;
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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().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();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_fails_after_no_test_completed() {
|
||||
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, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_no_test_completed(client.open().unwrap());
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::AlreadyCompleted)
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_after_no_test_completed() {
|
||||
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, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_no_test_completed(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
|
||||
server.join();
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn successful_open_handshake() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params.clone());
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
let negotiated = assert_open_started(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_open_restores_configured_receive_timeout() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params);
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.recv_timeout = Some(Duration::from_millis(75));
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
client.test_hooks.last_restored_read_timeout.get(),
|
||||
Some(Duration::from_millis(75))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_when_already_open() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_retries_after_first_timeout() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let (first, _) = recv_request(&socket, &tx);
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let params = Params::decode(&first[4..]).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
assert_open_started(outcome);
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_survives_interrupted_receive_within_same_attempt() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let (first, peer) = recv_request(&socket, &tx);
|
||||
let params = Params::decode(&first[4..]).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(500)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
// Several interrupted receive attempts before the real reply arrives
|
||||
// must not be treated as a timeout, must not retransmit the open
|
||||
// request, and must not advance to a next configured attempt.
|
||||
client.test_hooks.inject_recv_interrupted.set(5);
|
||||
|
||||
let outcome = client.open().unwrap();
|
||||
assert_open_started(outcome);
|
||||
// Exactly one request reached the server: interruptions were absorbed
|
||||
// inside the single configured attempt.
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_interrupted_receive_does_not_extend_the_attempt_deadline() {
|
||||
// A fake server that never replies: with a short attempt timeout, the
|
||||
// real question is whether interruptions can push the effective wait
|
||||
// past the configured deadline. They must not: the deadline stays
|
||||
// absolute across any number of interruptions.
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
// Each interruption carries no timing information of its own, so the
|
||||
// loop must keep re-checking the real deadline rather than treating an
|
||||
// interruption as "more time available" or a reason to fail outright.
|
||||
client.test_hooks.inject_recv_interrupted.set(20);
|
||||
|
||||
let started = Instant::now();
|
||||
let result = client.open();
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(matches!(result, Err(ClientError::OpenTimeout)));
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(800),
|
||||
"interrupted receives must not extend the attempt deadline, took {elapsed:?}"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_timeout_after_all_timeouts() {
|
||||
let server = timeout_server(Duration::from_millis(700));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert_eq!(
|
||||
client.test_hooks.last_restored_read_timeout.get(),
|
||||
Some(Duration::from_millis(50))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_version_mismatch_fails() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let mut params = params_from_config(&config).unwrap();
|
||||
params.protocol_version = 2;
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_rejection_fails_in_normal_mode() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let mut wrong_type = valid.clone();
|
||||
wrong_type[3] = FLAG_REPLY;
|
||||
let mut reserved_flags = valid.clone();
|
||||
reserved_flags[3] |= 0x10;
|
||||
let mut bad_magic = valid.clone();
|
||||
bad_magic[0] ^= 0xff;
|
||||
let unexpected_hmac =
|
||||
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(b"unexpected"));
|
||||
|
||||
for packet in [
|
||||
vec![0_u8],
|
||||
bad_magic,
|
||||
wrong_type,
|
||||
reserved_flags,
|
||||
unexpected_hmac,
|
||||
valid,
|
||||
] {
|
||||
socket.send_to(&packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
assert!(client.schedule.is_some());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(250), Duration::from_millis(250)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let started = Instant::now();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
let requests: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(700),
|
||||
"ignored datagrams restarted an attempt deadline"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ignored_open_datagrams_eventually_time_out() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
for _ in 0..2 {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
socket.send_to(&MAGIC, peer).unwrap();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
client.test_hooks.last_restored_read_timeout.get(),
|
||||
Some(Duration::from_millis(50))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_zero_token_normal_reply_is_terminal() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
|
||||
// deliberately non-compliant reply is built by encoding a normal
|
||||
// reply with a placeholder token and then zeroing the token field.
|
||||
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_deadline_overflow_occurs_before_send() {
|
||||
let server = timeout_server(Duration::from_millis(250));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::MAX],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::DurationOverflow)));
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_duration_overflow_cannot_partially_open() {
|
||||
let server = start_fake_server(|_socket, _tx| {});
|
||||
let client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
let reply = irtt_proto::OpenReply {
|
||||
flags: FLAG_OPEN | FLAG_REPLY,
|
||||
token: TOKEN,
|
||||
params: params_from_config(client.runtime.config()).unwrap(),
|
||||
};
|
||||
let mut latest = Instant::now();
|
||||
let mut step = Duration::MAX;
|
||||
while !step.is_zero() {
|
||||
if let Some(candidate) = latest.checked_add(step) {
|
||||
latest = candidate;
|
||||
}
|
||||
step /= 2;
|
||||
}
|
||||
let opened_at = ClientTimestamp {
|
||||
mono: latest,
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let machine = client
|
||||
.runtime
|
||||
.prepare_open_acceptance(reply, opened_at)
|
||||
.unwrap();
|
||||
let previous_recv_len = client.recv_buffer.len();
|
||||
|
||||
let failure = client.prepare_client_open(machine, opened_at).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
failure.primary,
|
||||
ClientError::NegotiationRejected { .. }
|
||||
));
|
||||
assert!(failure.machine.cleanup_close_packet().is_some());
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
assert_eq!(client.recv_buffer.len(), previous_recv_len);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_negotiation_failure_sends_cleanup_close() {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_send_failure_does_not_replace_primary_open_error() {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_cleanup_send.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_buffer_reservation_failure_precedes_dscp_application() {
|
||||
let mut returned = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF), matching
|
||||
// what the client below requests so negotiation succeeds unchanged.
|
||||
returned.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
client
|
||||
.test_hooks
|
||||
.recv_buffer_len_override
|
||||
.set(Some(usize::MAX));
|
||||
client.test_hooks.fail_open_dscp.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::AllocationFailed {
|
||||
operation: "negotiated receive buffer",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(client.test_hooks.fail_open_dscp.get());
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dscp_application_failure_leaves_machine_connected() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_open_dscp.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::SocketOption {
|
||||
operation: "set negotiated DSCP",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(client.test_hooks.prepared_active_session_before_dscp.get());
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_timeout_restoration_failure_leaves_machine_connected() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_open_timeout_restore.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::ReadTimeoutRestore { .. })
|
||||
));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dscp_rollback_failure_keeps_timeout_restoration_primary() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_open_timeout_restore.set(true);
|
||||
client.test_hooks.fail_dscp_restore.set(true);
|
||||
|
||||
let error = client.open().unwrap_err();
|
||||
|
||||
assert!(matches!(error, ClientError::ReadTimeoutRestore { .. }));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// An interrupted receive (`io::ErrorKind::Interrupted`, e.g. `EINTR`) must be
|
||||
/// invisible to the caller: retried transparently, with the eventual reply
|
||||
/// classified exactly as if no interruption had occurred.
|
||||
#[test]
|
||||
fn interrupted_receive_is_retried_and_the_reply_is_still_classified() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
let mut buf = [0_u8; 2048];
|
||||
let (size, _) = socket.recv_from(&mut buf).unwrap();
|
||||
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
|
||||
let _ = size;
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, None);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_secs(2)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
client.test_hooks.inject_recv_interrupted.set(10);
|
||||
let events = client.recv_once().unwrap();
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(
|
||||
matches!(&events[0], ClientEvent::EchoReply { seq: 0, .. }),
|
||||
"expected a normal EchoReply despite interrupted receive attempts, got {:?}",
|
||||
events[0]
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
/// One logical receive keeps one timeout budget: repeated interruptions must
|
||||
/// not extend a `recv_once` call past its configured receive timeout.
|
||||
#[test]
|
||||
fn interrupted_receive_does_not_extend_the_configured_timeout() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(100)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
// No reply is ever coming; only interrupted attempts occur before the
|
||||
// real socket takes over and legitimately times out.
|
||||
client.test_hooks.inject_recv_interrupted.set(20);
|
||||
let started = Instant::now();
|
||||
let events = client.recv_once().unwrap();
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(events.is_empty());
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(500),
|
||||
"interrupted receives must not extend the configured receive timeout, took {elapsed:?}"
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn probe_timestamps(permission_at: Instant, sent_at: ClientTimestamp) -> ProbeSendTimestamps {
|
||||
ProbeSendTimestamps {
|
||||
permission_at,
|
||||
send_anchor: sent_at,
|
||||
sent_at,
|
||||
send_call_start: sent_at.mono,
|
||||
send_finished_at: sent_at.mono,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_fails_before_open() {
|
||||
let server = start_fake_server(|_socket, _tx| {});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_sends_valid_echo_request() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
assert_open_started(client.open().unwrap());
|
||||
let events = client.send_probe().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
ClientEvent::EchoSent {
|
||||
seq, remote, bytes, ..
|
||||
} => {
|
||||
assert_eq!(*seq, 0);
|
||||
assert_eq!(*remote, server.addr);
|
||||
assert_eq!(*bytes, echo_packet_len(false, ¶ms));
|
||||
}
|
||||
other => panic!("expected EchoSent, got {other:?}"),
|
||||
}
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let packets: Vec<_> = server.rx.try_iter().collect();
|
||||
let echo_reqs: Vec<_> = packets
|
||||
.iter()
|
||||
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
|
||||
.collect();
|
||||
let echo_req = echo_reqs.first().unwrap();
|
||||
assert_eq!(&echo_req[..3], &MAGIC);
|
||||
assert_eq!(echo_req[3], 0x00);
|
||||
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
|
||||
assert_eq!(req_token, TOKEN);
|
||||
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
|
||||
assert_eq!(seq, 0);
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_short_probe_commits_before_presentation_length_error() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params.clone());
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let expected = echo_packet_len(false, ¶ms);
|
||||
let initial_deadline = client.next_send_deadline().unwrap();
|
||||
client.probe_reported_len = Some(expected - 1);
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::DatagramLengthMismatch {
|
||||
expected: error_expected,
|
||||
actual,
|
||||
}) if error_expected == expected && actual + 1 == expected
|
||||
));
|
||||
assert_eq!(client.runtime.packets_sent(), 1);
|
||||
assert_eq!(
|
||||
client.next_send_deadline(),
|
||||
Some(initial_deadline + client.runtime.config().interval)
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_failed_probe_send_preserves_machine_and_schedule() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let initial_deadline = client.next_send_deadline().unwrap();
|
||||
client.probe_send_error = true;
|
||||
|
||||
assert!(matches!(client.send_probe(), Err(ClientError::Socket(_))));
|
||||
assert_eq!(client.runtime.packets_sent(), 0);
|
||||
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe().unwrap().as_slice(),
|
||||
[ClientEvent::EchoSent { seq: 0, .. }]
|
||||
));
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_timeout_finalization_overflow_does_not_send_or_commit() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::MAX,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let initial_deadline = client.next_send_deadline().unwrap();
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe_at(probe_timestamps(initial_deadline, sent_at)),
|
||||
Err(ClientError::DurationOverflow)
|
||||
));
|
||||
assert_eq!(client.runtime.packets_sent(), 0);
|
||||
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
assert!(server
|
||||
.rx
|
||||
.try_iter()
|
||||
.all(|packet| packet.len() >= 4 && packet[3] & FLAG_OPEN != 0));
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_probe_separates_permission_committed_send_and_send_call_timing() {
|
||||
let duration = Duration::from_secs(1);
|
||||
let interval = Duration::from_millis(100);
|
||||
let params = Params {
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
..default_params()
|
||||
};
|
||||
let server = silent_open_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
duration: Some(duration),
|
||||
interval,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let opened_mono = Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.expect("test host monotonic clock has at least two seconds of history");
|
||||
client.schedule = Some(
|
||||
ProbeSchedule::new(
|
||||
opened_mono,
|
||||
&NegotiatedParams {
|
||||
params,
|
||||
restrictions: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let permission_at = opened_mono + Duration::from_millis(500);
|
||||
// Distinct pre-send anchor and post-send `sent_at`: the anchor drives
|
||||
// the operational timeout deadline, while `sent_at` (captured after the
|
||||
// send call) is the public measurement/timer_error endpoint. They are
|
||||
// deliberately decoupled by this PR.
|
||||
let send_anchor = ClientTimestamp {
|
||||
wall: SystemTime::now(),
|
||||
mono: opened_mono + Duration::from_millis(525),
|
||||
};
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::now(),
|
||||
mono: opened_mono + Duration::from_millis(534),
|
||||
};
|
||||
let timestamps = ProbeSendTimestamps {
|
||||
permission_at,
|
||||
send_anchor,
|
||||
sent_at,
|
||||
send_call_start: opened_mono + Duration::from_millis(526),
|
||||
send_finished_at: opened_mono + Duration::from_millis(533),
|
||||
};
|
||||
|
||||
let events = client.send_probe_at(timestamps).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ClientEvent::EchoSent {
|
||||
scheduled_at,
|
||||
sent_at: event_sent_at,
|
||||
send_call,
|
||||
timer_error,
|
||||
..
|
||||
}] if *scheduled_at == opened_mono
|
||||
&& *event_sent_at == sent_at
|
||||
&& *send_call == Duration::from_millis(7)
|
||||
&& *timer_error == Duration::from_millis(534)
|
||||
));
|
||||
assert_eq!(client.next_send_deadline(), Some(opened_mono + interval));
|
||||
assert_eq!(client.runtime.packets_sent(), 1);
|
||||
// The timeout deadline is anchored to the pre-send `send_anchor`, not
|
||||
// to the post-send `sent_at` captured 9ms later.
|
||||
let timeout_at = send_anchor.mono + client.probe_timeout();
|
||||
assert_ne!(timeout_at, sent_at.mono + client.probe_timeout());
|
||||
assert!(client
|
||||
.poll_timeouts_at(timeout_at - Duration::from_nanos(1))
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(matches!(
|
||||
client.poll_timeouts_at(timeout_at).unwrap().as_slice(),
|
||||
[ClientEvent::EchoLoss {
|
||||
sent_at: loss_sent_at,
|
||||
..
|
||||
}] if *loss_sent_at == sent_at
|
||||
));
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_send_call_excludes_wrapped_history_cleanup() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let scheduled_at = client.next_send_deadline().unwrap();
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::now(),
|
||||
mono: scheduled_at + Duration::from_millis(5),
|
||||
};
|
||||
client.runtime.seed_wrapped_probe_history_for_test(sent_at);
|
||||
let timestamps = ProbeSendTimestamps {
|
||||
permission_at: scheduled_at,
|
||||
send_anchor: sent_at,
|
||||
sent_at,
|
||||
send_call_start: scheduled_at + Duration::from_millis(6),
|
||||
send_finished_at: scheduled_at + Duration::from_millis(13),
|
||||
};
|
||||
|
||||
let events = client.send_probe_at(timestamps).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ClientEvent::EchoSent {
|
||||
seq: 0,
|
||||
send_call,
|
||||
..
|
||||
}] if *send_call == Duration::from_millis(7)
|
||||
));
|
||||
assert!(!client.runtime.has_timed_out_metadata());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_respects_finite_duration_exclusive_end() {
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 500_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = silent_open_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_secs(1)),
|
||||
interval: Duration::from_millis(500),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let start = client.next_send_deadline().unwrap();
|
||||
let interval = Duration::from_millis(500);
|
||||
|
||||
let now0 = ClientTimestamp {
|
||||
mono: start,
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
assert!(client
|
||||
.send_probe_at(probe_timestamps(now0.mono, now0))
|
||||
.is_ok());
|
||||
|
||||
let now1 = ClientTimestamp {
|
||||
mono: start + interval,
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
assert!(client
|
||||
.send_probe_at(probe_timestamps(now1.mono, now1))
|
||||
.is_ok());
|
||||
|
||||
let now2 = ClientTimestamp {
|
||||
mono: start + Duration::from_secs(1),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let events = client
|
||||
.send_probe_at(probe_timestamps(now2.mono, now2))
|
||||
.unwrap();
|
||||
assert!(events.is_empty());
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_probe_skips_missed_schedule_slots() {
|
||||
let interval = Duration::from_millis(10);
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 0,
|
||||
interval_ns: 10_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = echo_server(params);
|
||||
let config = ClientConfig {
|
||||
duration: None,
|
||||
interval,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let first_deadline = client.next_send_deadline().unwrap();
|
||||
let permission_at = first_deadline + Duration::from_millis(45);
|
||||
let delayed_send = ClientTimestamp {
|
||||
mono: first_deadline + Duration::from_millis(48),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let timestamps = ProbeSendTimestamps {
|
||||
permission_at,
|
||||
send_anchor: delayed_send,
|
||||
sent_at: delayed_send,
|
||||
send_call_start: first_deadline + Duration::from_millis(49),
|
||||
send_finished_at: first_deadline + Duration::from_millis(55),
|
||||
};
|
||||
let events = client
|
||||
.send_managed_probe_at(first_deadline, timestamps)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(
|
||||
events[0],
|
||||
ClientEvent::EchoSent {
|
||||
seq: 0,
|
||||
scheduled_at,
|
||||
sent_at,
|
||||
send_call,
|
||||
timer_error,
|
||||
..
|
||||
} if scheduled_at == first_deadline + Duration::from_millis(40)
|
||||
&& sent_at == delayed_send
|
||||
&& send_call == Duration::from_millis(6)
|
||||
&& timer_error == Duration::from_millis(8)
|
||||
));
|
||||
assert_eq!(
|
||||
client.next_send_deadline(),
|
||||
Some(first_deadline + Duration::from_millis(50))
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_probe_skip_preserves_finite_run_end() {
|
||||
let interval = Duration::from_millis(10);
|
||||
let duration = Duration::from_millis(50);
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 50_000_000,
|
||||
interval_ns: 10_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = echo_server(params);
|
||||
let config = ClientConfig {
|
||||
duration: Some(duration),
|
||||
interval,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let first_deadline = client.next_send_deadline().unwrap();
|
||||
let delayed_send = ClientTimestamp {
|
||||
mono: first_deadline + Duration::from_millis(45),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let first = client
|
||||
.send_managed_probe_at(
|
||||
first_deadline,
|
||||
probe_timestamps(delayed_send.mono, delayed_send),
|
||||
)
|
||||
.unwrap();
|
||||
let second_sent_at = ClientTimestamp {
|
||||
mono: first_deadline + Duration::from_millis(46),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let second = client
|
||||
.send_managed_probe_at(
|
||||
first_deadline + duration,
|
||||
probe_timestamps(second_sent_at.mono, second_sent_at),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.len(), 1);
|
||||
assert!(second.is_empty());
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_wait_is_bounded_by_next_ten_millisecond_deadline() {
|
||||
let now = Instant::now();
|
||||
let deadline = now + Duration::from_millis(10);
|
||||
|
||||
assert_eq!(
|
||||
bounded_receive_timeout(
|
||||
Some(deadline),
|
||||
Some(Duration::from_millis(20)),
|
||||
Duration::from_millis(20),
|
||||
now,
|
||||
),
|
||||
Some(Duration::from_millis(10))
|
||||
);
|
||||
assert_eq!(
|
||||
bounded_receive_timeout(
|
||||
Some(now),
|
||||
Some(Duration::from_millis(20)),
|
||||
Duration::from_millis(20),
|
||||
now,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_probe_limits() {
|
||||
for config in [
|
||||
ClientConfig {
|
||||
max_pending_probes: 0,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
ClientConfig {
|
||||
probe_timeout: Duration::ZERO,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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(¶ms);
|
||||
|
||||
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, ¶ms));
|
||||
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, ¶ms));
|
||||
}
|
||||
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
|
||||
}
|
||||
server.join();
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
use super::*;
|
||||
use crate::metadata::ReceiveMeta;
|
||||
|
||||
#[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,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&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,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&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,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&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,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&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,27 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sequence_ordering_handles_normal_and_wrapped_values() {
|
||||
assert!(sequence_is_after(11, 10));
|
||||
assert!(sequence_is_before(9, 10));
|
||||
assert!(!sequence_is_after(10, 10));
|
||||
assert!(!sequence_is_before(10, 10));
|
||||
|
||||
assert!(sequence_is_after(0, u32::MAX - 1));
|
||||
assert!(!sequence_is_before(0, u32::MAX - 1));
|
||||
assert!(sequence_is_after(1, u32::MAX));
|
||||
assert!(!sequence_is_before(1, u32::MAX));
|
||||
|
||||
assert!(sequence_is_before(u32::MAX, 1));
|
||||
assert!(!sequence_is_after(u32::MAX, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_received_seq_updates_across_wrap() {
|
||||
let mut highest = Some(u32::MAX);
|
||||
|
||||
update_highest_received(&mut highest, 0);
|
||||
assert_eq!(highest, Some(0));
|
||||
update_highest_received(&mut highest, u32::MAX);
|
||||
assert_eq!(highest, Some(0));
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use crate::socket_options::socket_ttl;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_accepts_valid_ttl_config_values() {
|
||||
for ttl in [None, Some(1), Some(255)] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = ttl;
|
||||
Client::connect(config).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
|
||||
let params = Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = open_success_server(params.clone());
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.ttl = Some(64);
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
||||
|
||||
client.open().unwrap();
|
||||
server.join();
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_ttl_values() {
|
||||
for ttl in [0, 256] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = Some(ttl);
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use crate::{Authentication, ClientError};
|
||||
|
||||
/// 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
|
||||
/// protocol decoder. [`SessionRequest::server_fill`] enforces the same bound
|
||||
/// before opening a session.
|
||||
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
@@ -26,7 +28,7 @@ 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.
|
||||
/// [`SessionRequest::length`] before protocol packets are encoded or sent.
|
||||
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
|
||||
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
||||
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
||||
@@ -45,27 +47,111 @@ pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
|
||||
/// 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).
|
||||
/// through [`NegotiationResult`](crate::NegotiationResult).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Configure a finite run with larger probes and accept documented server
|
||||
/// restrictions. Use `duration: None` for continuous mode, or
|
||||
/// [`RunMode::NoTest`] for negotiation without probes.
|
||||
///
|
||||
/// ```
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// use irtt_client::{ClientConfig, NegotiationPolicy, OpenPolicy, SessionRequest};
|
||||
///
|
||||
/// let config = ClientConfig {
|
||||
/// request: SessionRequest {
|
||||
/// duration: Some(Duration::from_secs(10)),
|
||||
/// interval: Duration::from_millis(250),
|
||||
/// length: 1200,
|
||||
/// ..SessionRequest::default()
|
||||
/// },
|
||||
/// open: OpenPolicy {
|
||||
/// negotiation: NegotiationPolicy::Loose,
|
||||
/// ..OpenPolicy::default()
|
||||
/// },
|
||||
/// ..ClientConfig::default()
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(config.request.duration, Some(Duration::from_secs(10)));
|
||||
/// assert_eq!(config.request.interval, Duration::from_millis(250));
|
||||
/// assert_eq!(config.request.length, 1200);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
/// Remote server name or address.
|
||||
/// Remote address families allowed during resolution and socket creation.
|
||||
pub address_family: AddressFamily,
|
||||
/// Local UDP socket properties.
|
||||
pub socket: SocketConfig,
|
||||
/// Protocol and session values requested from the server.
|
||||
pub request: SessionRequest,
|
||||
/// Open retry timeouts and negotiation policy.
|
||||
pub open: OpenPolicy,
|
||||
/// Concrete authentication for open, echo, and close packets.
|
||||
///
|
||||
/// 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,
|
||||
/// HMAC peers must use the same key. An empty HMAC key remains authenticated.
|
||||
pub auth: Authentication,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
address_family: AddressFamily::default(),
|
||||
socket: SocketConfig::default(),
|
||||
request: SessionRequest::default(),
|
||||
open: OpenPolicy::default(),
|
||||
auth: Authentication::Unauthenticated,
|
||||
probe_timeout: DEFAULT_PROBE_TIMEOUT,
|
||||
max_pending_probes: DEFAULT_MAX_PENDING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allowed remote address families. IPv6-only selection also sets IPV6_V6ONLY.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AddressFamily {
|
||||
/// Accept IPv4 and IPv6 addresses.
|
||||
#[default]
|
||||
Any,
|
||||
/// Accept only IPv4 addresses.
|
||||
Ipv4,
|
||||
/// Accept only IPv6 addresses and use IPv6-only sockets.
|
||||
Ipv6,
|
||||
}
|
||||
|
||||
/// Requested IRTT protocol and session semantics, reusable across endpoints.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionRequest {
|
||||
/// 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.
|
||||
///
|
||||
/// The managed driver stops sending after the negotiated duration.
|
||||
/// Low-level adapters leave run duration to the caller.
|
||||
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.
|
||||
///
|
||||
/// The managed driver uses the negotiated interval for cadence.
|
||||
/// Low-level adapters send whenever called.
|
||||
pub interval: Duration,
|
||||
/// Requested echo packet payload length, in bytes.
|
||||
///
|
||||
@@ -87,71 +173,20 @@ pub struct ClientConfig {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Authentication settings used by client sessions.
|
||||
///
|
||||
/// `ClientConfig` keeps its existing top-level HMAC field for source
|
||||
/// compatibility. Managed multi-target callers can use this smaller type for
|
||||
/// per-target auth overrides without carrying a full independent
|
||||
/// [`ClientConfig`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ClientAuthConfig {
|
||||
/// Optional HMAC key used to authenticate IRTT packets.
|
||||
pub hmac_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl From<Option<Vec<u8>>> for ClientAuthConfig {
|
||||
fn from(hmac_key: Option<Vec<u8>>) -> Self {
|
||||
Self { hmac_key }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientAuthConfig> for Option<Vec<u8>> {
|
||||
fn from(auth: ClientAuthConfig) -> Self {
|
||||
auth.hmac_key
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
impl Default for SessionRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_addr: format!("127.0.0.1:{DEFAULT_PORT}"),
|
||||
duration: Some(DEFAULT_DURATION),
|
||||
interval: DEFAULT_INTERVAL,
|
||||
length: 0,
|
||||
@@ -159,23 +194,78 @@ impl Default for ClientConfig {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 0,
|
||||
hmac_key: None,
|
||||
server_fill: None,
|
||||
open_timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
|
||||
run_mode: RunMode::Normal,
|
||||
negotiation_policy: NegotiationPolicy::Strict,
|
||||
socket_config: SocketConfig::default(),
|
||||
probe_timeout: DEFAULT_PROBE_TIMEOUT,
|
||||
max_pending_probes: DEFAULT_MAX_PENDING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening behavior shared by blocking, async, and managed clients.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenPolicy {
|
||||
/// 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 timeouts: Vec<Duration>,
|
||||
/// Policy for server changes to negotiable protocol parameters.
|
||||
pub negotiation: NegotiationPolicy,
|
||||
}
|
||||
|
||||
impl OpenPolicy {
|
||||
pub(crate) fn validate(&self) -> Result<(), ClientError> {
|
||||
if self.timeouts.is_empty() {
|
||||
return Err(ClientError::NoOpenTimeouts);
|
||||
}
|
||||
for timeout in &self.timeouts {
|
||||
if *timeout < MIN_OPEN_TIMEOUT {
|
||||
return Err(ClientError::OpenTimeoutTooSmall {
|
||||
timeout: *timeout,
|
||||
minimum: MIN_OPEN_TIMEOUT,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpenPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
|
||||
negotiation: NegotiationPolicy::Strict,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// These settings affect how the client binds and sends from the socket. They
|
||||
/// do not change the IRTT protocol parameters negotiated with the server.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
|
||||
///
|
||||
/// ```
|
||||
/// use std::net::{Ipv4Addr, SocketAddr};
|
||||
///
|
||||
/// use irtt_client::{AddressFamily, ClientConfig, SocketConfig};
|
||||
///
|
||||
/// let config = ClientConfig {
|
||||
/// address_family: AddressFamily::Ipv4,
|
||||
/// socket: SocketConfig {
|
||||
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
|
||||
/// ttl: Some(32),
|
||||
/// ..SocketConfig::default()
|
||||
/// },
|
||||
/// ..ClientConfig::default()
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(config.socket.ttl, Some(32));
|
||||
/// assert_eq!(config.address_family, AddressFamily::Ipv4);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SocketConfig {
|
||||
/// Local address to bind before connecting the UDP socket.
|
||||
@@ -183,23 +273,21 @@ pub struct SocketConfig {
|
||||
/// `None` binds to an unspecified address with an ephemeral port matching
|
||||
/// the selected remote address family.
|
||||
pub bind_addr: Option<SocketAddr>,
|
||||
/// Network interface to bind this socket to (`SO_BINDTODEVICE`).
|
||||
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||
pub bind_to_device: Option<String>,
|
||||
/// Firewall mark applied to outgoing packets (`SO_MARK`).
|
||||
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||
pub mark: Option<u32>,
|
||||
/// FreeBSD FIB used for routing this socket (`SO_SETFIB`).
|
||||
#[cfg(target_os = "freebsd")]
|
||||
pub fib: Option<u32>,
|
||||
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
|
||||
///
|
||||
/// `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.
|
||||
@@ -209,14 +297,15 @@ pub enum NegotiationPolicy {
|
||||
/// than requested.
|
||||
Strict,
|
||||
/// Accept documented server restrictions and report them in
|
||||
/// [`NegotiatedParams`](crate::NegotiatedParams).
|
||||
/// [`NegotiationResult`](crate::NegotiationResult).
|
||||
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.
|
||||
/// Open a probe session. The managed driver runs for the negotiated duration;
|
||||
/// low-level adapters leave sending and stopping to the caller.
|
||||
Normal,
|
||||
/// Complete negotiation without running the echo probe test.
|
||||
NoTest,
|
||||
|
||||
@@ -3,35 +3,36 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
|
||||
use crate::{session::NegotiationResult, timing::ClientTimestamp};
|
||||
|
||||
/// Result of the IRTT open exchange.
|
||||
/// Successful negotiation with a live session ready for echo probes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionStarted {
|
||||
/// Resolved remote socket address.
|
||||
pub remote: SocketAddr,
|
||||
/// Session token used on Echo and Close packets.
|
||||
pub token: u64,
|
||||
pub negotiation: NegotiationResult,
|
||||
/// Client timestamp when the Open reply was accepted.
|
||||
pub at: ClientTimestamp,
|
||||
}
|
||||
|
||||
/// Successful negotiation-only exchange with no live probe session or token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NoTestCompleted {
|
||||
pub remote: SocketAddr,
|
||||
pub negotiation: NegotiationResult,
|
||||
/// Client timestamp when the Open reply was accepted.
|
||||
pub at: ClientTimestamp,
|
||||
}
|
||||
|
||||
/// Result of the IRTT Open exchange, before any echo probes are driven.
|
||||
///
|
||||
/// 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.
|
||||
/// Managed sessions publish the same payload as a [`ClientEvent`] after 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,
|
||||
},
|
||||
Started(SessionStarted),
|
||||
NoTestCompleted(NoTestCompleted),
|
||||
}
|
||||
|
||||
/// Event emitted by an IRTT client session.
|
||||
@@ -44,28 +45,12 @@ pub enum OpenOutcome {
|
||||
#[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.
|
||||
SessionStarted(SessionStarted),
|
||||
/// A no-test exchange completed without creating a live session.
|
||||
///
|
||||
/// 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,
|
||||
},
|
||||
/// expected for this outcome.
|
||||
NoTestCompleted(NoTestCompleted),
|
||||
/// The session closed locally or through an authenticated peer close.
|
||||
///
|
||||
/// For a peer close-flagged echo reply, the reply's measurement or
|
||||
@@ -92,8 +77,9 @@ pub enum ClientEvent {
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Monotonic deadline at which the probe was scheduled to be sent.
|
||||
scheduled_at: Instant,
|
||||
/// Intended send deadline, supplied by the managed runner.
|
||||
/// Low-level adapters leave this absent.
|
||||
scheduled_at: Option<Instant>,
|
||||
/// Paired client wall/monotonic timestamp captured immediately after
|
||||
/// the successful UDP send completed. This is the RTT and userspace
|
||||
/// upstream one-way-delay fallback endpoint. The operational probe
|
||||
@@ -106,8 +92,8 @@ pub enum ClientEvent {
|
||||
/// scheduler commit work.
|
||||
send_call: Duration,
|
||||
/// Absolute difference between the scheduled send time and the
|
||||
/// post-send `sent_at` timestamp above.
|
||||
timer_error: Duration,
|
||||
/// post-send `sent_at` timestamp above; absent for low-level sends.
|
||||
timer_error: Option<Duration>,
|
||||
},
|
||||
|
||||
/// First in-window reply for a pending echo request.
|
||||
@@ -260,6 +246,19 @@ pub struct RttSample {
|
||||
/// processing time or comparing wall-clock timestamps from different hosts. It
|
||||
/// should not be used to represent raw elapsed wall-clock or monotonic time,
|
||||
/// which is non-negative and represented by [`Duration`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Signed timing values retain a negative result instead of clamping clock
|
||||
/// skew or server processing adjustments to zero:
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_client::SignedDuration;
|
||||
///
|
||||
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
|
||||
/// assert!(adjusted_rtt.is_negative());
|
||||
/// assert_eq!(adjusted_rtt.as_micros(), -250);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SignedDuration {
|
||||
ns: i128,
|
||||
|
||||
@@ -18,19 +18,38 @@
|
||||
//! wants full control over its own event loop, should start from. With the
|
||||
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
|
||||
//! adapter for a caller that owns a runtime and drives socket readiness
|
||||
//! itself. [`managed`] sits above both: `ManagedClientTask` /
|
||||
//! itself. Both adapters send one probe whenever called; callers own cadence
|
||||
//! and run duration, using `negotiation()` after Open. Sends return a
|
||||
//! [`SendReceipt`] after socket acceptance and session commitment;
|
||||
//! [`SendProbeError`] distinguishes uncommitted failures from post-commit
|
||||
//! failures carrying that receipt. Convert a receipt to [`ClientEvent`] for
|
||||
//! presentation or statistics. [`managed`] owns
|
||||
//! negotiated-interval scheduling, finite runs, and staggered/burst pacing:
|
||||
//! `ManagedClientTask` /
|
||||
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
|
||||
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
|
||||
//! feature) wraps that in a synchronous owner backed by its own dedicated
|
||||
//! current-thread runtime for callers that want managed multi-target
|
||||
//! behavior without touching Tokio themselves.
|
||||
//!
|
||||
//! A successful [`OpenOutcome`] carries either [`SessionStarted`] (with a live
|
||||
//! token) or [`NoTestCompleted`] (terminal, with no probe session). Lifecycle
|
||||
//! events use these same payload types. Their [`NegotiationResult`] separates
|
||||
//! typed [`AcceptedSessionParameters`] from the exact decoded peer `Params` and
|
||||
//! accepted [`NegotiationChange`] records. Use `accepted.interval` and
|
||||
//! `accepted.duration` for cadence and lifetime, `accepted.dscp` for the public
|
||||
//! DSCP codepoint, and `peer_params` for wire diagnostics. Strict/Loose policy
|
||||
//! compares the actual request and reply wire values before deriving accepted
|
||||
//! semantics. Both adapters' `negotiation()` accessors return only a live
|
||||
//! session's result; no-test completion leaves no current session.
|
||||
//!
|
||||
//! See `examples/` in the repository for a runnable example of each tier.
|
||||
//!
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
mod async_client;
|
||||
mod auth;
|
||||
mod client;
|
||||
mod config;
|
||||
mod error;
|
||||
@@ -39,6 +58,7 @@ pub mod managed;
|
||||
mod metadata;
|
||||
mod probe;
|
||||
mod receive;
|
||||
mod send;
|
||||
mod session;
|
||||
mod socket;
|
||||
mod socket_options;
|
||||
@@ -46,15 +66,18 @@ mod timing;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use async_client::AsyncClient;
|
||||
pub use auth::{Authentication, HmacKey};
|
||||
pub use client::Client;
|
||||
pub use config::{
|
||||
ClientAuthConfig, ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig,
|
||||
MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
|
||||
AddressFamily, ClientConfig, NegotiationPolicy, OpenPolicy, RecvBudget, RunMode,
|
||||
SessionRequest, SocketConfig, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL,
|
||||
MAX_UDP_PAYLOAD_LENGTH,
|
||||
};
|
||||
pub use error::ClientError;
|
||||
pub use event::{
|
||||
ClientEvent, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample, RttSample,
|
||||
ServerTiming, SignedDuration, WarningKind,
|
||||
ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample,
|
||||
RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
|
||||
};
|
||||
pub use session::{NegotiatedParams, NegotiationRestriction};
|
||||
pub use send::{SendProbeError, SendReceipt};
|
||||
pub use session::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
|
||||
pub use timing::ClientTimestamp;
|
||||
@@ -157,22 +157,14 @@ impl BlockingManagedClient {
|
||||
}
|
||||
|
||||
fn join_worker(&mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
||||
join_worker(
|
||||
self.worker
|
||||
.take()
|
||||
.expect("blocking managed worker is joined at most once"),
|
||||
)
|
||||
self.worker
|
||||
.take()
|
||||
.expect("blocking managed worker is joined at most once")
|
||||
.join()
|
||||
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn join_worker(
|
||||
worker: JoinHandle<ManagedOutcome>,
|
||||
) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
||||
worker
|
||||
.join()
|
||||
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
|
||||
}
|
||||
|
||||
impl Drop for BlockingManagedClient {
|
||||
fn drop(&mut self) {
|
||||
if self.worker.is_some() {
|
||||
|
||||
@@ -16,5 +16,5 @@ pub use model::*;
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use task::*;
|
||||
|
||||
#[cfg(all(feature = "tokio", test))]
|
||||
mod blocking_tests;
|
||||
#[cfg(feature = "tokio")]
|
||||
mod schedule;
|
||||
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ClientAuthConfig, ClientConfig, ClientError, ClientEvent};
|
||||
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
|
||||
|
||||
use super::TargetId;
|
||||
|
||||
@@ -26,12 +26,39 @@ pub struct TargetInstance {
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Endpoint and optional authentication override for one managed target.
|
||||
/// Authentication specification for a managed target.
|
||||
///
|
||||
/// Equality preserves the specification: inheritance and an explicit override
|
||||
/// remain distinct even when they currently resolve to the same authentication.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub enum TargetAuth {
|
||||
/// Use the shared client configuration's authentication.
|
||||
#[default]
|
||||
Inherit,
|
||||
/// Replace shared authentication. `Unauthenticated` explicitly disables it.
|
||||
Override(Authentication),
|
||||
}
|
||||
|
||||
impl TargetAuth {
|
||||
pub(crate) fn resolve(&self, shared: &Authentication) -> Authentication {
|
||||
match self {
|
||||
Self::Inherit => shared.clone(),
|
||||
Self::Override(auth) => auth.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Endpoint, address-family and authentication specification for one managed target.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedTargetConfig {
|
||||
pub id: TargetId,
|
||||
pub server_addr: String,
|
||||
pub auth: Option<ClientAuthConfig>,
|
||||
/// Override the shared address-family policy. `None` inherits it.
|
||||
///
|
||||
/// The endpoint remains a hostname when supplied as one, so every new
|
||||
/// generation resolves it again using the effective family policy.
|
||||
pub address_family: Option<AddressFamily>,
|
||||
pub auth: TargetAuth,
|
||||
}
|
||||
|
||||
impl ManagedTargetConfig {
|
||||
@@ -39,7 +66,8 @@ impl ManagedTargetConfig {
|
||||
Self {
|
||||
id: id.into(),
|
||||
server_addr: server_addr.into(),
|
||||
auth: None,
|
||||
address_family: None,
|
||||
auth: TargetAuth::Inherit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +91,8 @@ pub enum ManagedCompletionPolicy {
|
||||
/// Shared configuration for one managed task.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedClientConfig {
|
||||
/// Reusable session/socket configuration shared across target generations.
|
||||
/// Each target supplies its endpoint and resolves its family and authentication policies.
|
||||
pub client: ClientConfig,
|
||||
pub pacing: ManagedPacing,
|
||||
pub completion: ManagedCompletionPolicy,
|
||||
@@ -117,6 +147,12 @@ pub struct ManagedTargetStatus {
|
||||
pub target: TargetInstance,
|
||||
pub desired: bool,
|
||||
pub lifecycle: ManagedTargetLifecycle,
|
||||
/// Authoritative completed accounting for this target generation.
|
||||
///
|
||||
/// Present if and only if `lifecycle` is [`ManagedTargetLifecycle::Terminal`].
|
||||
/// This shares the terminal phase's outcome with [`ManagedEvent::TargetFinished`],
|
||||
/// so terminal details remain durable even when presentation events are lost.
|
||||
pub outcome: Option<Arc<ManagedTargetOutcome>>,
|
||||
pub server_addr: Arc<str>,
|
||||
pub remote: Option<SocketAddr>,
|
||||
}
|
||||
@@ -125,6 +161,10 @@ pub struct ManagedTargetStatus {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedStatus {
|
||||
pub lifecycle: ManagedLifecycle,
|
||||
/// Whether the task durably observed an explicit stop request.
|
||||
///
|
||||
/// This remains false until the task observes the request latch. Natural
|
||||
/// stopping and terminality alone do not imply a stop request was observed.
|
||||
pub stop_requested: bool,
|
||||
pub applied_command_sequence: u64,
|
||||
pub desired_target_count: usize,
|
||||
@@ -141,9 +181,21 @@ pub struct ManagedStatus {
|
||||
pub discarded_target_outcomes: u64,
|
||||
pub targets: Arc<[ManagedTargetStatus]>,
|
||||
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
|
||||
/// Authoritative final accounting, present if and only if `lifecycle` is
|
||||
/// [`ManagedLifecycle::Completed`] or [`ManagedLifecycle::Failed`].
|
||||
///
|
||||
/// Shares the terminal task phase's outcome with [`ManagedEvent::Completed`]
|
||||
/// or [`ManagedEvent::Failed`]. Abandoned deliberately has no outcome.
|
||||
pub final_outcome: Option<Arc<ManagedOutcome>>,
|
||||
}
|
||||
|
||||
/// Receiving half of authoritative durable latest-state observation.
|
||||
///
|
||||
/// The current snapshot is immediately readable. Updates may coalesce, and the
|
||||
/// final snapshot remains available after the sender closes at task termination.
|
||||
/// Use [`ManagedEventSubscription`] for lossy presentation events.
|
||||
pub type ManagedStatusSubscription = tokio::sync::watch::Receiver<Arc<ManagedStatus>>;
|
||||
|
||||
/// Lossy presentation event emitted by the managed task.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -449,199 +501,3 @@ pub(crate) fn classify_client_error(
|
||||
message: Arc::from(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::TryReserveError, io};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn io_error() -> io::Error {
|
||||
io::Error::other("boom")
|
||||
}
|
||||
|
||||
fn try_reserve_error() -> TryReserveError {
|
||||
Vec::<u8>::new().try_reserve(usize::MAX).unwrap_err()
|
||||
}
|
||||
|
||||
fn classification_cases() -> Vec<(ClientError, ManagedTargetFailureKind)> {
|
||||
vec![
|
||||
(
|
||||
ClientError::Resolve {
|
||||
addr: "example.invalid:2112".to_owned(),
|
||||
},
|
||||
ManagedTargetFailureKind::Resolve,
|
||||
),
|
||||
(
|
||||
ClientError::Socket(io_error()),
|
||||
ManagedTargetFailureKind::Socket,
|
||||
),
|
||||
(
|
||||
ClientError::DatagramLengthMismatch {
|
||||
expected: 64,
|
||||
actual: 32,
|
||||
},
|
||||
ManagedTargetFailureKind::Socket,
|
||||
),
|
||||
(
|
||||
ClientError::SocketOption {
|
||||
operation: "set read timeout",
|
||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||
source: io_error(),
|
||||
},
|
||||
ManagedTargetFailureKind::SocketOption,
|
||||
),
|
||||
(
|
||||
ClientError::ReadTimeoutRestore { source: io_error() },
|
||||
ManagedTargetFailureKind::SocketOption,
|
||||
),
|
||||
(
|
||||
ClientError::Protocol(irtt_proto::ProtoError::BadMagic),
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::ProtocolVersionMismatch {
|
||||
requested: 1,
|
||||
received: 2,
|
||||
},
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(ClientError::ZeroToken, ManagedTargetFailureKind::Protocol),
|
||||
(
|
||||
ClientError::UnexpectedNoTestReply,
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::NonZeroNoTestToken { token: 7 },
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::ServerRejected,
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::NegotiationRejected {
|
||||
reason: "duration".to_owned(),
|
||||
},
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(ClientError::OpenTimeout, ManagedTargetFailureKind::Timeout),
|
||||
(
|
||||
ClientError::InvalidConfig {
|
||||
reason: "interval".to_owned(),
|
||||
},
|
||||
ManagedTargetFailureKind::Configuration,
|
||||
),
|
||||
(
|
||||
ClientError::OpenTimeoutTooSmall {
|
||||
timeout: Duration::from_millis(1),
|
||||
minimum: Duration::from_millis(10),
|
||||
},
|
||||
ManagedTargetFailureKind::Configuration,
|
||||
),
|
||||
(
|
||||
ClientError::NoOpenTimeouts,
|
||||
ManagedTargetFailureKind::Configuration,
|
||||
),
|
||||
(
|
||||
ClientError::AllocationFailed {
|
||||
operation: "pending probes",
|
||||
source: try_reserve_error(),
|
||||
},
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::CounterOverflow {
|
||||
counter: "packets_sent",
|
||||
},
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::DurationOverflow,
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::PendingLimitExceeded { limit: 8 },
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::NoTokioRuntime,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(ClientError::NotOpen, ManagedTargetFailureKind::InvalidState),
|
||||
(
|
||||
ClientError::AlreadyOpen,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::AlreadyCompleted,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::AlreadyClosed,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::StalePreparedProbe {
|
||||
prepared_seq: 3,
|
||||
next_wire_seq: 4,
|
||||
},
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::PendingSequenceCollision { seq: 9 },
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_client_error_classifies_to_its_documented_kind() {
|
||||
for (error, expected) in classification_cases() {
|
||||
let failure = classify_client_error(ManagedTargetFailurePhase::Opening, &error);
|
||||
assert_eq!(
|
||||
failure.kind, expected,
|
||||
"unexpected classification for {error:?}"
|
||||
);
|
||||
assert_eq!(failure.phase, ManagedTargetFailurePhase::Opening);
|
||||
assert_eq!(&*failure.message, error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_preserves_the_reported_phase() {
|
||||
for phase in [
|
||||
ManagedTargetFailurePhase::Connecting,
|
||||
ManagedTargetFailurePhase::Opening,
|
||||
ManagedTargetFailurePhase::Sending,
|
||||
ManagedTargetFailurePhase::Receiving,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
ManagedTargetFailurePhase::Closing,
|
||||
] {
|
||||
let failure = classify_client_error(phase, &ClientError::OpenTimeout);
|
||||
assert_eq!(failure.phase, phase);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_and_kind_display_without_debug_spelling() {
|
||||
assert_eq!(
|
||||
ManagedTargetFailurePhase::Connecting.to_string(),
|
||||
"connecting"
|
||||
);
|
||||
assert_eq!(ManagedTargetFailurePhase::Closing.to_string(), "closing");
|
||||
assert_eq!(
|
||||
ManagedTargetFailureKind::SocketOption.to_string(),
|
||||
"socket option"
|
||||
);
|
||||
assert_eq!(
|
||||
ManagedTargetFailureKind::ResourceExhausted.to_string(),
|
||||
"resource exhausted"
|
||||
);
|
||||
assert_eq!(
|
||||
ManagedTargetFailureKind::InvalidState.to_string(),
|
||||
"invalid state"
|
||||
);
|
||||
assert_eq!(ManagedTargetFailureKind::Other.to_string(), "other");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{error::ClientError, session::AcceptedSessionParameters};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProbeSchedule {
|
||||
end_at: Option<Instant>,
|
||||
interval: Duration,
|
||||
next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScheduleCommit {
|
||||
pub(crate) scheduled_at: Instant,
|
||||
pub(crate) next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ProbeSchedule {
|
||||
pub(crate) fn new(
|
||||
start_at: Instant,
|
||||
accepted: &AcceptedSessionParameters,
|
||||
) -> Result<Self, ClientError> {
|
||||
let interval = accepted.interval;
|
||||
let end_at = accepted
|
||||
.duration
|
||||
.map(|duration| {
|
||||
start_at
|
||||
.checked_add(duration)
|
||||
.ok_or_else(|| ClientError::NegotiationRejected {
|
||||
reason: "duration is too large to schedule".to_owned(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
end_at,
|
||||
interval,
|
||||
next_send_at: Some(start_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
||||
self.next_send_at
|
||||
}
|
||||
|
||||
/// Wake for finite expiry independently of send readiness until finished.
|
||||
pub(crate) fn end_deadline(&self) -> Option<Instant> {
|
||||
self.end_at.filter(|_| !self.is_finished())
|
||||
}
|
||||
|
||||
pub(crate) fn interval(&self) -> Duration {
|
||||
self.interval
|
||||
}
|
||||
|
||||
pub(crate) fn permit_probe_at(&mut self, now: Instant) -> bool {
|
||||
if self.next_send_at.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.end_at.is_some_and(|end| now >= end) {
|
||||
self.next_send_at = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_managed_commit(
|
||||
&self,
|
||||
scheduled_at: Instant,
|
||||
permission_at: Instant,
|
||||
) -> Result<ScheduleCommit, ClientError> {
|
||||
let (scheduled_at, next_send_at) =
|
||||
advance_cadence(scheduled_at, self.interval, permission_at)?;
|
||||
Ok(ScheduleCommit {
|
||||
scheduled_at,
|
||||
next_send_at: if self.end_at.is_some_and(|end| next_send_at >= end) {
|
||||
None
|
||||
} else {
|
||||
Some(next_send_at)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&mut self, commit: ScheduleCommit) {
|
||||
self.next_send_at = commit.next_send_at;
|
||||
}
|
||||
|
||||
/// Reclaim slots crossed while a successful socket submission was in flight.
|
||||
/// This follows the send commit; it never advances a failed submission.
|
||||
pub(crate) fn skip_missed_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
|
||||
if let Some(deadline) = self.next_send_at.filter(|deadline| *deadline <= now) {
|
||||
let commit = self.preflight_managed_commit(deadline, now)?;
|
||||
self.commit(commit);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
self.next_send_at.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn advance_cadence(
|
||||
deadline: Instant,
|
||||
interval: Duration,
|
||||
now: Instant,
|
||||
) -> Result<(Instant, Instant), ClientError> {
|
||||
if interval.is_zero() {
|
||||
return Err(ClientError::InvalidConfig {
|
||||
reason: "probe interval must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed_slots = now
|
||||
.checked_duration_since(deadline)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos() / interval.as_nanos());
|
||||
let scheduled_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(elapsed_slots)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let next_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(
|
||||
elapsed_slots
|
||||
.checked_add(1)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let scheduled_at = deadline
|
||||
.checked_add(scheduled_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
let next_at = deadline
|
||||
.checked_add(next_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
Ok((scheduled_at, next_at))
|
||||
}
|
||||
|
||||
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
|
||||
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
||||
let seconds =
|
||||
u64::try_from(nanos / NANOS_PER_SECOND).map_err(|_| ClientError::DurationOverflow)?;
|
||||
let subsec_nanos = u32::try_from(nanos % NANOS_PER_SECOND)
|
||||
.expect("nanosecond remainder is always less than one second");
|
||||
Ok(Duration::new(seconds, subsec_nanos))
|
||||
}
|
||||
|
||||
pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||
left.checked_duration_since(right)
|
||||
.or_else(|| right.checked_duration_since(left))
|
||||
.unwrap_or(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lifetime_deadline_exists_only_for_an_unfinished_finite_schedule() {
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_millis(500);
|
||||
let mut accepted = AcceptedSessionParameters {
|
||||
duration: Some(Duration::from_millis(500)),
|
||||
interval: Duration::from_millis(1),
|
||||
length: 0,
|
||||
received_stats: irtt_proto::ReceivedStats::None,
|
||||
stamp_at: irtt_proto::StampAt::None,
|
||||
clock: irtt_proto::Clock::Both,
|
||||
dscp: 0,
|
||||
server_fill: None,
|
||||
};
|
||||
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
|
||||
assert_eq!(finite.end_deadline(), Some(end));
|
||||
assert!(finite.permit_probe_at(end - Duration::from_nanos(1)));
|
||||
assert_eq!(finite.end_deadline(), Some(end));
|
||||
assert!(!finite.permit_probe_at(end));
|
||||
assert!(finite.is_finished());
|
||||
assert_eq!(finite.end_deadline(), None);
|
||||
|
||||
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
|
||||
let commit = finite
|
||||
.preflight_managed_commit(start, end - Duration::from_nanos(1))
|
||||
.unwrap();
|
||||
finite.commit(commit);
|
||||
assert!(finite.is_finished());
|
||||
assert_eq!(finite.end_deadline(), None);
|
||||
|
||||
accepted.duration = None;
|
||||
let mut continuous = ProbeSchedule::new(start, &accepted).unwrap();
|
||||
assert!(continuous.permit_probe_at(end));
|
||||
assert_eq!(continuous.end_deadline(), None);
|
||||
}
|
||||
}
|
||||
+202
-2022
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,514 @@
|
||||
//! Task construction, public control/observation handles, and stop/update admission.
|
||||
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
fmt,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
Arc,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, watch};
|
||||
|
||||
use crate::{
|
||||
managed::{
|
||||
ManagedClientConfig, ManagedCommandAcknowledgement, ManagedCommandApplyError,
|
||||
ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError, ManagedEvent,
|
||||
ManagedEventSubscription, ManagedLifecycle, ManagedStatus, ManagedStatusSubscription,
|
||||
ManagedSubscribeError, ManagedTargetConfig, ManagedTargetLifecycle, ManagedTargetStatus,
|
||||
TargetInstance,
|
||||
},
|
||||
session::machine::SessionMachine,
|
||||
socket_options::validate_ttl,
|
||||
ClientConfig, ClientError,
|
||||
};
|
||||
|
||||
use super::{
|
||||
arm_wake,
|
||||
target::{TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
|
||||
timeouts::TIMEOUT_WORK_BUDGET,
|
||||
ManagedClientTask, OutcomeHistory, TaskPhase, TaskResources,
|
||||
};
|
||||
|
||||
// Tokio 1.53 broadcast asserts this exact bound before rounding capacity to a
|
||||
// power of two; Tokio does not expose it as a public constant.
|
||||
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
|
||||
|
||||
/// Entry point for constructing a unified Tokio managed task.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Construct the task and its separate control handle, subscribe before
|
||||
/// starting the task, then run it under the caller's Tokio runtime. A reachable
|
||||
/// server is required at runtime, so this example only type-checks.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{ClientConfig, ClientEvent};
|
||||
/// use irtt_client::managed::{
|
||||
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
|
||||
/// };
|
||||
///
|
||||
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let config = ManagedClientConfig {
|
||||
/// client: ClientConfig {
|
||||
/// request: irtt_client::SessionRequest {
|
||||
/// duration: Some(std::time::Duration::from_secs(10)),
|
||||
/// ..irtt_client::SessionRequest::default()
|
||||
/// },
|
||||
/// ..ClientConfig::default()
|
||||
/// },
|
||||
/// ..ManagedClientConfig::default()
|
||||
/// };
|
||||
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
|
||||
/// let (task, handle) = ManagedClient::task(config, targets)?;
|
||||
/// let mut events = handle.subscribe()?;
|
||||
/// let driver = tokio::spawn(task);
|
||||
///
|
||||
/// while let Ok(event) = events.recv().await {
|
||||
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
|
||||
/// println!("received a probe reply");
|
||||
/// }
|
||||
/// }
|
||||
/// let outcome = driver.await?;
|
||||
/// println!("run ended: {:?}", outcome.end_reason);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManagedClient;
|
||||
|
||||
impl ManagedClient {
|
||||
/// Validate configuration and construct a runtime-independent task/handle pair.
|
||||
pub fn task(
|
||||
config: ManagedClientConfig,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
|
||||
build_task(config, targets)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct StopSignal {
|
||||
requested: AtomicBool,
|
||||
update_admission: AtomicU8,
|
||||
wake: watch::Sender<()>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub(super) enum UpdateAdmission {
|
||||
Open,
|
||||
Stopping,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl UpdateAdmission {
|
||||
fn from_raw(value: u8) -> Self {
|
||||
match value {
|
||||
value if value == Self::Open as u8 => Self::Open,
|
||||
value if value == Self::Stopping as u8 => Self::Stopping,
|
||||
value if value == Self::Closed as u8 => Self::Closed,
|
||||
_ => unreachable!("managed update admission stores a known state"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StopSignal {
|
||||
fn request(&self) {
|
||||
self.begin_stopping();
|
||||
if !self.requested.swap(true, Ordering::AcqRel) {
|
||||
self.wake.send_replace(());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_admission(&self) -> UpdateAdmission {
|
||||
UpdateAdmission::from_raw(self.update_admission.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
pub(super) fn begin_stopping(&self) {
|
||||
let mut current = self.update_admission.load(Ordering::Acquire);
|
||||
loop {
|
||||
match UpdateAdmission::from_raw(current) {
|
||||
UpdateAdmission::Open => match self.update_admission.compare_exchange_weak(
|
||||
UpdateAdmission::Open as u8,
|
||||
UpdateAdmission::Stopping as u8,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
},
|
||||
UpdateAdmission::Stopping | UpdateAdmission::Closed => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn close_updates(&self) {
|
||||
self.update_admission
|
||||
.store(UpdateAdmission::Closed as u8, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(super) fn is_requested(&self) -> bool {
|
||||
self.requested.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable control and observation capability for a managed task.
|
||||
#[derive(Clone)]
|
||||
pub struct ManagedClientHandle {
|
||||
stop: Arc<StopSignal>,
|
||||
commands: mpsc::Sender<ManagedCommand>,
|
||||
max_live_target_generations: usize,
|
||||
status: watch::Receiver<Arc<ManagedStatus>>,
|
||||
events: broadcast::WeakSender<ManagedEvent>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ManagedClientHandle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ManagedClientHandle")
|
||||
.field("status", &self.status())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedClientHandle {
|
||||
/// Return the latest durable immutable status snapshot.
|
||||
pub fn status(&self) -> Arc<ManagedStatus> {
|
||||
Arc::clone(&self.status.borrow())
|
||||
}
|
||||
|
||||
/// Subscribe to authoritative durable latest-state observation.
|
||||
///
|
||||
/// The current snapshot is marked as seen when subscribing.
|
||||
/// The current snapshot is immediately available through `borrow()`. Updates
|
||||
/// may coalesce; this is not a lossless event log. After the task terminates,
|
||||
/// the sender closes but the final snapshot remains readable. Use
|
||||
/// [`Self::subscribe`] for the lossy presentation-event stream.
|
||||
pub fn subscribe_status(&self) -> ManagedStatusSubscription {
|
||||
let mut status = self.status.clone();
|
||||
drop(status.borrow_and_update());
|
||||
status
|
||||
}
|
||||
|
||||
/// Subscribe to future lossy presentation events.
|
||||
pub fn subscribe(&self) -> Result<ManagedEventSubscription, ManagedSubscribeError> {
|
||||
self.events
|
||||
.upgrade()
|
||||
.map(|sender| sender.subscribe())
|
||||
.ok_or(ManagedSubscribeError::Closed)
|
||||
}
|
||||
|
||||
/// Request idempotent graceful stop and return a durable-status receipt.
|
||||
pub fn stop(&self) -> ManagedStopReceipt {
|
||||
self.stop.request();
|
||||
ManagedStopReceipt::new(self.subscribe_status())
|
||||
}
|
||||
|
||||
/// Submit the complete desired target set, replacing any previous one.
|
||||
///
|
||||
/// `targets` is not a delta: any live target whose id is missing from
|
||||
/// `targets` is stopped and removed. A target that is still present with an
|
||||
/// unchanged configuration continues undisturbed — it is not restarted and
|
||||
/// does not get a new generation. A target present with a *changed*
|
||||
/// configuration, or whose current generation already reached a terminal
|
||||
/// state (e.g. failed, completed, or was closed by the peer), is treated as
|
||||
/// a fresh start: it is retired and a new generation is created for it, even
|
||||
/// if the configuration is byte-for-byte identical to the one that just
|
||||
/// finished.
|
||||
///
|
||||
/// This call enqueues the update without waiting for queue capacity; await
|
||||
/// the returned [`ManagedCommandReceipt`] to observe whether — and how — it
|
||||
/// was applied.
|
||||
pub fn update_targets(
|
||||
&self,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<ManagedCommandReceipt, ManagedCommandError> {
|
||||
match self.stop.update_admission() {
|
||||
UpdateAdmission::Open => {}
|
||||
UpdateAdmission::Stopping => return Err(ManagedCommandError::Stopping),
|
||||
UpdateAdmission::Closed => return Err(ManagedCommandError::DriverClosed),
|
||||
}
|
||||
if targets.len() > self.max_live_target_generations {
|
||||
return Err(ManagedCommandError::TooManyTargets {
|
||||
configured: targets.len(),
|
||||
limit: self.max_live_target_generations,
|
||||
});
|
||||
}
|
||||
|
||||
// Observing `Open` only permits attempting submission. A successful
|
||||
// `try_send` transfers command ownership to the driver. A concurrent
|
||||
// stop may move admission to `Stopping` before this send, allowing the
|
||||
// command to enqueue; the driver-side check immediately before
|
||||
// `apply_targets` determines whether it applies or resolves as
|
||||
// `Stopping`. Terminal sealing closes the receiver before draining
|
||||
// accepted commands.
|
||||
let (acknowledgement, receiver) = oneshot::channel();
|
||||
match self.commands.try_send(ManagedCommand::UpdateTargets {
|
||||
targets,
|
||||
acknowledgement,
|
||||
}) {
|
||||
Ok(()) => Ok(ManagedCommandReceipt { receiver }),
|
||||
Err(mpsc::error::TrySendError::Full(_)) => match self.stop.update_admission() {
|
||||
UpdateAdmission::Open => Err(ManagedCommandError::QueueFull),
|
||||
UpdateAdmission::Stopping => Err(ManagedCommandError::Stopping),
|
||||
UpdateAdmission::Closed => Err(ManagedCommandError::DriverClosed),
|
||||
},
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => Err(ManagedCommandError::DriverClosed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receipt for a target-set transaction accepted by the driver queue.
|
||||
#[must_use = "await the receipt to observe target-update application"]
|
||||
pub struct ManagedCommandReceipt {
|
||||
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
||||
}
|
||||
|
||||
impl ManagedCommandReceipt {
|
||||
/// Block until the target-set transaction is applied or rejected.
|
||||
///
|
||||
/// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
|
||||
/// must not be called from an asynchronous runtime; use the [`Future`]
|
||||
/// implementation there instead.
|
||||
pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
|
||||
self.receiver
|
||||
.blocking_recv()
|
||||
.unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for ManagedCommandReceipt {
|
||||
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
match Pin::new(&mut self.receiver).poll(cx) {
|
||||
Poll::Ready(Ok(result)) => Poll::Ready(result),
|
||||
Poll::Ready(Err(_)) => {
|
||||
Poll::Ready(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum ManagedCommand {
|
||||
UpdateTargets {
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
acknowledgement:
|
||||
oneshot::Sender<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Receipt resolving once stop is durably observed or the task is terminal.
|
||||
///
|
||||
/// Resolves from authoritative status as soon as `stop_requested` is true or
|
||||
/// the lifecycle is Completed, Failed, or Abandoned. It need not wait for
|
||||
/// graceful cleanup after stop observation.
|
||||
#[must_use = "await the receipt to observe durable stop acknowledgement"]
|
||||
pub struct ManagedStopReceipt {
|
||||
future: Pin<Box<dyn Future<Output = ()> + Send>>,
|
||||
}
|
||||
|
||||
impl ManagedStopReceipt {
|
||||
fn new(mut status: ManagedStatusSubscription) -> Self {
|
||||
let future = Box::pin(async move {
|
||||
while !{
|
||||
let snapshot = status.borrow();
|
||||
snapshot.stop_requested
|
||||
|| matches!(
|
||||
snapshot.lifecycle,
|
||||
ManagedLifecycle::Completed
|
||||
| ManagedLifecycle::Failed
|
||||
| ManagedLifecycle::Abandoned
|
||||
)
|
||||
} {
|
||||
// A closed sender retains the terminal snapshot. Never wait
|
||||
// indefinitely if the channel closes.
|
||||
if status.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { future }
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for ManagedStopReceipt {
|
||||
type Output = ();
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
self.future.as_mut().poll(cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_task(
|
||||
config: ManagedClientConfig,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
|
||||
if config.event_capacity == 0 {
|
||||
return Err(ManagedConfigError::ZeroEventCapacity);
|
||||
}
|
||||
if config.event_capacity > MAX_BROADCAST_CHANNEL_CAPACITY {
|
||||
return Err(ManagedConfigError::EventCapacityTooLarge {
|
||||
configured: config.event_capacity,
|
||||
maximum: MAX_BROADCAST_CHANNEL_CAPACITY,
|
||||
});
|
||||
}
|
||||
if config.command_capacity == 0 {
|
||||
return Err(ManagedConfigError::ZeroCommandCapacity);
|
||||
}
|
||||
if config.command_capacity > tokio::sync::Semaphore::MAX_PERMITS {
|
||||
return Err(ManagedConfigError::CommandCapacityTooLarge {
|
||||
configured: config.command_capacity,
|
||||
maximum: tokio::sync::Semaphore::MAX_PERMITS,
|
||||
});
|
||||
}
|
||||
if config.max_live_target_generations == 0 {
|
||||
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
|
||||
}
|
||||
if Instant::now().checked_add(config.final_drain).is_none() {
|
||||
return Err(ManagedConfigError::UnschedulableFinalDrain {
|
||||
duration: config.final_drain,
|
||||
});
|
||||
}
|
||||
if targets.len() > config.max_live_target_generations {
|
||||
return Err(ManagedConfigError::TooManyTargets {
|
||||
configured: targets.len(),
|
||||
limit: config.max_live_target_generations,
|
||||
});
|
||||
}
|
||||
if targets.is_empty() && config.completion == ManagedCompletionPolicy::FinishWhenQuiescent {
|
||||
return Err(ManagedConfigError::EmptyInitialTargets);
|
||||
}
|
||||
|
||||
let mut ids = HashSet::with_capacity(targets.len());
|
||||
let mut runtimes = Vec::with_capacity(targets.len());
|
||||
let mut next_generation = 1_u64;
|
||||
for target in targets {
|
||||
if !ids.insert(target.id.clone()) {
|
||||
return Err(ManagedConfigError::DuplicateTargetId { id: target.id });
|
||||
}
|
||||
let generation = next_generation;
|
||||
next_generation = next_generation
|
||||
.checked_add(1)
|
||||
.ok_or(ManagedConfigError::GenerationExhausted)?;
|
||||
let mut client_config = config.client.clone();
|
||||
client_config.address_family = target
|
||||
.address_family
|
||||
.unwrap_or(config.client.address_family);
|
||||
client_config.auth = target.auth.resolve(&config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedConfigError::InvalidTarget {
|
||||
id: target.id.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
runtimes.push(TargetRuntime {
|
||||
instance: TargetInstance {
|
||||
id: target.id.clone(),
|
||||
generation,
|
||||
},
|
||||
config: target.clone(),
|
||||
membership: TargetMembership::Desired,
|
||||
server_addr: Arc::from(target.server_addr),
|
||||
remote: None,
|
||||
counters: TargetCounters::default(),
|
||||
phase: Some(TargetPhase::Pending { client_config }),
|
||||
});
|
||||
}
|
||||
|
||||
let (event_sender, _) = broadcast::channel(config.event_capacity);
|
||||
let weak_events = event_sender.downgrade();
|
||||
let (wake_sender, wake_receiver) = watch::channel(());
|
||||
let stop = Arc::new(StopSignal {
|
||||
requested: AtomicBool::new(false),
|
||||
update_admission: AtomicU8::new(UpdateAdmission::Open as u8),
|
||||
wake: wake_sender,
|
||||
});
|
||||
let history = OutcomeHistory::new(config.outcome_history_limit);
|
||||
let initial_targets = runtimes
|
||||
.iter()
|
||||
.map(|target| ManagedTargetStatus {
|
||||
target: target.instance.clone(),
|
||||
desired: target.membership.is_desired(),
|
||||
lifecycle: ManagedTargetLifecycle::Pending,
|
||||
outcome: None,
|
||||
server_addr: Arc::clone(&target.server_addr),
|
||||
remote: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let phase = TaskPhase::NotStarted;
|
||||
let initial = Arc::new(ManagedStatus {
|
||||
lifecycle: phase.lifecycle(),
|
||||
stop_requested: false,
|
||||
applied_command_sequence: 0,
|
||||
desired_target_count: runtimes.len(),
|
||||
connecting_target_count: 0,
|
||||
opening_target_count: 0,
|
||||
active_target_count: 0,
|
||||
draining_target_count: 0,
|
||||
closing_target_count: 0,
|
||||
terminal_target_count: 0,
|
||||
total_target_outcomes: 0,
|
||||
successful_target_outcomes: 0,
|
||||
failed_target_outcomes: 0,
|
||||
peer_closed_target_outcomes: 0,
|
||||
discarded_target_outcomes: 0,
|
||||
targets: Arc::from(initial_targets.into_boxed_slice()),
|
||||
recent_target_outcomes: history.recent(),
|
||||
final_outcome: phase.final_outcome(),
|
||||
});
|
||||
let (status_sender, status_receiver) = watch::channel(initial);
|
||||
let (command_sender, command_receiver) = mpsc::channel(config.command_capacity);
|
||||
let max_live_target_generations = config.max_live_target_generations;
|
||||
let resources = TaskResources {
|
||||
status: status_sender,
|
||||
events: Some(event_sender),
|
||||
stop: Arc::clone(&stop),
|
||||
};
|
||||
let task = ManagedClientTask {
|
||||
#[cfg(test)]
|
||||
timeout_inspections: std::cell::RefCell::new(Vec::new()),
|
||||
phase,
|
||||
config,
|
||||
targets: runtimes,
|
||||
commands: command_receiver,
|
||||
history,
|
||||
resources: Some(resources),
|
||||
wake: Some(arm_wake(wake_receiver)),
|
||||
timer: None,
|
||||
cursor: 0,
|
||||
timeout_cursor: 0,
|
||||
timeout_backlog: VecDeque::with_capacity(TIMEOUT_WORK_BUDGET),
|
||||
scan_remaining: 0,
|
||||
send_cursor: 0,
|
||||
burst_remaining: 0,
|
||||
stagger_remaining: 0,
|
||||
send_gate: None,
|
||||
last_stagger_send: None,
|
||||
stop_observed: false,
|
||||
next_generation,
|
||||
applied_command_sequence: 0,
|
||||
};
|
||||
let handle = ManagedClientHandle {
|
||||
stop,
|
||||
commands: command_sender,
|
||||
max_live_target_generations,
|
||||
status: status_receiver,
|
||||
events: weak_events,
|
||||
};
|
||||
Ok((task, handle))
|
||||
}
|
||||
|
||||
pub(super) fn validate_target_config(config: &ClientConfig) -> Result<(), ClientError> {
|
||||
config.open.validate()?;
|
||||
SessionMachine::validate_config(config)?;
|
||||
if let Some(ttl) = config.socket.ttl {
|
||||
validate_ttl(ttl)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Burst/stagger send selection and committed-send cadence.
|
||||
|
||||
use std::{
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
managed::{schedule::instant_abs_diff, ManagedTargetEndReason, ManagedTargetFailurePhase},
|
||||
ClientEvent, SendProbeError,
|
||||
};
|
||||
|
||||
use super::{
|
||||
target::{OpenSessionFailureCleanup, TargetPhase},
|
||||
ManagedClientTask, TARGET_WORK_BUDGET,
|
||||
};
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn active_count(&self) -> usize {
|
||||
self.targets
|
||||
.iter()
|
||||
.filter(|target| target.is_paced_active())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn active_stagger_spacing(&self) -> Option<Duration> {
|
||||
let mut active = 0;
|
||||
let mut minimum: Option<Duration> = None;
|
||||
for target in &self.targets {
|
||||
let TargetPhase::Active { schedule, .. } = target.phase() else {
|
||||
continue;
|
||||
};
|
||||
if !target.membership.is_desired() {
|
||||
continue;
|
||||
}
|
||||
active += 1;
|
||||
minimum = minimum.into_iter().chain(Some(schedule.interval())).min();
|
||||
}
|
||||
minimum.map(|interval| stagger_spacing(interval, active))
|
||||
}
|
||||
|
||||
pub(super) fn stagger_target_added(&mut self, now: Instant) {
|
||||
let Some(existing) = self.send_gate.filter(|gate| *gate > now) else {
|
||||
self.send_gate = None;
|
||||
return;
|
||||
};
|
||||
let candidate = self
|
||||
.last_stagger_send
|
||||
.zip(self.active_stagger_spacing())
|
||||
.and_then(|(last, spacing)| last.checked_add(spacing))
|
||||
.filter(|gate| *gate > now);
|
||||
self.send_gate = candidate.map(|candidate| existing.min(candidate));
|
||||
}
|
||||
|
||||
pub(super) fn stagger_target_removed(&mut self, now: Instant) {
|
||||
self.send_gate = self.send_gate.filter(|gate| *gate > now);
|
||||
}
|
||||
|
||||
/// The next stagger gate after a send was accepted at `accepted_at`.
|
||||
///
|
||||
/// The gate advances on its **own** cadence rather than being re-anchored
|
||||
/// on every observed accept time. Anchoring on `accepted_at` folds each
|
||||
/// send's wakeup latency into the next slot and then into every slot after
|
||||
/// it, so the gate — and with it the whole group's send cadence — drifts
|
||||
/// later without bound while the per-target schedules stay on their
|
||||
/// absolute grid. The drift is not hypothetical: it shows up as a
|
||||
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
|
||||
/// interval, as probe slots the schedule then skips.
|
||||
///
|
||||
/// `accepted_at` is still the anchor in the two cases where continuing the
|
||||
/// old cadence would be wrong: there is no previous gate, or the pacer has
|
||||
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
|
||||
/// stranded in the past from releasing a burst of catch-up sends once the
|
||||
/// group starts moving again.
|
||||
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
|
||||
let base = match self.send_gate {
|
||||
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
|
||||
_ => accepted_at,
|
||||
};
|
||||
base.checked_add(spacing)
|
||||
}
|
||||
|
||||
fn record_stagger_acceptance(
|
||||
&mut self,
|
||||
result: SendResult,
|
||||
stagger_spacing: Option<Duration>,
|
||||
accepted_at: Instant,
|
||||
) {
|
||||
if !result.accepted() {
|
||||
return;
|
||||
}
|
||||
let Some(spacing) = stagger_spacing else {
|
||||
return;
|
||||
};
|
||||
self.last_stagger_send = Some(accepted_at);
|
||||
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
|
||||
}
|
||||
|
||||
fn poll_one_send(
|
||||
&mut self,
|
||||
index: usize,
|
||||
cx: &mut Context<'_>,
|
||||
now: Instant,
|
||||
stagger_spacing: Option<Duration>,
|
||||
) -> SendResult {
|
||||
if !self.targets[index].membership.is_desired() {
|
||||
if let TargetPhase::Active { send_waiting, .. } = self.targets[index].phase_mut() {
|
||||
*send_waiting = false;
|
||||
}
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
let phase = self.targets[index].take_phase();
|
||||
let TargetPhase::Active {
|
||||
mut client,
|
||||
mut schedule,
|
||||
mut send_waiting,
|
||||
} = phase
|
||||
else {
|
||||
self.targets[index].restore_phase(phase);
|
||||
return SendResult::NotAttempted;
|
||||
};
|
||||
// A burst pass may span several slots; validate this target against
|
||||
// fresh time rather than the pass's earlier scheduling snapshot.
|
||||
let now = now.max(Instant::now());
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
{
|
||||
send_waiting = false;
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
if !schedule.permit_probe_at(now) {
|
||||
send_waiting = false;
|
||||
let pending = client.has_pending_probes();
|
||||
if !pending {
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
}
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
if pending {
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
|
||||
return SendResult::Ready { accepted: false };
|
||||
}
|
||||
if schedule
|
||||
.next_send_deadline()
|
||||
.is_none_or(|deadline| deadline > now)
|
||||
{
|
||||
send_waiting = false;
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
let commit =
|
||||
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => {
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Drain,
|
||||
);
|
||||
return SendResult::Failed { accepted: false };
|
||||
}
|
||||
};
|
||||
let scheduled_at = commit.scheduled_at;
|
||||
let result = client.poll_send_probe(cx);
|
||||
let receipt = match &result {
|
||||
Poll::Ready(Ok(receipt))
|
||||
| Poll::Ready(Err(SendProbeError::AfterCommit { receipt, .. })) => Some(*receipt),
|
||||
Poll::Pending | Poll::Ready(Err(SendProbeError::NotCommitted(_))) => None,
|
||||
};
|
||||
let accepted = receipt.is_some();
|
||||
let send_result = match &result {
|
||||
Poll::Pending => SendResult::Pending,
|
||||
Poll::Ready(Ok(_)) => SendResult::Ready { accepted },
|
||||
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
|
||||
};
|
||||
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
|
||||
if accepted {
|
||||
schedule.commit(commit);
|
||||
}
|
||||
let schedule_error = if accepted {
|
||||
schedule.skip_missed_slots_at(Instant::now()).err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
send_waiting = result.is_pending();
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
// A committed send must reach the lifecycle/accounting stream even
|
||||
// when post-send processing fails. Publish before failure cleanup.
|
||||
let sent_event = receipt.map(|receipt| {
|
||||
let mut event = ClientEvent::from(receipt);
|
||||
if let ClientEvent::EchoSent {
|
||||
scheduled_at: intended,
|
||||
timer_error,
|
||||
..
|
||||
} = &mut event
|
||||
{
|
||||
*intended = Some(scheduled_at);
|
||||
*timer_error = Some(instant_abs_diff(receipt.sent_at.mono, scheduled_at));
|
||||
}
|
||||
event
|
||||
});
|
||||
match result {
|
||||
Poll::Pending => SendResult::Pending,
|
||||
Poll::Ready(Ok(_)) => {
|
||||
self.publish_client_events(index, vec![sent_event.unwrap()]);
|
||||
if let Some(error) = schedule_error {
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Drain,
|
||||
);
|
||||
SendResult::Failed { accepted }
|
||||
} else {
|
||||
SendResult::Ready { accepted }
|
||||
}
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
if let Some(event) = sent_event {
|
||||
self.publish_client_events(index, vec![event]);
|
||||
}
|
||||
let error = match error {
|
||||
SendProbeError::NotCommitted(source) => source,
|
||||
SendProbeError::AfterCommit { source, .. } => *source,
|
||||
};
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Sending,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Drain,
|
||||
);
|
||||
SendResult::Failed { accepted }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll_staggered_send(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||
if self.active_count() == 0 || self.send_gate.is_some_and(|gate| gate > now) {
|
||||
return false;
|
||||
}
|
||||
if self.stagger_remaining == 0 {
|
||||
self.stagger_remaining = self.targets.len();
|
||||
}
|
||||
let work = self.stagger_remaining.min(TARGET_WORK_BUDGET);
|
||||
for _ in 0..work {
|
||||
let index = self.send_cursor % self.targets.len();
|
||||
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
||||
self.stagger_remaining -= 1;
|
||||
if !self.targets[index].is_paced_active() {
|
||||
continue;
|
||||
}
|
||||
let stagger_spacing = self.active_stagger_spacing();
|
||||
let result = self.poll_one_send(index, cx, now, stagger_spacing);
|
||||
if result == SendResult::NotAttempted {
|
||||
continue;
|
||||
}
|
||||
self.stagger_remaining = 0;
|
||||
return matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
||||
}
|
||||
self.stagger_remaining > 0
|
||||
}
|
||||
|
||||
pub(super) fn poll_burst_sends(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||
if self.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if self.burst_remaining == 0 {
|
||||
self.burst_remaining = self.targets.len();
|
||||
}
|
||||
let work = self.burst_remaining.min(TARGET_WORK_BUDGET);
|
||||
let mut immediate = false;
|
||||
for _ in 0..work {
|
||||
let index = self.send_cursor % self.targets.len();
|
||||
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
||||
self.burst_remaining -= 1;
|
||||
let result = self.poll_one_send(index, cx, now, None);
|
||||
immediate |= matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
||||
}
|
||||
immediate || self.burst_remaining > 0
|
||||
}
|
||||
}
|
||||
|
||||
// A release gate delays a send opportunity; it cannot create one.
|
||||
pub(super) fn gated_send_deadline(
|
||||
cadence: Option<Instant>,
|
||||
gate: Option<Instant>,
|
||||
) -> Option<Instant> {
|
||||
cadence.map(|deadline| gate.map_or(deadline, |gate| deadline.max(gate)))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum SendResult {
|
||||
NotAttempted,
|
||||
Pending,
|
||||
Ready { accepted: bool },
|
||||
Failed { accepted: bool },
|
||||
}
|
||||
|
||||
impl SendResult {
|
||||
fn accepted(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ready { accepted: true } | Self::Failed { accepted: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn stagger_spacing(interval: Duration, active_targets: usize) -> Duration {
|
||||
let divisor = u128::try_from(active_targets.max(1)).unwrap_or(u128::MAX);
|
||||
let nanos = (interval.as_nanos() / divisor).max(1);
|
||||
let seconds = u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX);
|
||||
let subsec = u32::try_from(nanos % 1_000_000_000).unwrap_or(999_999_999);
|
||||
Duration::new(seconds, subsec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_gate_only_delays_an_existing_send_deadline() {
|
||||
let earlier = Instant::now();
|
||||
let later = earlier + Duration::from_secs(1);
|
||||
for (cadence, gate, expected) in [
|
||||
(None, None, None),
|
||||
(None, Some(earlier), None),
|
||||
(None, Some(later), None),
|
||||
(Some(earlier), None, Some(earlier)),
|
||||
(Some(later), Some(earlier), Some(later)),
|
||||
(Some(earlier), Some(earlier), Some(earlier)),
|
||||
(Some(earlier), Some(later), Some(later)),
|
||||
] {
|
||||
assert_eq!(gated_send_deadline(cadence, gate), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
//! Target-generation state, phase transitions, accounting, and completion.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
async_client::{AsyncClient, AsyncOpenState},
|
||||
managed::schedule::ProbeSchedule,
|
||||
managed::{
|
||||
classify_client_error, ManagedEvent, ManagedTargetConfig, ManagedTargetEndReason,
|
||||
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase,
|
||||
ManagedTargetLifecycle, ManagedTargetOutcome, TargetInstance,
|
||||
},
|
||||
ClientConfig, ClientError, ClientEvent, OpenOutcome,
|
||||
};
|
||||
|
||||
use super::{ManagedClientTask, TARGET_WORK_BUDGET};
|
||||
|
||||
const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
|
||||
|
||||
type ConnectFuture =
|
||||
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
|
||||
|
||||
pub(super) enum TargetPhase {
|
||||
Pending {
|
||||
client_config: ClientConfig,
|
||||
},
|
||||
Connecting {
|
||||
future: ConnectFuture,
|
||||
},
|
||||
Opening {
|
||||
client: AsyncClient,
|
||||
open: Box<AsyncOpenState>,
|
||||
},
|
||||
Active {
|
||||
client: AsyncClient,
|
||||
schedule: ProbeSchedule,
|
||||
send_waiting: bool,
|
||||
},
|
||||
Draining {
|
||||
client: AsyncClient,
|
||||
drain_started_at: Instant,
|
||||
deadline: Instant,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
post_deadline_receives_remaining: usize,
|
||||
},
|
||||
Closing {
|
||||
client: AsyncClient,
|
||||
deadline: Instant,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
},
|
||||
/// Terminal state always owns its finalized authoritative outcome.
|
||||
Terminal {
|
||||
outcome: Arc<ManagedTargetOutcome>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TargetPhase {
|
||||
pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle {
|
||||
match self {
|
||||
Self::Pending { .. } => ManagedTargetLifecycle::Pending,
|
||||
Self::Connecting { .. } => ManagedTargetLifecycle::Connecting,
|
||||
Self::Opening { .. } => ManagedTargetLifecycle::Opening,
|
||||
Self::Active { .. } => ManagedTargetLifecycle::Active,
|
||||
Self::Draining { .. } => ManagedTargetLifecycle::Draining,
|
||||
Self::Closing { .. } => ManagedTargetLifecycle::Closing,
|
||||
Self::Terminal { .. } => ManagedTargetLifecycle::Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn client(&self) -> &AsyncClient {
|
||||
match self {
|
||||
Self::Opening { client, .. }
|
||||
| Self::Active { client, .. }
|
||||
| Self::Draining { client, .. }
|
||||
| Self::Closing { client, .. } => client,
|
||||
_ => panic!("managed target has no client in this phase"),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_client(self) -> AsyncClient {
|
||||
match self {
|
||||
Self::Opening { client, .. }
|
||||
| Self::Active { client, .. }
|
||||
| Self::Draining { client, .. }
|
||||
| Self::Closing { client, .. } => client,
|
||||
_ => panic!("managed target has no client in this phase"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct TargetCounters {
|
||||
packets_sent: u64,
|
||||
/// Unique echo replies that arrived in order and before their probe timed
|
||||
/// out.
|
||||
///
|
||||
/// A reply that arrives behind an already-received sequence, or after its
|
||||
/// own probe timed out, is counted by `late` and deliberately not here.
|
||||
/// These counters are durable, unlike the lossy managed presentation
|
||||
/// events.
|
||||
replies_received: u64,
|
||||
duplicates: u64,
|
||||
late: u64,
|
||||
warning_events: u64,
|
||||
}
|
||||
|
||||
impl TargetCounters {
|
||||
/// Account for one client event.
|
||||
///
|
||||
/// Events that carry no durable target-local count are ignored. Probe
|
||||
/// sends are not counted here: `packets_sent` is authoritative in the
|
||||
/// underlying client and is synchronized through
|
||||
/// [`TargetRuntime::sync_packets_sent`].
|
||||
fn observe(&mut self, event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::EchoReply { .. } => {
|
||||
self.replies_received = self.replies_received.saturating_add(1);
|
||||
}
|
||||
ClientEvent::DuplicateReply { .. } => {
|
||||
self.duplicates = self.duplicates.saturating_add(1);
|
||||
}
|
||||
ClientEvent::LateReply { .. } => {
|
||||
self.late = self.late.saturating_add(1);
|
||||
}
|
||||
ClientEvent::Warning { .. } => {
|
||||
self.warning_events = self.warning_events.saturating_add(1);
|
||||
}
|
||||
ClientEvent::EchoSent { .. }
|
||||
| ClientEvent::EchoLoss { .. }
|
||||
| ClientEvent::SessionStarted(_)
|
||||
| ClientEvent::NoTestCompleted(_)
|
||||
| ClientEvent::SessionClosed { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum RetirementReason {
|
||||
Removed,
|
||||
Replaced,
|
||||
}
|
||||
|
||||
impl From<RetirementReason> for ManagedTargetEndReason {
|
||||
fn from(reason: RetirementReason) -> Self {
|
||||
match reason {
|
||||
RetirementReason::Removed => Self::Removed,
|
||||
RetirementReason::Replaced => Self::Replaced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum TargetMembership {
|
||||
Desired,
|
||||
Withdrawn(RetirementReason),
|
||||
}
|
||||
|
||||
impl TargetMembership {
|
||||
pub(super) fn is_desired(&self) -> bool {
|
||||
matches!(self, Self::Desired)
|
||||
}
|
||||
|
||||
fn retirement_reason(&self) -> Option<RetirementReason> {
|
||||
match self {
|
||||
Self::Desired => None,
|
||||
Self::Withdrawn(reason) => Some(*reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct TargetRuntime {
|
||||
pub(super) instance: TargetInstance,
|
||||
pub(super) config: ManagedTargetConfig,
|
||||
pub(super) membership: TargetMembership,
|
||||
pub(super) server_addr: Arc<str>,
|
||||
pub(super) remote: Option<std::net::SocketAddr>,
|
||||
pub(super) counters: TargetCounters,
|
||||
// None only while a synchronous operation owns the phase locally.
|
||||
pub(super) phase: Option<TargetPhase>,
|
||||
}
|
||||
|
||||
impl TargetRuntime {
|
||||
pub(super) fn phase(&self) -> &TargetPhase {
|
||||
self.phase
|
||||
.as_ref()
|
||||
.expect("managed target phase is extracted")
|
||||
}
|
||||
|
||||
pub(super) fn phase_mut(&mut self) -> &mut TargetPhase {
|
||||
self.phase
|
||||
.as_mut()
|
||||
.expect("managed target phase is extracted")
|
||||
}
|
||||
|
||||
pub(super) fn take_phase(&mut self) -> TargetPhase {
|
||||
self.phase
|
||||
.take()
|
||||
.expect("managed target phase is extracted")
|
||||
}
|
||||
|
||||
pub(super) fn restore_phase(&mut self, phase: TargetPhase) {
|
||||
assert!(
|
||||
self.phase.is_none(),
|
||||
"managed target phase is already installed"
|
||||
);
|
||||
self.phase = Some(phase);
|
||||
}
|
||||
|
||||
pub(super) fn is_paced_active(&self) -> bool {
|
||||
let phase = self.phase();
|
||||
self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. })
|
||||
}
|
||||
|
||||
/// Adopt the underlying client's authoritative sent count.
|
||||
///
|
||||
/// The client owns `packets_sent`; this runtime only mirrors it so a
|
||||
/// terminal outcome can report it after the client is gone.
|
||||
pub(super) fn sync_packets_sent(&mut self, client: &AsyncClient) {
|
||||
self.counters.packets_sent = client.packets_sent();
|
||||
}
|
||||
|
||||
pub(super) fn sync_live_packets_sent(&mut self) {
|
||||
self.counters.packets_sent = self.phase().client().packets_sent();
|
||||
}
|
||||
|
||||
/// Build this target's durable outcome.
|
||||
pub(super) fn outcome(
|
||||
&self,
|
||||
end_reason: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
) -> ManagedTargetOutcome {
|
||||
ManagedTargetOutcome {
|
||||
target: self.instance.clone(),
|
||||
server_addr: Arc::clone(&self.server_addr),
|
||||
remote: self.remote,
|
||||
end_reason,
|
||||
packets_sent: self.counters.packets_sent,
|
||||
replies_received: self.counters.replies_received,
|
||||
duplicates: self.counters.duplicates,
|
||||
late: self.counters.late,
|
||||
warning_events: self.counters.warning_events,
|
||||
cleanup_failure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum OpenSessionFailureCleanup {
|
||||
Drain,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn install_target_phase(&mut self, index: usize, phase: TargetPhase) {
|
||||
let previous = self.targets[index].phase().lifecycle();
|
||||
self.targets[index].phase = Some(phase);
|
||||
self.publish_target_phase(index, previous);
|
||||
}
|
||||
|
||||
fn publish_target_phase(&mut self, index: usize, previous: ManagedTargetLifecycle) {
|
||||
let lifecycle = self.targets[index].phase().lifecycle();
|
||||
let desired = self.targets[index].membership.is_desired();
|
||||
let was_paced_active = desired && previous == ManagedTargetLifecycle::Active;
|
||||
let paced_active = self.targets[index].is_paced_active();
|
||||
let now = Instant::now();
|
||||
match (was_paced_active, paced_active) {
|
||||
(false, true) => self.stagger_target_added(now),
|
||||
(true, false) => self.stagger_target_removed(now),
|
||||
_ => {}
|
||||
}
|
||||
self.replace_status();
|
||||
self.publish_event(ManagedEvent::TargetStateChanged {
|
||||
target: self.targets[index].instance.clone(),
|
||||
lifecycle,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
|
||||
for event in events {
|
||||
self.targets[index].counters.observe(&event);
|
||||
self.publish_event(ManagedEvent::Client {
|
||||
target: self.targets[index].instance.clone(),
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_target(
|
||||
&mut self,
|
||||
index: usize,
|
||||
end_reason: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
) {
|
||||
let outcome = Arc::new(self.targets[index].outcome(end_reason, cleanup_failure));
|
||||
// Finalize history before the phase publisher installs Terminal, updates
|
||||
// pacing and publishes one complete status followed by the lifecycle event.
|
||||
self.history.record(outcome.as_ref().clone());
|
||||
self.install_target_phase(
|
||||
index,
|
||||
TargetPhase::Terminal {
|
||||
outcome: Arc::clone(&outcome),
|
||||
},
|
||||
);
|
||||
self.publish_event(ManagedEvent::TargetFinished { outcome });
|
||||
}
|
||||
|
||||
fn fail_target(&mut self, index: usize, phase: ManagedTargetFailurePhase, error: ClientError) {
|
||||
let failure = classify_client_error(phase, &error);
|
||||
self.finish_target(index, ManagedTargetEndReason::Failed(failure), None);
|
||||
}
|
||||
|
||||
pub(super) fn begin_open_session_failure(
|
||||
&mut self,
|
||||
index: usize,
|
||||
phase: ManagedTargetFailurePhase,
|
||||
error: ClientError,
|
||||
now: Instant,
|
||||
cleanup: OpenSessionFailureCleanup,
|
||||
) -> bool {
|
||||
let primary_end = ManagedTargetEndReason::Failed(classify_client_error(phase, &error));
|
||||
let (TargetPhase::Opening { client, .. } | TargetPhase::Active { client, .. }) =
|
||||
self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!("open-session failure requires an opening or active target")
|
||||
};
|
||||
client.discard_prepared_probe();
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
match cleanup {
|
||||
OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now),
|
||||
OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn effective_retirement(&self, index: usize) -> Option<ManagedTargetEndReason> {
|
||||
self.targets[index]
|
||||
.membership
|
||||
.retirement_reason()
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn start_connecting(&mut self, index: usize) {
|
||||
let TargetPhase::Pending { client_config } = self.targets[index].take_phase() else {
|
||||
unreachable!("connecting starts from a pending target")
|
||||
};
|
||||
let endpoint = Arc::clone(&self.targets[index].server_addr);
|
||||
let future = Box::pin(async move { AsyncClient::connect(endpoint, client_config).await });
|
||||
self.targets[index].restore_phase(TargetPhase::Connecting { future });
|
||||
self.publish_target_phase(index, ManagedTargetLifecycle::Pending);
|
||||
}
|
||||
|
||||
pub(super) fn poll_target(&mut self, index: usize, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||
let retirement = self.effective_retirement(index);
|
||||
let stopping = self.phase.is_stopping() || retirement.is_some();
|
||||
match self.targets[index].phase_mut() {
|
||||
TargetPhase::Pending { .. } => {
|
||||
if stopping {
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
None,
|
||||
);
|
||||
false
|
||||
} else {
|
||||
self.start_connecting(index);
|
||||
true
|
||||
}
|
||||
}
|
||||
TargetPhase::Connecting { future } => {
|
||||
if stopping {
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
None,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
match future.as_mut().poll(cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(client)) => {
|
||||
self.targets[index].remote = Some(client.remote_addr());
|
||||
self.install_target_phase(
|
||||
index,
|
||||
TargetPhase::Opening {
|
||||
client,
|
||||
open: Box::new(AsyncOpenState::new()),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.fail_target(index, ManagedTargetFailurePhase::Connecting, error);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
TargetPhase::Opening { client, open } => {
|
||||
if stopping {
|
||||
let retirement = retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped);
|
||||
if !open.has_in_flight_work() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(index, retirement, None);
|
||||
return false;
|
||||
}
|
||||
open.request_stop_after_current_attempt();
|
||||
}
|
||||
match client.poll_open(open, cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(OpenOutcome::Started(started))) => {
|
||||
let schedule = match ProbeSchedule::new(
|
||||
started.at.mono,
|
||||
&started.negotiation.accepted,
|
||||
) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(error) => {
|
||||
return self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Close,
|
||||
)
|
||||
}
|
||||
};
|
||||
self.publish_client_events(
|
||||
index,
|
||||
vec![ClientEvent::SessionStarted(started)],
|
||||
);
|
||||
if stopping {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.begin_drain(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
now,
|
||||
)
|
||||
} else {
|
||||
let previous = self.targets[index].take_phase();
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client: previous.into_client(),
|
||||
schedule,
|
||||
send_waiting: false,
|
||||
});
|
||||
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
|
||||
true
|
||||
}
|
||||
}
|
||||
Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => {
|
||||
self.publish_client_events(
|
||||
index,
|
||||
vec![ClientEvent::NoTestCompleted(completed)],
|
||||
);
|
||||
let end_reason = if stopping {
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped)
|
||||
} else {
|
||||
ManagedTargetEndReason::NoTestComplete
|
||||
};
|
||||
self.finish_target(index, end_reason, None);
|
||||
false
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
if stopping {
|
||||
let TargetPhase::Opening { open, .. } = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let cleanup_failure =
|
||||
(!open.stopped_after_current_attempt()).then(|| {
|
||||
classify_client_error(
|
||||
ManagedTargetFailurePhase::Opening,
|
||||
&error,
|
||||
)
|
||||
});
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
cleanup_failure,
|
||||
);
|
||||
} else {
|
||||
self.fail_target(index, ManagedTargetFailurePhase::Opening, error);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
TargetPhase::Active { client, .. } => {
|
||||
if stopping {
|
||||
client.discard_prepared_probe();
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
return self.begin_drain(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
now,
|
||||
);
|
||||
}
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let received = match client.poll_recv(cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(events)) => {
|
||||
self.publish_client_events(index, events);
|
||||
true
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
return self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Receiving,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Close,
|
||||
);
|
||||
}
|
||||
};
|
||||
if self.targets[index].phase().client().is_peer_closed() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
|
||||
return false;
|
||||
}
|
||||
let TargetPhase::Active {
|
||||
client, schedule, ..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
schedule.permit_probe_at(now);
|
||||
if schedule.is_finished() && !client.has_pending_probes() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
return self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
|
||||
}
|
||||
received
|
||||
}
|
||||
TargetPhase::Draining { client, .. } => {
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let mut retained_state_changed = false;
|
||||
let received = match client.poll_recv(cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(events)) => {
|
||||
self.publish_client_events(index, events);
|
||||
retained_state_changed = true;
|
||||
true
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let TargetPhase::Draining {
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
cleanup_failure.get_or_insert_with(|| {
|
||||
classify_client_error(ManagedTargetFailurePhase::Receiving, &error)
|
||||
});
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
return self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
}
|
||||
};
|
||||
if self.targets[index].phase().client().is_peer_closed() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let TargetPhase::Draining {
|
||||
cleanup_failure, ..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
self.finish_target(
|
||||
index,
|
||||
ManagedTargetEndReason::PeerClosed,
|
||||
cleanup_failure.clone(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if retained_state_changed {
|
||||
let TargetPhase::Draining {
|
||||
client,
|
||||
drain_started_at,
|
||||
..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let candidate = self.drain_deadline(client, *drain_started_at);
|
||||
let TargetPhase::Draining {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
match candidate {
|
||||
Some(candidate) => *deadline = (*deadline).min(candidate),
|
||||
None => {
|
||||
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
return self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
let TargetPhase::Draining {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
post_deadline_receives_remaining,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if now >= *deadline {
|
||||
if received && *post_deadline_receives_remaining > 1 {
|
||||
*post_deadline_receives_remaining -= 1;
|
||||
true
|
||||
} else {
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
self.begin_close(index, primary_end, cleanup_failure, now)
|
||||
}
|
||||
} else {
|
||||
received
|
||||
}
|
||||
}
|
||||
TargetPhase::Closing { client, .. } => {
|
||||
let result = client.poll_close(cx);
|
||||
let TargetPhase::Closing {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let deadline = *deadline;
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
match result {
|
||||
Poll::Pending => {
|
||||
if now >= deadline {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(
|
||||
index,
|
||||
primary_end,
|
||||
cleanup_failure.or_else(|| Some(close_timeout_failure())),
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
Poll::Ready(Ok(events)) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.publish_client_events(index, events);
|
||||
self.finish_target(index, primary_end, cleanup_failure);
|
||||
false
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let cleanup =
|
||||
classify_client_error(ManagedTargetFailurePhase::Closing, &error);
|
||||
self.finish_target(index, primary_end, cleanup_failure.or(Some(cleanup)));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
TargetPhase::Terminal { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_drain(
|
||||
&mut self,
|
||||
index: usize,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
_now: Instant,
|
||||
) -> bool {
|
||||
let drain_started_at = Instant::now();
|
||||
let Some(deadline) =
|
||||
self.drain_deadline(self.targets[index].phase().client(), drain_started_at)
|
||||
else {
|
||||
return self.begin_close(
|
||||
index,
|
||||
primary_end,
|
||||
Some(duration_overflow_failure()),
|
||||
drain_started_at,
|
||||
);
|
||||
};
|
||||
let previous = self.targets[index].take_phase();
|
||||
let lifecycle = previous.lifecycle();
|
||||
self.targets[index].restore_phase(TargetPhase::Draining {
|
||||
client: previous.into_client(),
|
||||
drain_started_at,
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure: None,
|
||||
post_deadline_receives_remaining: POST_DEADLINE_RECEIVE_BUDGET,
|
||||
});
|
||||
self.publish_target_phase(index, lifecycle);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn drain_deadline(
|
||||
&self,
|
||||
client: &AsyncClient,
|
||||
drain_started_at: Instant,
|
||||
) -> Option<Instant> {
|
||||
client
|
||||
.latest_probe_timeout_deadline()
|
||||
.unwrap_or(drain_started_at)
|
||||
.max(drain_started_at)
|
||||
.checked_add(self.config.final_drain)
|
||||
}
|
||||
|
||||
pub(super) fn begin_close(
|
||||
&mut self,
|
||||
index: usize,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
mut cleanup_failure: Option<ManagedTargetFailure>,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
let deadline = now.checked_add(self.config.final_drain).unwrap_or_else(|| {
|
||||
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||
now
|
||||
});
|
||||
let previous = self.targets[index].take_phase();
|
||||
let lifecycle = previous.lifecycle();
|
||||
self.targets[index].restore_phase(TargetPhase::Closing {
|
||||
client: previous.into_client(),
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
});
|
||||
self.publish_target_phase(index, lifecycle);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn duration_overflow_failure() -> ManagedTargetFailure {
|
||||
classify_client_error(
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
&ClientError::DurationOverflow,
|
||||
)
|
||||
}
|
||||
|
||||
fn close_timeout_failure() -> ManagedTargetFailure {
|
||||
ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Closing,
|
||||
kind: ManagedTargetFailureKind::Timeout,
|
||||
message: Arc::from("best-effort close did not become writable before its deadline"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Bounded timeout discovery and processing across target generations.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::managed::{classify_client_error, ManagedTargetFailurePhase};
|
||||
|
||||
use super::{
|
||||
target::{duration_overflow_failure, OpenSessionFailureCleanup, TargetPhase},
|
||||
ManagedClientTask,
|
||||
};
|
||||
|
||||
pub(super) const TIMEOUT_WORK_BUDGET: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) struct TimeoutBacklogEntry {
|
||||
index: usize,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TimeoutStep {
|
||||
more_due: bool,
|
||||
}
|
||||
|
||||
impl TimeoutStep {
|
||||
const NONE: Self = Self { more_due: false };
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn poll_timeout_pass(&mut self, now: Instant) -> bool {
|
||||
let target_len = self.targets.len();
|
||||
if target_len == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut transitions_remaining = TIMEOUT_WORK_BUDGET;
|
||||
for _ in 0..target_len.min(TIMEOUT_WORK_BUDGET) {
|
||||
if transitions_remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let index = self.timeout_cursor;
|
||||
self.timeout_cursor = (self.timeout_cursor + 1) % target_len;
|
||||
if self.target_has_due_timeout(index, now) {
|
||||
let generation = self.targets[index].instance.generation;
|
||||
let step = self.poll_target_timeout(index, now);
|
||||
transitions_remaining -= 1;
|
||||
if step.more_due {
|
||||
self.enqueue_timeout_backlog(index, generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while transitions_remaining > 0 {
|
||||
let Some(entry) = self.timeout_backlog.pop_front() else {
|
||||
break;
|
||||
};
|
||||
let valid = self
|
||||
.targets
|
||||
.get(entry.index)
|
||||
.is_some_and(|target| target.instance.generation == entry.generation)
|
||||
&& self.target_has_due_timeout(entry.index, now);
|
||||
if !valid {
|
||||
continue;
|
||||
}
|
||||
|
||||
let step = self.poll_target_timeout(entry.index, now);
|
||||
transitions_remaining -= 1;
|
||||
if step.more_due {
|
||||
self.enqueue_timeout_backlog(entry.index, entry.generation);
|
||||
}
|
||||
}
|
||||
|
||||
!self.timeout_backlog.is_empty()
|
||||
}
|
||||
|
||||
fn enqueue_timeout_backlog(&mut self, index: usize, generation: u64) {
|
||||
if self.timeout_backlog.len() == TIMEOUT_WORK_BUDGET
|
||||
|| self
|
||||
.timeout_backlog
|
||||
.iter()
|
||||
.any(|entry| entry.index == index && entry.generation == generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.timeout_backlog
|
||||
.push_back(TimeoutBacklogEntry { index, generation });
|
||||
}
|
||||
|
||||
fn target_has_due_timeout(&self, index: usize, now: Instant) -> bool {
|
||||
#[cfg(test)]
|
||||
self.timeout_inspections.borrow_mut().push(index);
|
||||
match self.targets[index].phase() {
|
||||
TargetPhase::Active { client, .. } => {
|
||||
!self.phase.is_stopping()
|
||||
&& self.effective_retirement(index).is_none()
|
||||
&& client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
}
|
||||
TargetPhase::Draining { client, .. } => client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_target_timeout(&mut self, index: usize, now: Instant) -> TimeoutStep {
|
||||
match self.targets[index].phase_mut() {
|
||||
TargetPhase::Active { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
|
||||
Ok(batch) => {
|
||||
self.publish_client_events(index, batch.events);
|
||||
TimeoutStep {
|
||||
more_due: batch.more_due,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Close,
|
||||
);
|
||||
TimeoutStep::NONE
|
||||
}
|
||||
},
|
||||
TargetPhase::Draining { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
|
||||
Ok(batch) => {
|
||||
self.publish_client_events(index, batch.events);
|
||||
let TargetPhase::Draining {
|
||||
client,
|
||||
drain_started_at,
|
||||
..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let candidate = self.drain_deadline(client, *drain_started_at);
|
||||
let TargetPhase::Draining {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
match candidate {
|
||||
Some(candidate) => *deadline = (*deadline).min(candidate),
|
||||
None => {
|
||||
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
return TimeoutStep::NONE;
|
||||
}
|
||||
}
|
||||
TimeoutStep {
|
||||
more_due: batch.more_due,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let TargetPhase::Draining {
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
cleanup_failure.get_or_insert_with(|| {
|
||||
classify_client_error(ManagedTargetFailurePhase::Timing, &error)
|
||||
});
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
TimeoutStep::NONE
|
||||
}
|
||||
},
|
||||
_ => TimeoutStep::NONE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::managed::{ManagedClientConfig, ManagedTargetConfig};
|
||||
|
||||
use super::{super::ManagedClient, Instant, TIMEOUT_WORK_BUDGET};
|
||||
|
||||
// Poll latency through public events cannot distinguish an O(n) discovery
|
||||
// scan from a bounded scan reliably. This tiny inspection trace measures
|
||||
// work directly, including empty targets, and checks eventual coverage
|
||||
// without asserting a cursor value or a particular visitation order.
|
||||
#[test]
|
||||
fn timeout_discovery_has_bounded_work_and_eventually_visits_every_target() {
|
||||
let count = TIMEOUT_WORK_BUDGET * 2 + 1;
|
||||
let targets = (0..count)
|
||||
.map(|i| ManagedTargetConfig::new(i.to_string(), "127.0.0.1:2112"))
|
||||
.collect();
|
||||
let (mut task, _handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
max_live_target_generations: count,
|
||||
..ManagedClientConfig::default()
|
||||
},
|
||||
targets,
|
||||
)
|
||||
.unwrap();
|
||||
let mut visited = HashSet::new();
|
||||
for _ in 0..count.div_ceil(TIMEOUT_WORK_BUDGET) {
|
||||
task.timeout_inspections.borrow_mut().clear();
|
||||
task.poll_timeout_pass(Instant::now());
|
||||
let inspected = task.timeout_inspections.borrow();
|
||||
assert!(inspected.len() <= TIMEOUT_WORK_BUDGET);
|
||||
visited.extend(inspected.iter().copied());
|
||||
}
|
||||
assert_eq!(
|
||||
visited.len(),
|
||||
count,
|
||||
"timeout discovery must reach all targets"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Atomic desired-target transactions, generation retirement, and pruning.
|
||||
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
managed::{
|
||||
ManagedCommandAcknowledgement, ManagedCommandApplyError, ManagedCompletionPolicy,
|
||||
ManagedEvent, ManagedTargetConfig, ManagedTargetLifecycle, TargetInstance,
|
||||
},
|
||||
ClientConfig,
|
||||
};
|
||||
|
||||
use super::{
|
||||
control::{validate_target_config, ManagedCommand, UpdateAdmission},
|
||||
target::{RetirementReason, TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
|
||||
ManagedClientTask, TaskPhase,
|
||||
};
|
||||
|
||||
const COMMAND_WORK_BUDGET: usize = 32;
|
||||
|
||||
struct PlannedTarget {
|
||||
target: ManagedTargetConfig,
|
||||
client_config: ClientConfig,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
struct PlannedRetirement {
|
||||
index: usize,
|
||||
reason: RetirementReason,
|
||||
synchronous: bool,
|
||||
}
|
||||
|
||||
struct UpdatePlan {
|
||||
retirements: Vec<PlannedRetirement>,
|
||||
created: Vec<PlannedTarget>,
|
||||
next_generation: u64,
|
||||
next_command_sequence: u64,
|
||||
prospective_live_count: usize,
|
||||
}
|
||||
|
||||
fn synchronously_retireable(phase: &TargetPhase) -> bool {
|
||||
match phase {
|
||||
TargetPhase::Pending { .. }
|
||||
| TargetPhase::Connecting { .. }
|
||||
| TargetPhase::Terminal { .. } => true,
|
||||
TargetPhase::Opening { open, .. } => !open.has_in_flight_work(),
|
||||
TargetPhase::Active { .. } | TargetPhase::Draining { .. } | TargetPhase::Closing { .. } => {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_satisfies_target(runtime: &TargetRuntime, target: &ManagedTargetConfig) -> bool {
|
||||
runtime.membership.is_desired()
|
||||
&& !matches!(runtime.phase(), TargetPhase::Terminal { .. })
|
||||
&& runtime.config == *target
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn process_commands(&mut self, cx: &mut Context<'_>) -> bool {
|
||||
let mut immediate = false;
|
||||
for _ in 0..COMMAND_WORK_BUDGET {
|
||||
match self.commands.poll_recv(cx) {
|
||||
Poll::Ready(Some(ManagedCommand::UpdateTargets {
|
||||
targets,
|
||||
acknowledgement,
|
||||
})) => {
|
||||
// This check is the driver-side application linearization point.
|
||||
// `apply_targets` cannot suspend, so a stop observed here wins over
|
||||
// this command; once it passes, this command may complete atomically.
|
||||
let result = if !self.phase.is_running()
|
||||
|| self.resources().stop.update_admission() != UpdateAdmission::Open
|
||||
{
|
||||
Err(ManagedCommandApplyError::Stopping)
|
||||
} else {
|
||||
self.apply_targets(targets)
|
||||
};
|
||||
let _ = acknowledgement.send(result);
|
||||
immediate = true;
|
||||
}
|
||||
Poll::Ready(None) | Poll::Pending => break,
|
||||
}
|
||||
}
|
||||
immediate
|
||||
}
|
||||
|
||||
fn apply_targets(
|
||||
&mut self,
|
||||
incoming: Vec<ManagedTargetConfig>,
|
||||
) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
|
||||
let plan = self.plan_targets(incoming)?;
|
||||
debug_assert!(plan.prospective_live_count <= self.config.max_live_target_generations);
|
||||
|
||||
let now = Instant::now();
|
||||
let mut synchronous = HashSet::with_capacity(plan.retirements.len());
|
||||
let mut finished = Vec::new();
|
||||
for retirement in &plan.retirements {
|
||||
let target = &mut self.targets[retirement.index];
|
||||
let was_paced_active = target.is_paced_active();
|
||||
target.membership = TargetMembership::Withdrawn(retirement.reason);
|
||||
if retirement.synchronous {
|
||||
synchronous.insert(retirement.index);
|
||||
if !matches!(target.phase(), TargetPhase::Terminal { .. }) {
|
||||
let outcome = Arc::new(target.outcome(retirement.reason.into(), None));
|
||||
self.history.record(outcome.as_ref().clone());
|
||||
finished.push(ManagedEvent::TargetFinished { outcome });
|
||||
}
|
||||
} else if was_paced_active {
|
||||
// Removal is directional: it may discard an elapsed gate but never
|
||||
// lengthens an existing future stagger gate.
|
||||
self.stagger_target_removed(now);
|
||||
}
|
||||
}
|
||||
if !synchronous.is_empty() {
|
||||
let mut index = 0;
|
||||
self.targets.retain(|_| {
|
||||
let keep = !synchronous.contains(&index);
|
||||
index += 1;
|
||||
keep
|
||||
});
|
||||
self.rebase_target_cursors();
|
||||
}
|
||||
|
||||
let mut created_instances = Vec::with_capacity(plan.created.len());
|
||||
for planned in plan.created {
|
||||
let instance = TargetInstance {
|
||||
id: planned.target.id.clone(),
|
||||
generation: planned.generation,
|
||||
};
|
||||
created_instances.push(instance.clone());
|
||||
self.targets.push(TargetRuntime {
|
||||
instance,
|
||||
server_addr: Arc::from(planned.target.server_addr.clone()),
|
||||
config: planned.target,
|
||||
membership: TargetMembership::Desired,
|
||||
remote: None,
|
||||
counters: TargetCounters::default(),
|
||||
phase: Some(TargetPhase::Pending {
|
||||
client_config: planned.client_config,
|
||||
}),
|
||||
});
|
||||
}
|
||||
self.next_generation = plan.next_generation;
|
||||
self.applied_command_sequence = plan.next_command_sequence;
|
||||
let stopping = self
|
||||
.targets
|
||||
.iter()
|
||||
.all(|runtime| !runtime.membership.is_desired())
|
||||
&& self.config.completion == ManagedCompletionPolicy::FinishWhenQuiescent;
|
||||
if stopping {
|
||||
self.resources().stop.begin_stopping();
|
||||
self.phase = TaskPhase::Stopping;
|
||||
}
|
||||
|
||||
// A transaction has one externally visible durable point: all runtime and
|
||||
// history changes above, then this exact status snapshot, then its events and
|
||||
// acknowledgement. Do not use per-transition publishing helpers here.
|
||||
let status = self.snapshot();
|
||||
self.resources().status.send_replace(Arc::clone(&status));
|
||||
if stopping {
|
||||
self.publish_event(ManagedEvent::Stopping);
|
||||
}
|
||||
for event in finished {
|
||||
self.publish_event(event);
|
||||
}
|
||||
for target in created_instances {
|
||||
self.publish_event(ManagedEvent::TargetStateChanged {
|
||||
target,
|
||||
lifecycle: ManagedTargetLifecycle::Pending,
|
||||
});
|
||||
}
|
||||
Ok(ManagedCommandAcknowledgement {
|
||||
sequence: self.applied_command_sequence,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_targets(
|
||||
&self,
|
||||
incoming: Vec<ManagedTargetConfig>,
|
||||
) -> Result<UpdatePlan, ManagedCommandApplyError> {
|
||||
let mut ids = HashSet::with_capacity(incoming.len());
|
||||
let mut prepared = Vec::with_capacity(incoming.len());
|
||||
for target in incoming {
|
||||
if !ids.insert(target.id.clone()) {
|
||||
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
|
||||
}
|
||||
let mut client_config = self.config.client.clone();
|
||||
client_config.address_family = target
|
||||
.address_family
|
||||
.unwrap_or(self.config.client.address_family);
|
||||
client_config.auth = target.auth.resolve(&self.config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedCommandApplyError::InvalidTarget {
|
||||
id: target.id.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
prepared.push((target, client_config));
|
||||
}
|
||||
let mut retirements = Vec::new();
|
||||
for (index, runtime) in self.targets.iter().enumerate() {
|
||||
if !runtime.membership.is_desired()
|
||||
|| prepared
|
||||
.iter()
|
||||
.any(|(target, _)| runtime_satisfies_target(runtime, target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let reason = if prepared
|
||||
.iter()
|
||||
.any(|(target, _)| target.id == runtime.instance.id)
|
||||
{
|
||||
RetirementReason::Replaced
|
||||
} else {
|
||||
RetirementReason::Removed
|
||||
};
|
||||
retirements.push(PlannedRetirement {
|
||||
index,
|
||||
reason,
|
||||
synchronous: synchronously_retireable(runtime.phase()),
|
||||
});
|
||||
}
|
||||
|
||||
let mut next_generation = self.next_generation;
|
||||
let mut created = Vec::new();
|
||||
for (target, client_config) in prepared {
|
||||
if self
|
||||
.targets
|
||||
.iter()
|
||||
.any(|runtime| runtime_satisfies_target(runtime, &target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let generation = next_generation;
|
||||
next_generation = next_generation
|
||||
.checked_add(1)
|
||||
.ok_or(ManagedCommandApplyError::GenerationExhausted)?;
|
||||
created.push(PlannedTarget {
|
||||
target,
|
||||
client_config,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
let next_command_sequence = self
|
||||
.applied_command_sequence
|
||||
.checked_add(1)
|
||||
.ok_or(ManagedCommandApplyError::CommandSequenceExhausted)?;
|
||||
let synchronous = retirements
|
||||
.iter()
|
||||
.filter(|retirement| retirement.synchronous)
|
||||
.map(|retirement| retirement.index)
|
||||
.collect::<HashSet<_>>();
|
||||
let prospective_live_count = self
|
||||
.targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| !synchronous.contains(index))
|
||||
.count()
|
||||
.saturating_add(created.len());
|
||||
if prospective_live_count > self.config.max_live_target_generations {
|
||||
return Err(ManagedCommandApplyError::LiveGenerationLimitExceeded {
|
||||
required: prospective_live_count,
|
||||
limit: self.config.max_live_target_generations,
|
||||
});
|
||||
}
|
||||
Ok(UpdatePlan {
|
||||
retirements,
|
||||
created,
|
||||
next_generation,
|
||||
next_command_sequence,
|
||||
prospective_live_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prune_undesired_terminal(&mut self) {
|
||||
if !self.targets.iter().any(|target| {
|
||||
!target.membership.is_desired()
|
||||
&& matches!(target.phase(), TargetPhase::Terminal { .. })
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
self.targets.retain(|target| {
|
||||
target.membership.is_desired()
|
||||
|| !matches!(target.phase(), TargetPhase::Terminal { .. })
|
||||
});
|
||||
self.rebase_target_cursors();
|
||||
self.replace_status();
|
||||
}
|
||||
|
||||
fn rebase_target_cursors(&mut self) {
|
||||
let len = self.targets.len();
|
||||
if len == 0 {
|
||||
self.cursor = 0;
|
||||
self.timeout_cursor = 0;
|
||||
self.send_cursor = 0;
|
||||
} else {
|
||||
self.cursor %= len;
|
||||
self.timeout_cursor %= len;
|
||||
self.send_cursor %= len;
|
||||
}
|
||||
self.scan_remaining = self.scan_remaining.min(len);
|
||||
self.burst_remaining = self.burst_remaining.min(len);
|
||||
self.stagger_remaining = self.stagger_remaining.min(len);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -56,180 +56,3 @@ impl From<ReceiveMeta> for PacketMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{ReceiveMeta, MAX_KERNEL_RX_LAG};
|
||||
use crate::event::PacketMeta;
|
||||
|
||||
fn userspace_wall() -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
|
||||
}
|
||||
|
||||
fn meta_with_kernel_rx(kernel_rx_timestamp: SystemTime) -> ReceiveMeta {
|
||||
ReceiveMeta {
|
||||
traffic_class: None,
|
||||
kernel_rx_timestamp: Some(kernel_rx_timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_without_kernel_timestamp_uses_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
|
||||
assert_eq!(
|
||||
ReceiveMeta::default().preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_uses_plausible_earlier_kernel_timestamp() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = userspace - Duration::from_micros(80);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
kernel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_accepts_kernel_timestamp_equal_to_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(userspace).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_rejects_kernel_timestamp_later_than_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = userspace + Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_accepts_kernel_timestamp_exactly_at_the_lag_bound() {
|
||||
// The bound is inclusive: a lag of exactly MAX_KERNEL_RX_LAG is
|
||||
// still accepted, one nanosecond more is not.
|
||||
let userspace = userspace_wall();
|
||||
let at_bound = userspace - MAX_KERNEL_RX_LAG;
|
||||
let past_bound = userspace - MAX_KERNEL_RX_LAG - Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(at_bound).preferred_receive_wall(userspace),
|
||||
at_bound
|
||||
);
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(past_bound).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_rejects_implausibly_old_kernel_timestamp() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = SystemTime::UNIX_EPOCH;
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_handles_extreme_timestamps_without_panicking() {
|
||||
let userspace = userspace_wall();
|
||||
let far_future = SystemTime::UNIX_EPOCH + Duration::from_secs(u64::from(u32::MAX)) * 4;
|
||||
let before_epoch = SystemTime::UNIX_EPOCH - Duration::from_secs(u64::from(u32::MAX));
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(far_future).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(before_epoch).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
assert_eq!(
|
||||
ReceiveMeta::default().preferred_receive_wall(before_epoch),
|
||||
before_epoch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_preserves_kernel_timestamp_rejected_for_measurement() {
|
||||
let userspace = userspace_wall();
|
||||
let rejected = userspace + Duration::from_secs(30);
|
||||
let meta = meta_with_kernel_rx(rejected);
|
||||
|
||||
assert_eq!(meta.preferred_receive_wall(userspace), userspace);
|
||||
assert_eq!(
|
||||
PacketMeta::from(meta).kernel_rx_timestamp,
|
||||
Some(rejected),
|
||||
"observed metadata must survive rejection as a measurement endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(0),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(0));
|
||||
assert_eq!(packet_meta.dscp, Some(0));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_observed_traffic_class_derives_dscp_and_ecn() {
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(184),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(184));
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(186),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(186));
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(2));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_mapping_preserves_none_vs_observed_zero() {
|
||||
let unavailable = PacketMeta::from(ReceiveMeta::default());
|
||||
let observed_zero = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(0),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(unavailable.traffic_class, None);
|
||||
assert_eq!(unavailable.dscp, None);
|
||||
assert_eq!(unavailable.ecn, None);
|
||||
|
||||
assert_eq!(observed_zero.traffic_class, Some(0));
|
||||
assert_eq!(observed_zero.dscp, Some(0));
|
||||
assert_eq!(observed_zero.ecn, Some(0));
|
||||
}
|
||||
}
|
||||
+155
-324
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, TryReserveError, VecDeque},
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
time::{Instant, SystemTime},
|
||||
};
|
||||
|
||||
@@ -85,7 +85,12 @@ impl PendingMap {
|
||||
});
|
||||
}
|
||||
if self.map.len() == self.map.capacity() {
|
||||
self.map.try_reserve(1).map_err(pending_allocation_failed)?;
|
||||
self.map
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "pending probe storage",
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -186,8 +191,8 @@ impl PendingMap {
|
||||
ExpiredBatch { probes, more_due }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
/// Mutable access to a still-pending probe by wire sequence, without
|
||||
@@ -198,14 +203,13 @@ impl PendingMap {
|
||||
self.map.get_mut(&wire_seq).map(|entry| &mut entry.probe)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
pub fn next_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.first
|
||||
.and_then(|wire_seq| self.map.get(&wire_seq))
|
||||
.map(|entry| entry.probe.timeout_at)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
#[cfg(feature = "tokio")]
|
||||
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.last
|
||||
.and_then(|wire_seq| self.map.get(&wire_seq))
|
||||
@@ -234,116 +238,65 @@ impl PendingMap {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, wire_seq: u32) -> bool {
|
||||
self.map.contains_key(&wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.map.capacity()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn linkage(&self) -> (Option<u32>, Option<u32>) {
|
||||
(self.first, self.last)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn entry_links(&self, wire_seq: u32) -> Option<(Option<u32>, Option<u32>)> {
|
||||
self.map
|
||||
.get(&wire_seq)
|
||||
.map(|entry| (entry.previous, entry.next))
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_allocation_failed(source: TryReserveError) -> ClientError {
|
||||
ClientError::AllocationFailed {
|
||||
operation: "pending probe storage",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TimedOutMap {
|
||||
map: HashMap<u32, PendingProbe>,
|
||||
insertion_order: VecDeque<u32>,
|
||||
max_capacity: usize,
|
||||
// Timeout transitions consume PendingMap's head in deadline order. Reuse
|
||||
// its linked storage in a separate map: the head owns FIFO eviction and
|
||||
// the tail owns the latest retained timeout, even after arbitrary replies.
|
||||
probes: PendingMap,
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
deadline_inspections: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl TimedOutMap {
|
||||
pub fn new(max_capacity: usize) -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
insertion_order: VecDeque::new(),
|
||||
max_capacity,
|
||||
probes: PendingMap::new(max_capacity),
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
deadline_inspections: std::cell::Cell::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, probe: PendingProbe) {
|
||||
if self.max_capacity == 0 {
|
||||
if self.probes.max_capacity == 0 {
|
||||
return;
|
||||
}
|
||||
if let std::collections::hash_map::Entry::Occupied(mut entry) =
|
||||
self.map.entry(probe.wire_seq)
|
||||
{
|
||||
entry.insert(probe);
|
||||
return;
|
||||
// A sequence cannot be both pending and retained: committing its next
|
||||
// generation removes the old retention before adding the pending probe.
|
||||
debug_assert!(!self.probes.map.contains_key(&probe.wire_seq));
|
||||
if self.probes.map.len() == self.probes.max_capacity {
|
||||
self.probes
|
||||
.remove(self.probes.first.expect("full retention has a head"));
|
||||
}
|
||||
while self.map.len() >= self.max_capacity {
|
||||
self.evict_oldest();
|
||||
}
|
||||
self.insertion_order.push_back(probe.wire_seq);
|
||||
self.map.insert(probe.wire_seq, probe);
|
||||
// Unlike pending admission, retention always makes room by eviction.
|
||||
// Allocation here remains infallible as it was for the previous map.
|
||||
self.probes.commit_insert(probe);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, wire_seq: u32) -> Option<PendingProbe> {
|
||||
let removed = self.map.remove(&wire_seq);
|
||||
if removed.is_some() {
|
||||
self.insertion_order.retain(|seq| *seq != wire_seq);
|
||||
}
|
||||
removed
|
||||
self.probes.remove(wire_seq)
|
||||
}
|
||||
|
||||
/// Mutable access to a still-retained timed-out probe by wire sequence,
|
||||
/// without disturbing eviction order. Used to attach an observed kernel
|
||||
/// TX timestamp that arrives after the probe has already timed out.
|
||||
pub fn get_mut(&mut self, wire_seq: u32) -> Option<&mut PendingProbe> {
|
||||
self.map.get_mut(&wire_seq)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.insertion_order.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
self.probes.get_mut(wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.map.values().map(|probe| probe.timeout_at).max()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, wire_seq: u32) -> bool {
|
||||
self.map.contains_key(&wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insertion_order_len(&self) -> usize {
|
||||
self.insertion_order.len()
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
while let Some(oldest_key) = self.insertion_order.pop_front() {
|
||||
if self.map.remove(&oldest_key).is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.probes
|
||||
.last
|
||||
.and_then(|wire_seq| self.probes.map.get(&wire_seq))
|
||||
.map(|entry| {
|
||||
#[cfg(test)]
|
||||
self.deadline_inspections
|
||||
.set(self.deadline_inspections.get() + 1);
|
||||
entry.probe.timeout_at
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,263 +373,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(map: &mut PendingMap, probe: PendingProbe) {
|
||||
map.preflight_insert(probe.wire_seq).unwrap();
|
||||
map.commit_insert(probe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_rejects_over_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(0).unwrap();
|
||||
map.commit_insert(pending(0, now + Duration::from_secs(4)));
|
||||
map.preflight_insert(1).unwrap();
|
||||
map.commit_insert(pending(1, now + Duration::from_secs(4)));
|
||||
assert!(matches!(
|
||||
map.preflight_insert(2),
|
||||
Err(ClientError::PendingLimitExceeded { limit: 2 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_preflight_reserves_without_changing_contents() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let initial_capacity = map.capacity();
|
||||
|
||||
map.preflight_insert(7).unwrap();
|
||||
let reserved_capacity = map.capacity();
|
||||
assert!(reserved_capacity >= initial_capacity);
|
||||
assert_eq!(map.len(), 0);
|
||||
assert!(!map.contains(7));
|
||||
|
||||
map.preflight_insert(7).unwrap();
|
||||
assert_eq!(map.capacity(), reserved_capacity);
|
||||
assert_eq!(map.len(), 0);
|
||||
assert!(!map.contains(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_preflight_rejects_sequence_collision() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(9).unwrap();
|
||||
map.commit_insert(pending(9, now));
|
||||
|
||||
assert!(matches!(
|
||||
map.preflight_insert(9),
|
||||
Err(ClientError::PendingSequenceCollision { seq: 9 })
|
||||
));
|
||||
assert_eq!(map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_allocation_failure_maps_to_dedicated_error() {
|
||||
let mut map = HashMap::<u32, PendingProbe>::new();
|
||||
let source = map.try_reserve(usize::MAX).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
pending_allocation_failed(source),
|
||||
ClientError::AllocationFailed {
|
||||
operation: "pending probe storage",
|
||||
..
|
||||
fn pending_removal_and_sequence_reuse_preserve_expiry_and_capacity() {
|
||||
let start = Instant::now();
|
||||
// Remove the head, middle or tail, then reuse that sequence at a later
|
||||
// deadline. Observe expiry and capacity, not private list links.
|
||||
for removed in 0..3 {
|
||||
let mut pending_map = PendingMap::new(3);
|
||||
for seq in 0..3 {
|
||||
pending_map.preflight_insert(seq).unwrap();
|
||||
pending_map
|
||||
.commit_insert(pending(seq, start + Duration::from_secs(u64::from(seq))));
|
||||
}
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
pending_map.preflight_insert(3),
|
||||
Err(ClientError::PendingLimitExceeded { .. })
|
||||
));
|
||||
assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed);
|
||||
pending_map.preflight_insert(removed).unwrap();
|
||||
let reused_deadline = start + Duration::from_secs(4);
|
||||
pending_map.commit_insert(pending(removed, reused_deadline));
|
||||
|
||||
#[test]
|
||||
fn pending_map_unlinks_head_middle_and_tail_and_updates_extrema() {
|
||||
let mut map = PendingMap::new(4);
|
||||
let now = Instant::now();
|
||||
insert(&mut map, pending(1, now + Duration::from_secs(1)));
|
||||
insert(&mut map, pending(2, now + Duration::from_secs(2)));
|
||||
insert(&mut map, pending(3, now + Duration::from_secs(3)));
|
||||
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(1), Some(3)));
|
||||
assert_eq!(map.entry_links(1), Some((None, Some(2))));
|
||||
assert_eq!(map.entry_links(2), Some((Some(1), Some(3))));
|
||||
assert_eq!(map.entry_links(3), Some((Some(2), None)));
|
||||
|
||||
assert!(map.remove(2).is_some());
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(1), Some(3)));
|
||||
assert_eq!(map.entry_links(1), Some((None, Some(3))));
|
||||
assert_eq!(map.entry_links(3), Some((Some(1), None)));
|
||||
|
||||
assert!(map.remove(1).is_some());
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(3), Some(3)));
|
||||
assert_eq!(map.entry_links(3), Some((None, None)));
|
||||
|
||||
assert!(map.remove(3).is_some());
|
||||
assert_eq!(map.next_timeout_deadline(), None);
|
||||
assert_eq!(map.latest_timeout_deadline(), None);
|
||||
assert_eq!(map.linkage(), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_bounded_expiration_matches_exhaustive_order() {
|
||||
let now = Instant::now();
|
||||
let probes = [
|
||||
pending(1, now + Duration::from_secs(1)),
|
||||
pending(2, now + Duration::from_secs(2)),
|
||||
pending(3, now + Duration::from_secs(3)),
|
||||
pending(4, now + Duration::from_secs(4)),
|
||||
];
|
||||
let mut bounded = PendingMap::new(probes.len());
|
||||
let mut exhaustive = PendingMap::new(probes.len());
|
||||
for probe in probes.iter().cloned() {
|
||||
insert(&mut bounded, probe.clone());
|
||||
insert(&mut exhaustive, probe);
|
||||
}
|
||||
|
||||
let empty = bounded.drain_expired_bounded(now + Duration::from_secs(4), 0);
|
||||
assert!(empty.probes.is_empty());
|
||||
assert!(empty.more_due);
|
||||
assert_eq!(bounded.linkage(), (Some(1), Some(4)));
|
||||
|
||||
let mut actual = Vec::new();
|
||||
loop {
|
||||
let batch = bounded.drain_expired_bounded(now + Duration::from_secs(4), 2);
|
||||
actual.extend(batch.probes.into_iter().map(|probe| probe.wire_seq));
|
||||
if !batch.more_due {
|
||||
break;
|
||||
let remaining = (0..3).filter(|seq| *seq != removed).collect::<Vec<_>>();
|
||||
for (index, seq) in remaining.iter().enumerate() {
|
||||
assert_eq!(
|
||||
pending_map.next_timeout_deadline(),
|
||||
Some(start + Duration::from_secs(u64::from(*seq)))
|
||||
);
|
||||
let expired = pending_map.drain_expired_bounded(start + Duration::from_secs(3), 1);
|
||||
assert_eq!(
|
||||
expired
|
||||
.probes
|
||||
.iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![*seq]
|
||||
);
|
||||
assert_eq!(expired.more_due, index == 0);
|
||||
}
|
||||
assert_eq!(pending_map.next_timeout_deadline(), Some(reused_deadline));
|
||||
let expired = pending_map.drain_expired_bounded(reused_deadline, 3);
|
||||
assert_eq!(expired.probes.len(), 1);
|
||||
assert_eq!(expired.probes[0].wire_seq, removed);
|
||||
assert!(!expired.more_due);
|
||||
assert!(pending_map.is_empty());
|
||||
assert_eq!(pending_map.next_timeout_deadline(), None);
|
||||
pending_map.preflight_insert(removed).unwrap();
|
||||
pending_map.commit_insert(pending(removed, reused_deadline));
|
||||
assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed);
|
||||
assert!(pending_map.is_empty());
|
||||
}
|
||||
let expected = exhaustive
|
||||
.drain_expired_bounded(now + Duration::from_secs(4), usize::MAX)
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual, vec![1, 2, 3, 4]);
|
||||
assert_eq!(bounded.linkage(), (None, None));
|
||||
}
|
||||
|
||||
// Measure deadline inspection work directly: wall-clock thresholds cannot
|
||||
// reliably distinguish a bounded lookup from a retention scan.
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn pending_map_sequence_reuse_leaves_no_stale_link() {
|
||||
let mut map = PendingMap::new(1);
|
||||
let now = Instant::now();
|
||||
insert(&mut map, pending(7, now));
|
||||
assert!(map.remove(7).is_some());
|
||||
insert(&mut map, pending(7, now + Duration::from_secs(1)));
|
||||
|
||||
assert!(map.drain_expired_bounded(now, usize::MAX).probes.is_empty());
|
||||
assert_eq!(map.linkage(), (Some(7), Some(7)));
|
||||
assert_eq!(map.entry_links(7), Some((None, None)));
|
||||
assert_eq!(
|
||||
map.drain_expired_bounded(now + Duration::from_secs(1), usize::MAX)
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![7]
|
||||
);
|
||||
assert_eq!(map.linkage(), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_commit_uses_only_preflighted_map_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(7).unwrap();
|
||||
let capacity = map.capacity();
|
||||
|
||||
map.commit_insert(pending(7, now));
|
||||
|
||||
assert_eq!(map.capacity(), capacity);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert_eq!(map.linkage(), (Some(7), Some(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_mixed_removals_preserve_links() {
|
||||
let mut map = PendingMap::new(4);
|
||||
let now = Instant::now();
|
||||
for seq in 1..=4 {
|
||||
insert(
|
||||
&mut map,
|
||||
pending(seq, now + Duration::from_secs(u64::from(seq))),
|
||||
fn timeout_retention_deadlines_have_bounded_lookup_work() {
|
||||
const COUNT: u32 = 4096;
|
||||
let start = Instant::now();
|
||||
let deadline = |seq| start + Duration::from_micros(u64::from(seq / 2));
|
||||
let mut pending_map = PendingMap::new(COUNT as usize);
|
||||
let mut timed_out = TimedOutMap::new(COUNT as usize);
|
||||
for seq in 0..COUNT {
|
||||
pending_map.preflight_insert(seq).unwrap();
|
||||
pending_map.commit_insert(pending(seq, deadline(seq)));
|
||||
}
|
||||
let last_deadline = deadline(COUNT - 1);
|
||||
for seq in 0..COUNT {
|
||||
let batch = pending_map.drain_expired_bounded(last_deadline, 1);
|
||||
assert_eq!(batch.probes.len(), 1);
|
||||
assert_eq!(batch.probes[0].wire_seq, seq);
|
||||
assert_eq!(batch.more_due, seq + 1 < COUNT);
|
||||
timed_out.insert(batch.probes.into_iter().next().unwrap());
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
|
||||
assert_eq!(
|
||||
pending_map
|
||||
.latest_timeout_deadline()
|
||||
.into_iter()
|
||||
.chain(timed_out.latest_timeout_deadline())
|
||||
.max(),
|
||||
Some(last_deadline)
|
||||
);
|
||||
}
|
||||
|
||||
assert!(map.remove(2).is_some());
|
||||
assert_eq!(map.entry_links(1), Some((None, Some(3))));
|
||||
assert_eq!(map.entry_links(3), Some((Some(1), Some(4))));
|
||||
let expired = map.drain_expired_bounded(now + Duration::from_secs(3), usize::MAX);
|
||||
assert_eq!(
|
||||
expired
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
assert!(
|
||||
timed_out.deadline_inspections.replace(0) <= 2 * COUNT as usize,
|
||||
"draining must not scan growing timeout retention after each transition"
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(4), Some(4)));
|
||||
assert!(map.remove(4).is_some());
|
||||
assert_eq!(map.linkage(), (None, None));
|
||||
// A middle/head removal leaves the latest deadline intact. Removing
|
||||
// the newest survivors must walk back through equal deadlines too.
|
||||
assert!(timed_out.remove(COUNT / 2).is_some());
|
||||
assert!(timed_out.remove(0).is_some());
|
||||
for seq in (1..COUNT).rev().filter(|seq| *seq != COUNT / 2) {
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
|
||||
assert!(timed_out.remove(seq).is_some());
|
||||
}
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), None);
|
||||
assert!(timed_out.deadline_inspections.get() <= COUNT as usize);
|
||||
}
|
||||
|
||||
// Public loss/reply events cannot reveal leaked retention bookkeeping.
|
||||
#[test]
|
||||
fn bounded_probe_tracking_evicts_oldest_entries() {
|
||||
let mut set = CompletedSet::new(3);
|
||||
set.insert(0);
|
||||
set.insert(1);
|
||||
set.insert(2);
|
||||
assert!(set.contains(0));
|
||||
assert!(set.contains(1));
|
||||
assert!(set.contains(2));
|
||||
set.insert(3);
|
||||
assert_eq!(set.set.len(), 3);
|
||||
assert!(!set.contains(0));
|
||||
assert!(set.contains(3));
|
||||
|
||||
let mut map = TimedOutMap::new(2);
|
||||
let now = Instant::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());
|
||||
assert!(map.remove(1).is_some());
|
||||
assert!(map.remove(2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_map_remove_prunes_insertion_order() {
|
||||
fn timed_out_retention_preserves_fifo_eviction_and_stays_bounded() {
|
||||
let mut map = TimedOutMap::new(4);
|
||||
let now = Instant::now();
|
||||
|
||||
for i in 0..20 {
|
||||
map.insert(pending(i, now));
|
||||
assert!(map.remove(i).is_some());
|
||||
assert_eq!(map.len(), 0);
|
||||
assert_eq!(map.insertion_order_len(), 0);
|
||||
assert!(map.probes.map.len() <= 4);
|
||||
}
|
||||
|
||||
// Equal deadlines across wire-sequence wrap must still evict by
|
||||
// insertion order. Removal at any position frees capacity immediately.
|
||||
let sequences = [u32::MAX - 1, u32::MAX, 0, 1];
|
||||
for removed in sequences {
|
||||
let mut map = TimedOutMap::new(4);
|
||||
for seq in sequences {
|
||||
map.insert(pending(seq, now));
|
||||
}
|
||||
assert!(map.remove(removed).is_some());
|
||||
map.insert(pending(2, now));
|
||||
let oldest = sequences.into_iter().find(|seq| *seq != removed).unwrap();
|
||||
map.insert(pending(3, now));
|
||||
assert!(map.remove(oldest).is_none(), "oldest survivor is evicted");
|
||||
for seq in sequences
|
||||
.into_iter()
|
||||
.filter(|seq| *seq != removed && *seq != oldest)
|
||||
{
|
||||
assert!(map.remove(seq).is_some());
|
||||
}
|
||||
assert!(map.remove(2).is_some());
|
||||
assert!(map.remove(3).is_some());
|
||||
assert!(map.probes.map.is_empty());
|
||||
#[cfg(feature = "tokio")]
|
||||
assert_eq!(map.latest_timeout_deadline(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,80 +111,3 @@ pub(crate) fn drain_tx_timestamps<S: std::os::fd::AsRawFd>(
|
||||
) -> io::Result<()> {
|
||||
linux::drain_tx_timestamps(socket, on_timestamp)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::UdpSocket;
|
||||
|
||||
use crate::{metadata::ReceiveMeta, receive::recv_datagram, timing::ClientTimestamp};
|
||||
|
||||
fn connected_loopback_pair() -> (UdpSocket, UdpSocket) {
|
||||
let a = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let b = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
a.connect(b.local_addr().unwrap()).unwrap();
|
||||
b.connect(a.local_addr().unwrap()).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_receive_returns_length() {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
sender.send(b"hello").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_receive_returns_default_metadata() {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
sender.send(b"meta").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
|
||||
assert_eq!(datagram.meta, ReceiveMeta::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_receive_captures_timestamp_after_successful_receive() {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
sender.send(b"time").unwrap();
|
||||
|
||||
let before = ClientTimestamp::now();
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let after = ClientTimestamp::now();
|
||||
|
||||
assert!(datagram.received_at.mono >= before.mono);
|
||||
assert!(datagram.received_at.mono <= after.mono);
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_try_receive_preserves_fallback_metadata_and_would_block() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
receiver.set_nonblocking(true).unwrap();
|
||||
let receiver = tokio::net::UdpSocket::from_std(receiver).unwrap();
|
||||
sender.send(b"tokio").unwrap();
|
||||
receiver.readable().await.unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..datagram.len], b"tokio");
|
||||
assert_eq!(datagram.meta, ReceiveMeta::default());
|
||||
|
||||
let error = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@
|
||||
//! session so later IDs cannot be misattributed. It performs a small bounded,
|
||||
//! nonblocking read of
|
||||
//! `MSG_ERRQUEUE` so the adapter can opportunistically collect timestamps
|
||||
//! without ever waiting for one. The observed kernel TX wall time is retained
|
||||
//! as metadata only; it is not consumed by any measurement in this change.
|
||||
//! without ever waiting for one. A plausible kernel TX wall time is used only
|
||||
//! for upstream one-way delay; RTT retains the userspace send instant.
|
||||
|
||||
use std::{
|
||||
io::{self, IoSliceMut},
|
||||
@@ -161,12 +161,12 @@ fn recv_datagram_fd(
|
||||
buf: &mut [u8],
|
||||
flags: MsgFlags,
|
||||
) -> Result<ReceivedDatagram, io::Error> {
|
||||
let mut control = ControlBuffer::new();
|
||||
let mut control = ControlBuffer([0; CONTROL_LEN]);
|
||||
let mut iov = [IoSliceMut::new(buf)];
|
||||
|
||||
// The socket is connected, so the source address is not needed; `()` skips
|
||||
// copying it out.
|
||||
let msg = recvmsg::<()>(socket_fd, &mut iov, Some(control.as_mut_slice()), flags)?;
|
||||
let msg = recvmsg::<()>(socket_fd, &mut iov, Some(&mut control.0), flags)?;
|
||||
let received_at = ClientTimestamp::now();
|
||||
|
||||
Ok(ReceivedDatagram {
|
||||
@@ -215,343 +215,3 @@ fn system_time_from_timespec(timespec: TimeSpec) -> Option<SystemTime> {
|
||||
/// `cmsghdr` alignment, which 8-byte alignment covers on supported targets.
|
||||
#[repr(align(8))]
|
||||
struct ControlBuffer([u8; CONTROL_LEN]);
|
||||
|
||||
impl ControlBuffer {
|
||||
fn new() -> Self {
|
||||
Self([0; CONTROL_LEN])
|
||||
}
|
||||
|
||||
fn as_mut_slice(&mut self) -> &mut [u8] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
time::{Duration, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nix::sys::time::TimeSpec;
|
||||
|
||||
use crate::{
|
||||
event::PacketMeta,
|
||||
receive::{configure_receive_metadata, recv_datagram},
|
||||
socket_options::apply_traffic_class_to_socket,
|
||||
timing::ClientTimestamp,
|
||||
};
|
||||
|
||||
fn connected_ipv4_loopback_pair() -> (UdpSocket, UdpSocket) {
|
||||
let a = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let b = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
a.connect(b.local_addr().unwrap()).unwrap();
|
||||
b.connect(a.local_addr().unwrap()).unwrap();
|
||||
a.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
b.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
fn connected_ipv6_loopback_pair() -> Option<(UdpSocket, UdpSocket)> {
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0));
|
||||
let a = match UdpSocket::bind(bind_addr) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
let b = match UdpSocket::bind(bind_addr) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
match a.connect(b.local_addr().unwrap()) {
|
||||
Ok(()) => {}
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
match b.connect(a.local_addr().unwrap()) {
|
||||
Ok(()) => {}
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
a.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
b.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
Some((a, b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recvmsg_receive_returns_length_payload_and_timestamp() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"hello").unwrap();
|
||||
|
||||
let before = ClientTimestamp::now();
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let after = ClientTimestamp::now();
|
||||
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"hello");
|
||||
assert!(datagram.received_at.mono >= before.mono);
|
||||
assert!(datagram.received_at.mono <= after.mono);
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_recvmsg_would_block_clears_readiness() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
receiver.set_nonblocking(true).unwrap();
|
||||
let receiver = tokio::net::UdpSocket::from_std(receiver).unwrap();
|
||||
let mut buf = [0_u8; 16];
|
||||
|
||||
sender.send(b"first").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), receiver.readable())
|
||||
.await
|
||||
.expect("initial readability timed out")
|
||||
.unwrap();
|
||||
|
||||
let first = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..first.len], b"first");
|
||||
|
||||
let error = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), receiver.readable())
|
||||
.await
|
||||
.is_err(),
|
||||
"readable completed from stale readiness after recvmsg WouldBlock"
|
||||
);
|
||||
|
||||
sender.send(b"second").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), receiver.readable())
|
||||
.await
|
||||
.expect("second readability timed out")
|
||||
.unwrap();
|
||||
|
||||
let second = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..second.len], b"second");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_rx_timestamp_metadata_is_observed_when_kernel_provides_it() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"stamp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"stamp");
|
||||
let Some(timestamp) = datagram.meta.kernel_rx_timestamp else {
|
||||
eprintln!(
|
||||
"skipping kernel timestamp assertion: kernel did not provide SCM_TIMESTAMPING"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let duration = timestamp.duration_since(UNIX_EPOCH).unwrap();
|
||||
assert!(duration.as_nanos() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_rx_timestamp_and_traffic_class_coexist() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"both").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..datagram.len], b"both");
|
||||
|
||||
// Both cmsgs are requested on this socket; the parser must not
|
||||
// depend on the order the kernel emits them in to observe either.
|
||||
if datagram.meta.kernel_rx_timestamp.is_none() {
|
||||
eprintln!("skipping combined assertion: kernel did not provide SCM_TIMESTAMPING");
|
||||
return;
|
||||
}
|
||||
if datagram.meta.traffic_class.is_none() {
|
||||
eprintln!("skipping combined assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
}
|
||||
assert!(datagram.meta.kernel_rx_timestamp.is_some());
|
||||
assert_eq!(datagram.meta.traffic_class.unwrap() & 0xfc, 184);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_traffic_class_metadata_is_observed_when_kernel_provides_it() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"dscp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!("skipping IPv4 ancillary metadata assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class & 0xfc, 184);
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_traffic_class_metadata_is_observed_when_kernel_provides_it() {
|
||||
let Some((sender, receiver)) = connected_ipv6_loopback_pair() else {
|
||||
return;
|
||||
};
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"dscp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary metadata assertion: kernel did not provide IPV6_TCLASS"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class & 0xfc, 184);
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_zero_traffic_class_preserves_some_zero() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"zero").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!("skipping observed-zero assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class, 0);
|
||||
assert_eq!(packet_meta.traffic_class, Some(0));
|
||||
assert_eq!(packet_meta.dscp, Some(0));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timespec_conversion_accepts_valid_unix_timestamp() {
|
||||
let timestamp = super::system_time_from_timespec(TimeSpec::new(1, 2)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
timestamp.duration_since(UNIX_EPOCH).unwrap(),
|
||||
Duration::new(1, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timespec_conversion_rejects_negative_or_invalid_values() {
|
||||
assert_eq!(super::system_time_from_timespec(TimeSpec::new(-1, 0)), None);
|
||||
assert_eq!(super::system_time_from_timespec(TimeSpec::new(0, -1)), None);
|
||||
assert_eq!(
|
||||
super::system_time_from_timespec(TimeSpec::new(0, 1_000_000_000)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn is_unavailable_ipv6_loopback(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_enable_tx_timestamping_then_drain_reports_ids_through_the_adapter_api() {
|
||||
let (socket, _peer) = connected_ipv4_loopback_pair();
|
||||
// Mirrors production: RX-only first, then a best-effort upgrade.
|
||||
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
|
||||
|
||||
if !super::try_enable_tx_timestamping(&socket) {
|
||||
eprintln!(
|
||||
"skipping adapter-level TX timestamping smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = socket.send(b"one") {
|
||||
eprintln!("skipping adapter-level TX timestamping smoke test: send failed: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
let mut observed = None;
|
||||
while observed.is_none() && std::time::Instant::now() < deadline {
|
||||
super::drain_tx_timestamps(&socket, |id, timestamp| {
|
||||
observed = Some((id, timestamp));
|
||||
})
|
||||
.unwrap();
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
let Some((id, timestamp)) = observed else {
|
||||
eprintln!(
|
||||
"skipping adapter-level TX timestamping smoke test: no TX timestamp record \
|
||||
arrived within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert_eq!(id, 0);
|
||||
assert!(timestamp.duration_since(UNIX_EPOCH).unwrap() > Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_tx_timestamps_is_a_bounded_nonblocking_no_op_on_an_untouched_socket() {
|
||||
let (socket, _peer) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
|
||||
|
||||
let mut calls = 0_usize;
|
||||
super::drain_tx_timestamps(&socket, |_, _| calls += 1).unwrap();
|
||||
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,7 @@ pub(crate) fn classify(cmsgs: impl Iterator<Item = ControlMessageOwned>) -> Erro
|
||||
|
||||
for cmsg in cmsgs {
|
||||
match cmsg {
|
||||
ControlMessageOwned::Ipv4RecvErr(err, _) => {
|
||||
extended_error = Some((err.ee_errno, err.ee_origin, err.ee_info, err.ee_data));
|
||||
}
|
||||
ControlMessageOwned::Ipv6RecvErr(err, _) => {
|
||||
ControlMessageOwned::Ipv4RecvErr(err, _) | ControlMessageOwned::Ipv6RecvErr(err, _) => {
|
||||
extended_error = Some((err.ee_errno, err.ee_origin, err.ee_info, err.ee_data));
|
||||
}
|
||||
ControlMessageOwned::ScmTimestampsns(observed) => {
|
||||
@@ -145,12 +142,6 @@ pub(crate) fn classify(cmsgs: impl Iterator<Item = ControlMessageOwned>) -> Erro
|
||||
#[repr(align(8))]
|
||||
struct ErrorQueueControlBuffer([u8; ERROR_QUEUE_CONTROL_LEN]);
|
||||
|
||||
impl ErrorQueueControlBuffer {
|
||||
fn new() -> Self {
|
||||
Self([0; ERROR_QUEUE_CONTROL_LEN])
|
||||
}
|
||||
}
|
||||
|
||||
/// Nonblocking drain of a single record from `fd`'s `MSG_ERRQUEUE`.
|
||||
///
|
||||
/// Returns `Ok(None)` when the queue is empty (`EAGAIN`/`EWOULDBLOCK`) — the
|
||||
@@ -171,7 +162,7 @@ impl ErrorQueueControlBuffer {
|
||||
pub(crate) fn try_recv_error_queue_record(fd: RawFd) -> io::Result<Option<ErrorQueueRecord>> {
|
||||
let mut payload: [u8; 0] = [];
|
||||
let mut iov = [IoSliceMut::new(&mut payload)];
|
||||
let mut control = ErrorQueueControlBuffer::new();
|
||||
let mut control = ErrorQueueControlBuffer([0; ERROR_QUEUE_CONTROL_LEN]);
|
||||
|
||||
match recvmsg::<()>(
|
||||
fd,
|
||||
@@ -190,448 +181,3 @@ pub(crate) fn try_recv_error_queue_record(fd: RawFd) -> io::Result<Option<ErrorQ
|
||||
Err(errno) => Err(io::Error::from(errno)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
net::{SocketAddr, UdpSocket},
|
||||
os::fd::AsRawFd,
|
||||
time::{Duration, Instant, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nix::sys::{
|
||||
socket::{setsockopt, sockopt},
|
||||
time::TimeSpec,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn extended_error(
|
||||
errno: u32,
|
||||
origin: u8,
|
||||
info: u32,
|
||||
data: u32,
|
||||
) -> nix::libc::sock_extended_err {
|
||||
nix::libc::sock_extended_err {
|
||||
ee_errno: errno,
|
||||
ee_origin: origin,
|
||||
ee_type: 0,
|
||||
ee_code: 0,
|
||||
ee_pad: 0,
|
||||
ee_info: info,
|
||||
ee_data: data,
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamps_with_system(system: TimeSpec) -> Timestamps {
|
||||
Timestamps {
|
||||
system,
|
||||
hw_trans: TimeSpec::new(0, 0),
|
||||
hw_raw: TimeSpec::new(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_timestamp_error(id: u32) -> ControlMessageOwned {
|
||||
ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ENOMSG as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
SCM_TSTAMP_SND,
|
||||
id,
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_valid_send_timestamp_completion() {
|
||||
let cmsgs = vec![
|
||||
send_timestamp_error(7),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(1, 2))),
|
||||
];
|
||||
|
||||
let record = classify(cmsgs.into_iter());
|
||||
|
||||
assert_eq!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp {
|
||||
id: 7,
|
||||
timestamp: UNIX_EPOCH + Duration::new(1, 2),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamp_origin_without_timestamp_as_malformed() {
|
||||
let cmsgs = vec![send_timestamp_error(3)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamp_origin_with_invalid_timespec_as_malformed() {
|
||||
let cmsgs = vec![
|
||||
send_timestamp_error(3),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(-1, 0))),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_non_timestamp_extended_error_as_socket_error() {
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ECONNREFUSED as u32,
|
||||
nix::libc::SO_EE_ORIGIN_ICMP,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::SocketError {
|
||||
errno: nix::libc::ECONNREFUSED,
|
||||
origin: nix::libc::SO_EE_ORIGIN_ICMP,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamping_origin_with_wrong_completion_kind_as_malformed() {
|
||||
// Same origin byte as a send-timestamp completion, but a different
|
||||
// `ee_info` (e.g. SCM_TSTAMP_SCHED/ACK, which this client never
|
||||
// requests). A timestamping-origin record must never become a
|
||||
// fatal SocketError merely because it reports a completion kind we
|
||||
// did not ask for; it stays timing metadata.
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ENOMSG as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
1,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamping_origin_with_unexpected_errno_as_malformed() {
|
||||
// Origin is the sole discriminator: even an origin==TIMESTAMPING
|
||||
// record with a surprising errno (the kernel always reports ENOMSG
|
||||
// for this origin, but classification must not assume that) is
|
||||
// still timing metadata, never a real socket error.
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ECONNREFUSED as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
SCM_TSTAMP_SND,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_records_without_any_extended_error() {
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4Tos(0)];
|
||||
|
||||
assert_eq!(classify(cmsgs.into_iter()), ErrorQueueRecord::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cmsgs_do_not_affect_classification() {
|
||||
let cmsgs = vec![
|
||||
ControlMessageOwned::Ipv4Tos(0),
|
||||
send_timestamp_error(4),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(9, 0))),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::TxTimestamp {
|
||||
id: 4,
|
||||
timestamp: UNIX_EPOCH + Duration::new(9, 0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmsg_order_does_not_affect_classification() {
|
||||
let forward = classify(
|
||||
vec![
|
||||
send_timestamp_error(9),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(5, 0))),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
let reversed = classify(
|
||||
vec![
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(5, 0))),
|
||||
send_timestamp_error(9),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(forward, reversed);
|
||||
}
|
||||
|
||||
/// Bounded poll for one error-queue record, since software TX timestamp
|
||||
/// delivery is asynchronous even on loopback. Never sleeps hoping
|
||||
/// delivery "probably" happened; it keeps polling until either a
|
||||
/// record appears or the overall deadline passes.
|
||||
fn poll_for_record(fd: RawFd, overall_timeout: Duration) -> Option<ErrorQueueRecord> {
|
||||
let deadline = Instant::now() + overall_timeout;
|
||||
loop {
|
||||
if let Some(record) = try_recv_error_queue_record(fd).unwrap() {
|
||||
return Some(record);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
fn tx_timestamped_loopback_socket() -> Option<(UdpSocket, UdpSocket)> {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
// Held for the caller's lifetime: if this peer socket were dropped,
|
||||
// its port would close and subsequent sends would fail with
|
||||
// ECONNREFUSED once the kernel reports ICMP port-unreachable.
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(peer.local_addr().unwrap()).unwrap();
|
||||
|
||||
match setsockopt(&socket, sockopt::Timestamping, &TX_TIMESTAMPING_FLAGS) {
|
||||
Ok(()) => Some((socket, peer)),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"skipping TX timestamping smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_opt_id_increments_sequentially_across_separate_sends() {
|
||||
let Some((socket, _peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
let fd = socket.as_raw_fd();
|
||||
|
||||
// Under heavy parallel test load, loopback UDP sends can
|
||||
// occasionally surface a stale ICMP-derived socket error unrelated
|
||||
// to this test's own sockets. That is an environmental condition,
|
||||
// not a claim this test makes about kernel TX timestamp behavior,
|
||||
// so it is a skip rather than a failure.
|
||||
if let Err(error) = socket.send(b"first") {
|
||||
eprintln!("skipping automatic OPT_ID assertion: first send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(first) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping automatic OPT_ID assertion: no TX timestamp record was \
|
||||
delivered within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(first, ErrorQueueRecord::TxTimestamp { id: 0, .. }),
|
||||
"expected first send to report id 0, got {first:?}"
|
||||
);
|
||||
|
||||
if let Err(error) = socket.send(b"second") {
|
||||
eprintln!("skipping automatic OPT_ID assertion: second send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(second) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping automatic OPT_ID assertion: no TX timestamp record was \
|
||||
delivered for the second send within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(second, ErrorQueueRecord::TxTimestamp { id: 1, .. }),
|
||||
"expected second send to report id 1, got {second:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rx_only_sends_before_tx_enable_do_not_consume_the_later_tx_id_namespace() {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(peer.local_addr().unwrap()).unwrap();
|
||||
setsockopt(
|
||||
&socket,
|
||||
sockopt::Timestamping,
|
||||
&super::super::RX_TIMESTAMPING_FLAGS,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Ordinary pre-enable sends: TX_SOFTWARE is not yet requested, so
|
||||
// none of these may leave anything on the error queue for the
|
||||
// later-enabled OPT_ID counter to have skipped past.
|
||||
for _ in 0..3 {
|
||||
if let Err(error) = socket.send(b"pre-enable") {
|
||||
eprintln!("skipping pre-enable send/RX-only smoke test: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
try_recv_error_queue_record(socket.as_raw_fd()).unwrap(),
|
||||
None,
|
||||
"RX-only sends must never populate MSG_ERRQUEUE"
|
||||
);
|
||||
|
||||
// This mirrors production: RX-only is configured first (e.g. at
|
||||
// connect time), and only later, after a successful Open, is the
|
||||
// socket upgraded to TX_TIMESTAMPING_FLAGS.
|
||||
if setsockopt(&socket, sockopt::Timestamping, &TX_TIMESTAMPING_FLAGS).is_err() {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = socket.send(b"first-timestamped") {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: post-upgrade send failed: {error}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: no TX timestamp record was \
|
||||
delivered within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(record, ErrorQueueRecord::TxTimestamp { id: 0, .. }),
|
||||
"expected the first send after upgrade to report id 0 (pre-enable sends must not \
|
||||
have consumed part of the namespace), got {record:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_rx_scm_timestamping_still_works_after_tx_flags_are_enabled() {
|
||||
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
peer.send_to(b"reply", socket.local_addr().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let Ok(datagram) = super::super::recv_datagram(&socket, &mut buf) else {
|
||||
eprintln!("skipping post-TX-enable RX smoke test: receive failed");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&buf[..datagram.len], b"reply");
|
||||
let Some(timestamp) = datagram.meta.kernel_rx_timestamp else {
|
||||
eprintln!(
|
||||
"skipping post-TX-enable RX smoke test: kernel did not provide SCM_TIMESTAMPING"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(timestamp.duration_since(std::time::UNIX_EPOCH).unwrap() > Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_timestamp_and_normal_reply_streams_do_not_interfere() {
|
||||
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
let fd = socket.as_raw_fd();
|
||||
|
||||
if let Err(error) = socket.send(b"ping") {
|
||||
eprintln!("skipping TX/RX separation smoke test: ping send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let mut incoming = [0_u8; 16];
|
||||
let Ok((len, from)) = peer.recv_from(&mut incoming) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: peer never observed ping");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&incoming[..len], b"ping");
|
||||
peer.send_to(b"pong", from).unwrap();
|
||||
|
||||
// Reading the normal payload must not require, wait for, or be
|
||||
// disturbed by draining MSG_ERRQUEUE first.
|
||||
let mut reply_buf = [0_u8; 16];
|
||||
let Ok(datagram) = super::super::recv_datagram(&socket, &mut reply_buf) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: normal receive failed");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&reply_buf[..datagram.len], b"pong");
|
||||
|
||||
// The TX timestamp for "ping" is still independently available on
|
||||
// the error queue, unconsumed by the normal receive above.
|
||||
let Some(record) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: no TX timestamp record arrived");
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp { id: 0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_timestamp_correlation_does_not_depend_on_original_payload() {
|
||||
let Some((socket, _peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// OPT_TSONLY notifications never carry the original payload; a
|
||||
// larger payload must classify identically to the zero-length one
|
||||
// used elsewhere, since correlation is by `id` alone.
|
||||
let payload = vec![0x5A_u8; 512];
|
||||
if let Err(error) = socket.send(&payload) {
|
||||
eprintln!("skipping OPT_TSONLY payload-independence smoke test: send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping OPT_TSONLY payload-independence smoke test: no TX timestamp record \
|
||||
arrived"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp { id: 0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
// NOTE: There is no live test for whether a failed nonblocking `sendmsg`
|
||||
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
|
||||
// on a connected loopback UDP socket (for example through socket-buffer
|
||||
// saturation) has no deterministic OS-level mechanism in this environment,
|
||||
// and the repository's testing policy forbids sleep/saturation-based
|
||||
// flakiness. The correlation is intentionally best effort: a kernel ID can
|
||||
// theoretically be consumed by a send that later fails. The deterministic
|
||||
// adapter test covers only the local half: an injected `WouldBlock` does
|
||||
// not advance the local wire-sequence counter. Any later unmatched kernel
|
||||
// timestamp is discarded and the userspace `sent_at` remains the fallback.
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::{error::Error, fmt, net::SocketAddr, time::Duration};
|
||||
|
||||
use crate::{ClientError, ClientEvent, ClientTimestamp};
|
||||
|
||||
/// An Echo datagram accepted by the UDP socket and committed in session state.
|
||||
///
|
||||
/// The wire sequence and sent count have advanced, and the probe is pending.
|
||||
/// This receipt remains authoritative even if subsequent validation or kernel
|
||||
/// TX timestamp processing fails. It carries transport facts only; managed
|
||||
/// scheduling annotations belong on the corresponding [`ClientEvent::EchoSent`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SendReceipt {
|
||||
/// Committed wire sequence number.
|
||||
pub seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
pub remote: SocketAddr,
|
||||
/// Public measurement timestamp sampled immediately after socket acceptance.
|
||||
/// The private pre-send anchor still determines the timeout deadline.
|
||||
pub sent_at: ClientTimestamp,
|
||||
/// Number of bytes the socket reported accepting.
|
||||
pub bytes: usize,
|
||||
/// Time spent in the successful socket send call.
|
||||
pub send_call: Duration,
|
||||
}
|
||||
|
||||
impl From<SendReceipt> for ClientEvent {
|
||||
/// Construct the committed send event without scheduling annotations.
|
||||
fn from(receipt: SendReceipt) -> Self {
|
||||
Self::EchoSent {
|
||||
seq: receipt.seq,
|
||||
remote: receipt.remote,
|
||||
scheduled_at: None,
|
||||
sent_at: receipt.sent_at,
|
||||
bytes: receipt.bytes,
|
||||
send_call: receipt.send_call,
|
||||
timer_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe-send failure with an explicit socket-acceptance commitment boundary.
|
||||
///
|
||||
/// There is deliberately no blanket conversion from [`ClientError`]: each send
|
||||
/// failure must be classified at the point where its commitment is known.
|
||||
#[derive(Debug)]
|
||||
pub enum SendProbeError {
|
||||
/// No datagram was accepted and no probe state or sequence was committed.
|
||||
NotCommitted(ClientError),
|
||||
/// The probe was committed, then post-send processing failed.
|
||||
/// The pending probe and advanced sequence/count remain committed.
|
||||
AfterCommit {
|
||||
/// Authoritative receipt for the accepted send.
|
||||
receipt: SendReceipt,
|
||||
/// Operational cause of the post-send failure.
|
||||
source: Box<ClientError>,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for SendProbeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NotCommitted(source) => write!(f, "probe send was not committed: {source}"),
|
||||
Self::AfterCommit { source, .. } => {
|
||||
write!(
|
||||
f,
|
||||
"probe send was committed, but post-send processing failed: {source}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SendProbeError {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(match self {
|
||||
Self::NotCommitted(source) => source,
|
||||
Self::AfterCommit { source, .. } => source.as_ref(),
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,4 +2,4 @@ pub(crate) mod machine;
|
||||
mod negotiation;
|
||||
|
||||
pub(crate) use negotiation::negotiate_params;
|
||||
pub use negotiation::{NegotiatedParams, NegotiationRestriction};
|
||||
pub use negotiation::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
|
||||
@@ -1,117 +1,139 @@
|
||||
use std::fmt;
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, PROTOCOL_VERSION};
|
||||
|
||||
use crate::{config::NegotiationPolicy, error::ClientError};
|
||||
|
||||
/// Protocol parameters accepted for a session.
|
||||
/// Validated session semantics accepted after a successful Open exchange.
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// `params.dscp` is the raw IP TOS / Traffic Class byte, not a DSCP
|
||||
/// codepoint: for a configured codepoint of 46 (EF), an unrestricted
|
||||
/// negotiation leaves `params.dscp == 184`. [`NegotiationRestriction::DscpChanged`]
|
||||
/// reports codepoints instead, for consistency with [`crate::ClientConfig::dscp`].
|
||||
/// These values drive ordinary client behavior. The exact peer-returned wire
|
||||
/// representation is available separately in [`NegotiationResult::peer_params`].
|
||||
#[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>,
|
||||
pub struct AcceptedSessionParameters {
|
||||
/// `None` means continuous mode; finite durations are positive.
|
||||
pub duration: Option<Duration>,
|
||||
/// Positive interval between probes when driven by a scheduler.
|
||||
pub interval: Duration,
|
||||
/// Accepted packet length in the usable unsigned domain.
|
||||
pub length: u32,
|
||||
pub received_stats: ReceivedStats,
|
||||
pub stamp_at: StampAt,
|
||||
pub clock: Clock,
|
||||
/// DSCP codepoint (`0..=63`), as in [`crate::SessionRequest::dscp`].
|
||||
pub dscp: u8,
|
||||
/// Accepted server payload fill, preserving the peer's string value.
|
||||
pub server_fill: Option<String>,
|
||||
}
|
||||
|
||||
/// A server-side restriction applied during session parameter negotiation.
|
||||
/// Accepted session semantics, exact protocol evidence, and accepted changes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NegotiationRestriction {
|
||||
/// Run duration was reduced.
|
||||
pub struct NegotiationResult {
|
||||
pub accepted: AcceptedSessionParameters,
|
||||
/// Exact decoded parameters returned by the peer, without normalization.
|
||||
///
|
||||
/// 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.
|
||||
/// Echo encoding/decoding uses these accepted wire values. `dscp` here is
|
||||
/// the raw IP TOS / Traffic Class byte, unlike [`AcceptedSessionParameters::dscp`].
|
||||
pub peer_params: Params,
|
||||
/// Differences accepted under [`crate::NegotiationPolicy::Loose`].
|
||||
pub changes: Vec<NegotiationChange>,
|
||||
}
|
||||
|
||||
/// A semantic parameter change accepted during negotiation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NegotiationChange {
|
||||
/// A finite duration was reduced, or continuous mode was limited.
|
||||
DurationReduced {
|
||||
requested_ns: i64,
|
||||
negotiated_ns: i64,
|
||||
/// `None` means the request was continuous.
|
||||
requested: Option<Duration>,
|
||||
negotiated: Duration,
|
||||
},
|
||||
/// Probe interval was increased.
|
||||
IntervalIncreased {
|
||||
requested_ns: i64,
|
||||
negotiated_ns: i64,
|
||||
requested: Duration,
|
||||
negotiated: Duration,
|
||||
},
|
||||
/// Probe interval was reduced.
|
||||
/// Loose policy accepts any positive reduced interval.
|
||||
IntervalReduced {
|
||||
requested_ns: i64,
|
||||
negotiated_ns: i64,
|
||||
requested: Duration,
|
||||
negotiated: Duration,
|
||||
},
|
||||
LengthReduced {
|
||||
requested: u32,
|
||||
negotiated: u32,
|
||||
},
|
||||
/// 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.
|
||||
///
|
||||
/// These values are DSCP codepoints (`0..=63`) for human-facing
|
||||
/// consistency with [`crate::ClientConfig::dscp`], not the raw wire
|
||||
/// `Params::dscp` byte carried by [`NegotiatedParams::params`].
|
||||
DscpChanged { requested: i64, negotiated: i64 },
|
||||
/// Returned server payload fill behavior differs from the request.
|
||||
ServerFillChanged,
|
||||
ClockChanged {
|
||||
requested: Clock,
|
||||
negotiated: Clock,
|
||||
},
|
||||
/// Both values are DSCP codepoints, rather than raw Traffic Class bytes.
|
||||
DscpChanged {
|
||||
requested: u8,
|
||||
negotiated: u8,
|
||||
},
|
||||
ServerFillChanged {
|
||||
requested: Option<String>,
|
||||
negotiated: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl NegotiationRestriction {
|
||||
/// Return a human-readable description of this negotiated restriction.
|
||||
impl NegotiationChange {
|
||||
/// Return a human-readable description of this accepted change.
|
||||
pub fn message(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NegotiationRestriction {
|
||||
impl fmt::Display for NegotiationChange {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DurationReduced {
|
||||
requested_ns: 0,
|
||||
negotiated_ns,
|
||||
requested: None,
|
||||
negotiated,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"server limited continuous duration to {negotiated_ns} ns"
|
||||
"server limited continuous duration to {} ns",
|
||||
negotiated.as_nanos()
|
||||
)
|
||||
}
|
||||
Self::DurationReduced {
|
||||
requested_ns,
|
||||
negotiated_ns,
|
||||
requested: Some(requested),
|
||||
negotiated,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"server reduced duration from {requested_ns} ns to {negotiated_ns} ns"
|
||||
"server reduced duration from {} ns to {} ns",
|
||||
requested.as_nanos(),
|
||||
negotiated.as_nanos()
|
||||
)
|
||||
}
|
||||
Self::IntervalIncreased {
|
||||
requested_ns,
|
||||
negotiated_ns,
|
||||
requested,
|
||||
negotiated,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"server increased interval from {requested_ns} ns to {negotiated_ns} ns"
|
||||
"server increased interval from {} ns to {} ns",
|
||||
requested.as_nanos(),
|
||||
negotiated.as_nanos()
|
||||
)
|
||||
}
|
||||
Self::IntervalReduced {
|
||||
requested_ns,
|
||||
negotiated_ns,
|
||||
requested,
|
||||
negotiated,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"server reduced interval from {requested_ns} ns to {negotiated_ns} ns"
|
||||
"server reduced interval from {} ns to {} ns",
|
||||
requested.as_nanos(),
|
||||
negotiated.as_nanos()
|
||||
)
|
||||
}
|
||||
Self::LengthReduced {
|
||||
@@ -156,23 +178,32 @@ impl fmt::Display for NegotiationRestriction {
|
||||
} => {
|
||||
write!(f, "server changed DSCP from {requested} to {negotiated}")
|
||||
}
|
||||
Self::ServerFillChanged => write!(f, "server changed payload fill behavior"),
|
||||
Self::ServerFillChanged { .. } => write!(f, "server changed payload fill behavior"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called only after validating the request and returned numeric domains.
|
||||
fn positive_duration(nanos: i64) -> Duration {
|
||||
Duration::from_nanos(u64::try_from(nanos).expect("validated non-negative nanoseconds"))
|
||||
}
|
||||
|
||||
fn finite_duration(nanos: i64) -> Option<Duration> {
|
||||
(nanos != 0).then(|| positive_duration(nanos))
|
||||
}
|
||||
|
||||
pub(crate) fn negotiate_params(
|
||||
requested: &Params,
|
||||
returned: Params,
|
||||
policy: NegotiationPolicy,
|
||||
) -> Result<NegotiatedParams, ClientError> {
|
||||
) -> Result<NegotiationResult, ClientError> {
|
||||
if returned.protocol_version != PROTOCOL_VERSION {
|
||||
return Err(ClientError::ProtocolVersionMismatch {
|
||||
requested: PROTOCOL_VERSION,
|
||||
received: returned.protocol_version,
|
||||
});
|
||||
}
|
||||
let mut restrictions = Vec::new();
|
||||
let mut changes = Vec::new();
|
||||
|
||||
validate_duration_restriction(requested.duration_ns, returned.duration_ns)?;
|
||||
if returned.length < 0 {
|
||||
@@ -195,70 +226,70 @@ pub(crate) fn negotiate_params(
|
||||
if returned.duration_ns < requested.duration_ns
|
||||
|| (requested.duration_ns == 0 && returned.duration_ns > 0)
|
||||
{
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested.duration_ns,
|
||||
negotiated_ns: returned.duration_ns,
|
||||
&mut changes,
|
||||
NegotiationChange::DurationReduced {
|
||||
requested: finite_duration(requested.duration_ns),
|
||||
negotiated: positive_duration(returned.duration_ns),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if returned.interval_ns > requested.interval_ns {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::IntervalIncreased {
|
||||
requested_ns: requested.interval_ns,
|
||||
negotiated_ns: returned.interval_ns,
|
||||
&mut changes,
|
||||
NegotiationChange::IntervalIncreased {
|
||||
requested: positive_duration(requested.interval_ns),
|
||||
negotiated: positive_duration(returned.interval_ns),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if returned.interval_ns < requested.interval_ns {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::IntervalReduced {
|
||||
requested_ns: requested.interval_ns,
|
||||
negotiated_ns: returned.interval_ns,
|
||||
&mut changes,
|
||||
NegotiationChange::IntervalReduced {
|
||||
requested: positive_duration(requested.interval_ns),
|
||||
negotiated: positive_duration(returned.interval_ns),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if returned.length < requested.length {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::LengthReduced {
|
||||
requested: requested.length,
|
||||
negotiated: returned.length,
|
||||
&mut changes,
|
||||
NegotiationChange::LengthReduced {
|
||||
requested: u32::try_from(requested.length).expect("validated requested length"),
|
||||
negotiated: u32::try_from(returned.length).expect("validated returned length"),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if returned.received_stats != requested.received_stats {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::ReceivedStatsChanged {
|
||||
&mut changes,
|
||||
NegotiationChange::ReceivedStatsChanged {
|
||||
requested: requested.received_stats,
|
||||
negotiated: returned.received_stats,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if returned.stamp_at != requested.stamp_at {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::StampAtChanged {
|
||||
&mut changes,
|
||||
NegotiationChange::StampAtChanged {
|
||||
requested: requested.stamp_at,
|
||||
negotiated: returned.stamp_at,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if returned.clock != requested.clock {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::ClockChanged {
|
||||
&mut changes,
|
||||
NegotiationChange::ClockChanged {
|
||||
requested: requested.clock,
|
||||
negotiated: returned.clock,
|
||||
},
|
||||
@@ -270,29 +301,48 @@ pub(crate) fn negotiate_params(
|
||||
});
|
||||
}
|
||||
if returned.dscp == 0 && requested.dscp != 0 {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::DscpChanged {
|
||||
&mut changes,
|
||||
NegotiationChange::DscpChanged {
|
||||
// `requested`/`negotiated` here are raw wire TOS/Traffic Class
|
||||
// bytes; shift back to the codepoint the user actually
|
||||
// configured for a human-facing restriction.
|
||||
requested: requested.dscp >> 2,
|
||||
negotiated: returned.dscp >> 2,
|
||||
// configured for a semantic change record.
|
||||
requested: u8::try_from(requested.dscp >> 2).expect("validated requested DSCP"),
|
||||
negotiated: u8::try_from(returned.dscp >> 2).expect("validated returned DSCP"),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if returned.server_fill != requested.server_fill {
|
||||
record_restriction(
|
||||
record_change(
|
||||
policy,
|
||||
&mut restrictions,
|
||||
NegotiationRestriction::ServerFillChanged,
|
||||
&mut changes,
|
||||
NegotiationChange::ServerFillChanged {
|
||||
requested: requested
|
||||
.server_fill
|
||||
.as_ref()
|
||||
.map(|fill| fill.value.clone()),
|
||||
negotiated: returned.server_fill.as_ref().map(|fill| fill.value.clone()),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(NegotiatedParams {
|
||||
params: returned,
|
||||
restrictions,
|
||||
// Compare the exact wire values above before deriving accepted semantics.
|
||||
// In particular, do not normalize fill or traffic class before Strict policy.
|
||||
let accepted = AcceptedSessionParameters {
|
||||
duration: finite_duration(returned.duration_ns),
|
||||
interval: positive_duration(returned.interval_ns),
|
||||
length: u32::try_from(returned.length).expect("validated returned length"),
|
||||
received_stats: returned.received_stats,
|
||||
stamp_at: returned.stamp_at,
|
||||
clock: returned.clock,
|
||||
dscp: u8::try_from(returned.dscp >> 2).expect("validated returned DSCP"),
|
||||
server_fill: returned.server_fill.as_ref().map(|fill| fill.value.clone()),
|
||||
};
|
||||
Ok(NegotiationResult {
|
||||
accepted,
|
||||
peer_params: returned,
|
||||
changes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,194 +382,17 @@ fn validate_dscp_restriction(returned: i64) -> Result<(), ClientError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_restriction(
|
||||
fn record_change(
|
||||
policy: NegotiationPolicy,
|
||||
restrictions: &mut Vec<NegotiationRestriction>,
|
||||
restriction: NegotiationRestriction,
|
||||
changes: &mut Vec<NegotiationChange>,
|
||||
change: NegotiationChange,
|
||||
) -> Result<(), ClientError> {
|
||||
if policy == NegotiationPolicy::Strict {
|
||||
return Err(ClientError::NegotiationRejected {
|
||||
reason: restriction.message(),
|
||||
reason: change.message(),
|
||||
});
|
||||
}
|
||||
|
||||
restrictions.push(restriction);
|
||||
changes.push(change);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_proto::{Clock, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
fn default_params() -> Params {
|
||||
Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
length: 256,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
dscp: 184,
|
||||
server_fill: Some(ServerFill {
|
||||
value: "rand".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_rejected(requested: &Params, returned: &Params, policy: NegotiationPolicy) {
|
||||
assert!(matches!(
|
||||
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 negotiate_params(requested, returned.clone(), policy) {
|
||||
Err(ClientError::NegotiationRejected { reason }) => reason,
|
||||
other => panic!("expected negotiation rejection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rejects_changed_negotiated_fields() {
|
||||
let requested = default_params();
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.length = 128;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::LengthReduced {
|
||||
requested: requested.length,
|
||||
negotiated: returned.length,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 0;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::DscpChanged {
|
||||
requested: requested.dscp >> 2,
|
||||
negotiated: returned.dscp >> 2,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.received_stats = ReceivedStats::Count;
|
||||
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_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_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_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::ServerFillChanged.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_duration_negotiation_uses_run_duration_semantics() {
|
||||
let requested = default_params();
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = requested.duration_ns / 2;
|
||||
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();
|
||||
returned.duration_ns = requested.duration_ns + 1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"duration increased"
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = 0;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned continuous duration for finite request"
|
||||
);
|
||||
|
||||
let mut continuous_requested = requested.clone();
|
||||
continuous_requested.duration_ns = 0;
|
||||
let mut finite_returned = continuous_requested.clone();
|
||||
finite_returned.duration_ns = 1_000_000_000;
|
||||
let negotiated = assert_negotiates(
|
||||
&continuous_requested,
|
||||
&finite_returned,
|
||||
NegotiationPolicy::Loose,
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![NegotiationRestriction::DurationReduced {
|
||||
requested_ns: 0,
|
||||
negotiated_ns: finite_returned.duration_ns,
|
||||
}]
|
||||
);
|
||||
|
||||
assert_rejected(
|
||||
&continuous_requested,
|
||||
&finite_returned,
|
||||
NegotiationPolicy::Strict,
|
||||
);
|
||||
|
||||
assert!(negotiate_params(
|
||||
&continuous_requested,
|
||||
continuous_requested.clone(),
|
||||
NegotiationPolicy::Strict
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,61 @@
|
||||
use std::{
|
||||
net::{SocketAddr, ToSocketAddrs, UdpSocket},
|
||||
time::Duration,
|
||||
};
|
||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, SocketConfig, DEFAULT_PORT, MIN_OPEN_TIMEOUT},
|
||||
config::{AddressFamily, SocketConfig, DEFAULT_PORT},
|
||||
error::ClientError,
|
||||
receive::configure_receive_metadata,
|
||||
socket_options::apply_ttl_to_socket,
|
||||
};
|
||||
|
||||
pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> {
|
||||
if timeouts.is_empty() {
|
||||
return Err(ClientError::NoOpenTimeouts);
|
||||
}
|
||||
for timeout in timeouts {
|
||||
if *timeout < MIN_OPEN_TIMEOUT {
|
||||
return Err(ClientError::OpenTimeoutTooSmall {
|
||||
timeout: *timeout,
|
||||
minimum: MIN_OPEN_TIMEOUT,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
use crate::socket_options::apply_routing_options;
|
||||
|
||||
pub(crate) fn resolve_remote(config: &ClientConfig) -> Result<SocketAddr, ClientError> {
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
SYNC_RESOLVER_CALLS.with(|calls| calls.set(calls.get() + 1));
|
||||
|
||||
let addr = normalize_server_addr(&config.server_addr);
|
||||
pub(crate) fn resolve_remote(
|
||||
endpoint: &str,
|
||||
family: AddressFamily,
|
||||
) -> Result<SocketAddr, ClientError> {
|
||||
let addr = normalize_server_addr(endpoint);
|
||||
let mut addrs = addr
|
||||
.to_socket_addrs()
|
||||
.map_err(|_| ClientError::Resolve { addr: addr.clone() })?;
|
||||
addrs
|
||||
.find(|addr| address_family_allowed(config, *addr))
|
||||
.find(|addr| address_family_allowed(family, *addr))
|
||||
.ok_or(ClientError::Resolve { addr })
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub(crate) async fn resolve_remote_tokio(config: &ClientConfig) -> Result<SocketAddr, ClientError> {
|
||||
let addr = normalize_server_addr(&config.server_addr);
|
||||
pub(crate) async fn resolve_remote_tokio(
|
||||
endpoint: &str,
|
||||
family: AddressFamily,
|
||||
) -> Result<SocketAddr, ClientError> {
|
||||
let addr = normalize_server_addr(endpoint);
|
||||
if let Ok(remote) = addr.parse::<SocketAddr>() {
|
||||
return address_family_allowed(config, remote)
|
||||
return address_family_allowed(family, remote)
|
||||
.then_some(remote)
|
||||
.ok_or(ClientError::Resolve { addr });
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
TOKIO_DNS_LOOKUPS.with(|calls| calls.set(calls.get() + 1));
|
||||
|
||||
let mut addrs = tokio::net::lookup_host(&addr)
|
||||
.await
|
||||
.map_err(|_| ClientError::Resolve { addr: addr.clone() })?;
|
||||
addrs
|
||||
.find(|remote| address_family_allowed(config, *remote))
|
||||
.find(|remote| address_family_allowed(family, *remote))
|
||||
.ok_or_else(|| ClientError::Resolve { addr: addr.clone() })
|
||||
}
|
||||
|
||||
fn address_family_allowed(config: &ClientConfig, remote: SocketAddr) -> bool {
|
||||
(!config.socket_config.ipv4_only || remote.is_ipv4())
|
||||
&& (!config.socket_config.ipv6_only || remote.is_ipv6())
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
std::thread_local! {
|
||||
static SYNC_RESOLVER_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
static TOKIO_DNS_LOOKUPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
pub(crate) fn resolution_call_counts() -> (usize, usize) {
|
||||
(
|
||||
SYNC_RESOLVER_CALLS.with(std::cell::Cell::get),
|
||||
TOKIO_DNS_LOOKUPS.with(std::cell::Cell::get),
|
||||
)
|
||||
fn address_family_allowed(family: AddressFamily, remote: SocketAddr) -> bool {
|
||||
match family {
|
||||
AddressFamily::Any => true,
|
||||
AddressFamily::Ipv4 => remote.is_ipv4(),
|
||||
AddressFamily::Ipv6 => remote.is_ipv6(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_server_addr(addr: &str) -> String {
|
||||
@@ -104,17 +83,16 @@ pub(crate) fn normalize_server_addr(addr: &str) -> String {
|
||||
pub(crate) fn connect_udp_socket(
|
||||
config: &SocketConfig,
|
||||
remote: SocketAddr,
|
||||
family: AddressFamily,
|
||||
) -> Result<UdpSocket, ClientError> {
|
||||
let socket = create_connected_udp_socket(config, remote)?;
|
||||
socket.set_read_timeout(config.recv_timeout)?;
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn create_connected_udp_socket(
|
||||
config: &SocketConfig,
|
||||
remote: SocketAddr,
|
||||
) -> Result<UdpSocket, ClientError> {
|
||||
let socket = create_prebind_udp_socket(config, remote)?;
|
||||
let socket = create_prebind_udp_socket(remote, family)?;
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
apply_routing_options(&socket, config, remote)?;
|
||||
let bind_addr = config.bind_addr.unwrap_or_else(|| {
|
||||
if remote.is_ipv4() {
|
||||
SocketAddr::from(([0, 0, 0, 0], 0))
|
||||
@@ -138,8 +116,8 @@ fn create_connected_udp_socket(
|
||||
}
|
||||
|
||||
fn create_prebind_udp_socket(
|
||||
config: &SocketConfig,
|
||||
remote: SocketAddr,
|
||||
family: AddressFamily,
|
||||
) -> Result<Socket, ClientError> {
|
||||
let domain = if remote.is_ipv4() {
|
||||
Domain::IPV4
|
||||
@@ -148,7 +126,7 @@ fn create_prebind_udp_socket(
|
||||
};
|
||||
let socket = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
|
||||
if config.ipv6_only && remote.is_ipv6() {
|
||||
if family == AddressFamily::Ipv6 && remote.is_ipv6() {
|
||||
socket.set_only_v6(true)?;
|
||||
}
|
||||
Ok(socket)
|
||||
@@ -158,212 +136,9 @@ fn create_prebind_udp_socket(
|
||||
pub(crate) fn connect_tokio_udp_socket(
|
||||
config: &SocketConfig,
|
||||
remote: SocketAddr,
|
||||
family: AddressFamily,
|
||||
) -> Result<tokio::net::UdpSocket, ClientError> {
|
||||
let socket = create_connected_udp_socket(config, remote)?;
|
||||
let socket = connect_udp_socket(config, remote, family)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok(tokio::net::UdpSocket::from_std(socket)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_server_addr_adds_default_ports() {
|
||||
assert_eq!(normalize_server_addr("127.0.0.1"), "127.0.0.1:2112");
|
||||
assert_eq!(normalize_server_addr("127.0.0.1:1234"), "127.0.0.1:1234");
|
||||
assert_eq!(normalize_server_addr("localhost"), "localhost:2112");
|
||||
assert_eq!(normalize_server_addr("localhost:1234"), "localhost:1234");
|
||||
assert_eq!(normalize_server_addr("::1"), "[::1]:2112");
|
||||
assert_eq!(normalize_server_addr("[::1]"), "[::1]:2112");
|
||||
assert_eq!(normalize_server_addr("[::1]:1234"), "[::1]:1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_family_filtering_remains_unchanged() {
|
||||
let ipv4 = SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT));
|
||||
let ipv6 = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], DEFAULT_PORT));
|
||||
let mut config = ClientConfig::default();
|
||||
|
||||
assert!(address_family_allowed(&config, ipv4));
|
||||
assert!(address_family_allowed(&config, ipv6));
|
||||
|
||||
config.socket_config.ipv4_only = true;
|
||||
assert!(address_family_allowed(&config, ipv4));
|
||||
assert!(!address_family_allowed(&config, ipv6));
|
||||
|
||||
config.socket_config.ipv4_only = false;
|
||||
config.socket_config.ipv6_only = true;
|
||||
assert!(!address_family_allowed(&config, ipv4));
|
||||
assert!(address_family_allowed(&config, ipv6));
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv6_only_is_applied_before_bind() {
|
||||
let config = SocketConfig {
|
||||
ipv6_only: true,
|
||||
..SocketConfig::default()
|
||||
};
|
||||
let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], DEFAULT_PORT));
|
||||
|
||||
let socket = create_prebind_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket.only_v6().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv6_socket_preserves_v6only_and_explicit_bind() {
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0));
|
||||
let peer = match UdpSocket::bind(bind_addr) {
|
||||
Ok(peer) => peer,
|
||||
Err(error) if ipv6_loopback_unavailable(&error) => {
|
||||
eprintln!("skipping IPv6 Tokio socket test: IPv6 loopback unavailable: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
let remote = peer.local_addr().unwrap();
|
||||
let config = SocketConfig {
|
||||
bind_addr: Some(bind_addr),
|
||||
ipv6_only: true,
|
||||
..SocketConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let socket = connect_tokio_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket2::SockRef::from(&socket).only_v6().unwrap());
|
||||
assert!(socket.local_addr().unwrap().is_ipv6());
|
||||
assert_eq!(socket.local_addr().unwrap().ip(), bind_addr.ip());
|
||||
assert_eq!(socket.peer_addr().unwrap(), remote);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv4_socket_preserves_family_bind_peer_and_ttl() {
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let remote = peer.local_addr().unwrap();
|
||||
let bind_addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let config = SocketConfig {
|
||||
bind_addr: Some(bind_addr),
|
||||
ttl: Some(64),
|
||||
..SocketConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let socket = connect_tokio_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket.local_addr().unwrap().is_ipv4());
|
||||
assert_eq!(socket.local_addr().unwrap().ip(), bind_addr.ip());
|
||||
assert_eq!(socket.peer_addr().unwrap(), remote);
|
||||
assert_eq!(socket2::SockRef::from(&socket).ttl_v4().unwrap(), 64);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_filters_families_without_using_blocking_resolution() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "localhost:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv4_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let before = resolution_call_counts();
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
let after = resolution_call_counts();
|
||||
|
||||
assert!(remote.is_ipv4());
|
||||
assert_eq!(after.0, before.0);
|
||||
assert_eq!(after.1, before.1 + 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_selects_ipv6_for_ipv6_only_hostname() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "localhost:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
assert!(remote.is_ipv6());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_literal_path_bypasses_all_name_resolution() {
|
||||
let config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let before = resolution_call_counts();
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
let after = resolution_call_counts();
|
||||
|
||||
assert_eq!(remote, SocketAddr::from(([127, 0, 0, 1], 2112)));
|
||||
assert_eq!(after, before);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_rejects_disallowed_literal_family() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
assert!(matches!(
|
||||
resolve_remote_tokio(&config).await,
|
||||
Err(ClientError::Resolve { .. })
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_rejects_mixed_family_restrictions() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv4_only = true;
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
assert!(matches!(
|
||||
resolve_remote_tokio(&config).await,
|
||||
Err(ClientError::Resolve { .. })
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn tokio_runtime() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn ipv6_loopback_unavailable(error: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,64 @@ use std::{
|
||||
|
||||
use socket2::SockRef;
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
use socket2::Socket;
|
||||
|
||||
use crate::{
|
||||
config::{MAX_DSCP_CODEPOINT, MAX_TTL},
|
||||
error::ClientError,
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
pub(crate) fn apply_routing_options(
|
||||
socket: &Socket,
|
||||
config: &crate::SocketConfig,
|
||||
remote: SocketAddr,
|
||||
) -> Result<(), ClientError> {
|
||||
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||
{
|
||||
if let Some(device) = &config.bind_to_device {
|
||||
socket
|
||||
.bind_device(Some(device.as_bytes()))
|
||||
.map_err(|source| ClientError::SocketOption {
|
||||
operation: "bind socket to device",
|
||||
remote,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
if let Some(mark) = config.mark {
|
||||
socket
|
||||
.set_mark(mark)
|
||||
.map_err(|source| ClientError::SocketOption {
|
||||
operation: "set firewall mark",
|
||||
remote,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
if let Some(fib) = config.fib {
|
||||
socket
|
||||
.set_fib(fib)
|
||||
.map_err(|source| ClientError::SocketOption {
|
||||
operation: "set FIB",
|
||||
remote,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts a public DSCP codepoint (`0..=`[`MAX_DSCP_CODEPOINT`]) into the
|
||||
/// raw IP TOS / Traffic Class byte it occupies the upper six bits of.
|
||||
///
|
||||
@@ -87,52 +140,15 @@ pub(crate) fn apply_ttl_to_socket(
|
||||
ttl: u32,
|
||||
) -> Result<(), ClientError> {
|
||||
validate_ttl(ttl)?;
|
||||
set_socket_ttl(socket, remote, ttl).map_err(|source| ClientError::SocketOption {
|
||||
operation: "set TTL/hop limit",
|
||||
remote,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn socket_traffic_class(
|
||||
socket: &UdpSocket,
|
||||
remote: SocketAddr,
|
||||
) -> Result<u32, ClientError> {
|
||||
get_socket_traffic_class(socket, remote).map_err(|source| ClientError::SocketOption {
|
||||
operation: "read DSCP socket option",
|
||||
remote,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
pub(crate) fn tokio_socket_traffic_class(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
remote: SocketAddr,
|
||||
) -> Result<u32, ClientError> {
|
||||
get_socket_traffic_class_ref(SockRef::from(socket), remote).map_err(|source| {
|
||||
set_socket_ttl_ref(SockRef::from(socket), remote, ttl).map_err(|source| {
|
||||
ClientError::SocketOption {
|
||||
operation: "read DSCP socket option",
|
||||
operation: "set TTL/hop limit",
|
||||
remote,
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn socket_ttl(socket: &UdpSocket, remote: SocketAddr) -> Result<u32, ClientError> {
|
||||
get_socket_ttl(socket, remote).map_err(|source| ClientError::SocketOption {
|
||||
operation: "read TTL/hop limit socket option",
|
||||
remote,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_socket_ttl(socket: &UdpSocket, remote: SocketAddr, ttl: u32) -> io::Result<()> {
|
||||
set_socket_ttl_ref(SockRef::from(socket), remote, ttl)
|
||||
}
|
||||
|
||||
fn set_socket_ttl_ref(socket: SockRef<'_>, remote: SocketAddr, ttl: u32) -> io::Result<()> {
|
||||
if remote.is_ipv4() {
|
||||
socket.set_ttl_v4(ttl)
|
||||
@@ -141,23 +157,13 @@ fn set_socket_ttl_ref(socket: SockRef<'_>, remote: SocketAddr, ttl: u32) -> io::
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_socket_ttl(socket: &UdpSocket, remote: SocketAddr) -> io::Result<u32> {
|
||||
let socket = SockRef::from(socket);
|
||||
if remote.is_ipv4() {
|
||||
socket.ttl_v4()
|
||||
} else {
|
||||
socket.unicast_hops_v6()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_socket_traffic_class(
|
||||
socket: &UdpSocket,
|
||||
remote: SocketAddr,
|
||||
traffic_class: u32,
|
||||
operation: &'static str,
|
||||
) -> Result<(), ClientError> {
|
||||
set_socket_traffic_class_io(socket, remote, traffic_class).map_err(|source| {
|
||||
set_socket_traffic_class_ref(SockRef::from(socket), remote, traffic_class).map_err(|source| {
|
||||
ClientError::SocketOption {
|
||||
operation,
|
||||
remote,
|
||||
@@ -166,14 +172,6 @@ fn set_socket_traffic_class(
|
||||
})
|
||||
}
|
||||
|
||||
fn set_socket_traffic_class_io(
|
||||
socket: &UdpSocket,
|
||||
remote: SocketAddr,
|
||||
traffic_class: u32,
|
||||
) -> io::Result<()> {
|
||||
set_socket_traffic_class_ref(SockRef::from(socket), remote, traffic_class)
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn set_tokio_socket_traffic_class(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
@@ -282,302 +280,3 @@ fn unsupported_traffic_class(traffic_class: u32, feature: &'static str) -> io::R
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
))
|
||||
))]
|
||||
fn get_ipv4_traffic_class(socket: SockRef<'_>) -> io::Result<u32> {
|
||||
socket.tos_v4()
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)
|
||||
))]
|
||||
fn get_ipv4_traffic_class(_socket: SockRef<'_>) -> io::Result<u32> {
|
||||
unsupported_readback("IPv4 DSCP socket option readback")
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
)
|
||||
))]
|
||||
fn get_ipv6_traffic_class(socket: SockRef<'_>) -> io::Result<u32> {
|
||||
socket.tclass_v6()
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
not(any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
))
|
||||
))]
|
||||
fn get_ipv6_traffic_class(_socket: SockRef<'_>) -> io::Result<u32> {
|
||||
unsupported_readback("IPv6 DSCP socket option readback")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_socket_traffic_class(socket: &UdpSocket, remote: SocketAddr) -> io::Result<u32> {
|
||||
get_socket_traffic_class_ref(SockRef::from(socket), remote)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_socket_traffic_class_ref(socket: SockRef<'_>, remote: SocketAddr) -> io::Result<u32> {
|
||||
if remote.is_ipv4() {
|
||||
get_ipv4_traffic_class(socket)
|
||||
} else {
|
||||
get_ipv6_traffic_class(socket)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
not(any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
)),
|
||||
)
|
||||
))]
|
||||
fn unsupported_readback(feature: &'static str) -> io::Result<u32> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!("{feature} is unsupported on this target"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_dscp_codepoint_to_traffic_class_byte() {
|
||||
for (dscp, traffic_class) in [(0, 0), (1, 4), (46, 184), (63, 252)] {
|
||||
let value = dscp_codepoint_to_traffic_class(dscp).unwrap();
|
||||
assert_eq!(value, traffic_class);
|
||||
assert_eq!(value & 0b11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_dscp_codepoint() {
|
||||
assert!(matches!(
|
||||
dscp_codepoint_to_traffic_class(64),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_ttl_range() {
|
||||
assert!(validate_ttl(1).is_ok());
|
||||
assert!(validate_ttl(64).is_ok());
|
||||
assert!(validate_ttl(255).is_ok());
|
||||
assert!(matches!(
|
||||
validate_ttl(0),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_ttl(256),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_socket_option_sets_ttl() {
|
||||
let remote = SocketAddr::from(([127, 0, 0, 1], 9));
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(remote).unwrap();
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 64).unwrap();
|
||||
assert_eq!(socket_ttl(&socket, remote).unwrap(), 64);
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 1).unwrap();
|
||||
assert_eq!(socket_ttl(&socket, remote).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_socket_option_sets_unicast_hop_limit() {
|
||||
let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9));
|
||||
let Some(socket) = bind_connected_ipv6_loopback(remote) else {
|
||||
return;
|
||||
};
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 64).unwrap();
|
||||
let ttl = match socket_ttl(&socket, remote) {
|
||||
Ok(ttl) => ttl,
|
||||
Err(error) if is_unsupported_socket_readback(&error) => {
|
||||
eprintln!("skipping IPv6 hop-limit readback test: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
assert_eq!(ttl, 64);
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 1).unwrap();
|
||||
assert_eq!(socket_ttl(&socket, remote).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn ipv4_socket_option_sets_and_clears_traffic_class() {
|
||||
let remote = SocketAddr::from(([127, 0, 0, 1], 9));
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(remote).unwrap();
|
||||
|
||||
// 184 is the raw wire TOS/Traffic Class byte for DSCP codepoint 46
|
||||
// (EF); applying it directly must not shift it again.
|
||||
apply_traffic_class_to_socket(&socket, remote, 184).unwrap();
|
||||
assert_eq!(socket_traffic_class(&socket, remote).unwrap(), 184);
|
||||
|
||||
clear_dscp_on_socket(&socket, remote).unwrap();
|
||||
assert_eq!(socket_traffic_class(&socket, remote).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
feature = "tokio",
|
||||
not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
))
|
||||
))]
|
||||
#[test]
|
||||
fn tokio_ipv4_socket_option_sets_and_clears_traffic_class() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let remote = SocketAddr::from(([127, 0, 0, 1], 9));
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(remote).unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let socket = tokio::net::UdpSocket::from_std(socket).unwrap();
|
||||
|
||||
apply_traffic_class_to_tokio_socket(&socket, remote, 184).unwrap();
|
||||
assert_eq!(tokio_socket_traffic_class(&socket, remote).unwrap(), 184);
|
||||
|
||||
clear_dscp_on_tokio_socket(&socket, remote).unwrap();
|
||||
assert_eq!(tokio_socket_traffic_class(&socket, remote).unwrap(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
))]
|
||||
fn ipv6_socket_option_sets_and_clears_traffic_class() {
|
||||
let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9));
|
||||
let Some(socket) = bind_connected_ipv6_loopback(remote) else {
|
||||
return;
|
||||
};
|
||||
|
||||
apply_traffic_class_to_socket(&socket, remote, 184).unwrap();
|
||||
let traffic_class = match socket_traffic_class(&socket, remote) {
|
||||
Ok(traffic_class) => traffic_class,
|
||||
Err(error) if is_unsupported_socket_readback(&error) => {
|
||||
eprintln!("skipping IPv6 traffic-class readback test: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
assert_eq!(traffic_class, 184);
|
||||
|
||||
clear_dscp_on_socket(&socket, remote).unwrap();
|
||||
assert_eq!(socket_traffic_class(&socket, remote).unwrap(), 0);
|
||||
}
|
||||
|
||||
fn bind_connected_ipv6_loopback(remote: SocketAddr) -> Option<UdpSocket> {
|
||||
let socket = match UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0))) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!("skipping IPv6 socket option test: IPv6 loopback unavailable: {error}");
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
match socket.connect(remote) {
|
||||
Ok(()) => Some(socket),
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!("skipping IPv6 socket option test: IPv6 loopback unavailable: {error}");
|
||||
None
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unavailable_ipv6_loopback(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unsupported_socket_readback(error: &ClientError) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
ClientError::SocketOption { source, .. }
|
||||
if matches!(source.kind(), io::ErrorKind::Unsupported)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use irtt_client::*;
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[path = "client/mod.rs"]
|
||||
mod client;
|
||||
+136
-381
@@ -1,204 +1,37 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_fails_after_close() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
fn operations_fail_after_local_close_without_datagrams() {
|
||||
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, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
let config = default_test_config();
|
||||
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(50)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyClosed)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_sends_one_close_packet_with_negotiated_token() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1_234),
|
||||
mono: Instant::now() + Duration::from_secs(5),
|
||||
};
|
||||
client.test_hooks.close_sent_at.set(Some(sent_at));
|
||||
let events = client.close().unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ClientEvent::SessionClosed {
|
||||
token: TOKEN,
|
||||
at,
|
||||
..
|
||||
}] if *at == sent_at
|
||||
));
|
||||
assert!(!client.runtime.is_open());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let close = &packets[1];
|
||||
assert_eq!(close[3], flags::FLAG_CLOSE);
|
||||
assert_eq!(u64::from_le_bytes(close[4..12].try_into().unwrap()), TOKEN);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_event_reservation_failure_precedes_dscp_clear_and_send() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_event_reserve.set(true);
|
||||
client.test_hooks.fail_close_dscp_clear.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.close(),
|
||||
Err(ClientError::AllocationFailed {
|
||||
operation: "close event result",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
|
||||
assert!(client.test_hooks.fail_close_dscp_clear.get());
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
|
||||
client.test_hooks.fail_close_dscp_clear.set(false);
|
||||
client.close().unwrap();
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_dscp_clear_failure_precedes_send_and_preserves_open_state() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_dscp_clear.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.close(),
|
||||
Err(ClientError::SocketOption { .. })
|
||||
));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_send_failure_leaves_machine_and_schedule_open_for_retry() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(2_345),
|
||||
mono: Instant::now() + Duration::from_secs(6),
|
||||
};
|
||||
client.test_hooks.close_sent_at.set(Some(sent_at));
|
||||
client.test_hooks.fail_close_send.set(true);
|
||||
|
||||
assert!(matches!(client.close(), Err(ClientError::Socket(_))));
|
||||
assert_eq!(client.test_hooks.close_sent_at.get(), Some(sent_at));
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
|
||||
|
||||
assert!(matches!(
|
||||
client.close().unwrap().as_slice(),
|
||||
[ClientEvent::SessionClosed {
|
||||
token: TOKEN,
|
||||
at,
|
||||
..
|
||||
}] if *at == sent_at
|
||||
));
|
||||
assert_eq!(client.test_hooks.close_sent_at.get(), None);
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 2);
|
||||
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_successful_close_commits_before_length_mismatch() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let expected = client.runtime.prepare_close().unwrap().bytes.len();
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(3_456),
|
||||
mono: Instant::now() + Duration::from_secs(7),
|
||||
};
|
||||
client.test_hooks.close_sent_at.set(Some(sent_at));
|
||||
client.test_hooks.close_reported_len.set(Some(expected - 1));
|
||||
|
||||
assert!(matches!(
|
||||
client.close(),
|
||||
Err(ClientError::DatagramLengthMismatch {
|
||||
expected: error_expected,
|
||||
actual,
|
||||
}) if error_expected == expected && actual + 1 == expected
|
||||
));
|
||||
assert!(!client.runtime.is_open());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
assert_eq!(client.test_hooks.close_sent_at.get(), None);
|
||||
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::AlreadyClosed),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -222,145 +55,18 @@ fn send_probe_fails_after_close() {
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
Err(irtt_client::SendProbeError::NotCommitted(
|
||||
ClientError::AlreadyClosed
|
||||
))
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server({
|
||||
let params = params.clone();
|
||||
move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
let reply = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
while recv_request_timeout(&socket, &tx).is_some() {}
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(ClientEvent::EchoReply { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
events.get(1),
|
||||
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
|
||||
));
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
assert!(client.schedule.is_none());
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
));
|
||||
|
||||
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
|
||||
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_close_dscp_cleanup_failure_preserves_events_and_closed_state() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server({
|
||||
let params = params.clone();
|
||||
move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
socket
|
||||
.send_to(
|
||||
&echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
while recv_request_timeout(&socket, &tx).is_some() {}
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
})
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
client.test_hooks.fail_close_dscp_clear.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.recv_once().unwrap().as_slice(),
|
||||
[
|
||||
ClientEvent::EchoReply { .. },
|
||||
ClientEvent::SessionClosed { token: TOKEN, .. }
|
||||
]
|
||||
));
|
||||
assert!(client.is_peer_closed());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
|
||||
let open = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
let echo = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
assert_ne!(open[3] & FLAG_OPEN, 0);
|
||||
assert_eq!(echo[3] & flags::FLAG_CLOSE, 0);
|
||||
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_available_stops_after_peer_close() {
|
||||
let params = default_params();
|
||||
@@ -388,7 +94,7 @@ fn recv_available_stops_after_peer_close() {
|
||||
socket.send_to(&close, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
@@ -407,21 +113,6 @@ fn recv_available_stops_after_peer_close() {
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_echo_reply_does_not_close_session() {
|
||||
let params = default_params();
|
||||
let (mut client, server) = open_client_with_echo_server(¶ms);
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(events.as_slice(), [ClientEvent::EchoReply { .. }]));
|
||||
assert!(client.next_send_deadline().is_some());
|
||||
assert!(client.send_probe().is_ok());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_flagged_duplicate_emits_duplicate_then_closes() {
|
||||
let params = default_params();
|
||||
@@ -451,14 +142,10 @@ fn close_flagged_duplicate_emits_duplicate_then_closes() {
|
||||
socket.send_to(&close, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
})
|
||||
.unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
@@ -508,20 +195,20 @@ fn close_flagged_retained_timeout_emits_late_then_closes() {
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
probe_timeout: Duration::from_millis(50),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
let mut client = Client::connect(
|
||||
server.addr.to_string(),
|
||||
ClientConfig {
|
||||
probe_timeout: Duration::from_millis(50),
|
||||
..default_test_config()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
})
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let sent = client.send_probe().unwrap();
|
||||
let ClientEvent::EchoSent { sent_at, .. } = &sent[0] else {
|
||||
panic!("expected EchoSent");
|
||||
};
|
||||
let sent_at = sent.sent_at;
|
||||
assert!(matches!(
|
||||
client
|
||||
.poll_timeouts_at(sent_at.mono + client.probe_timeout())
|
||||
@@ -603,15 +290,17 @@ fn close_flagged_evicted_sequence_emits_late_then_closes() {
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
max_pending_probes: 1,
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
let mut client = Client::connect(
|
||||
server.addr.to_string(),
|
||||
ClientConfig {
|
||||
max_pending_probes: 1,
|
||||
..default_test_config()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
})
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
client.send_probe().unwrap();
|
||||
@@ -668,14 +357,10 @@ fn close_flagged_untracked_sequence_emits_warning_then_closes() {
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
})
|
||||
.unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
assert!(matches!(
|
||||
@@ -726,14 +411,10 @@ fn wrong_token_close_flag_does_not_close_session() {
|
||||
let _ = recv_request(&socket, &tx);
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
})
|
||||
.unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
@@ -745,10 +426,84 @@ fn wrong_token_close_flag_does_not_close_session() {
|
||||
}]
|
||||
));
|
||||
assert!(!client.is_peer_closed());
|
||||
assert!(matches!(
|
||||
client.send_probe().unwrap().as_slice(),
|
||||
[ClientEvent::EchoSent { seq: 1, .. }]
|
||||
));
|
||||
assert_eq!(client.send_probe().unwrap().seq, 1);
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server({
|
||||
let params = params.clone();
|
||||
move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
let reply = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
while recv_request_timeout(&socket, &tx).is_some() {}
|
||||
}
|
||||
});
|
||||
let config = default_test_config();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(ClientEvent::EchoReply { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
events.get(1),
|
||||
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
|
||||
));
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::AlreadyClosed),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(irtt_client::SendProbeError::NotCommitted(
|
||||
ClientError::AlreadyClosed
|
||||
))
|
||||
));
|
||||
|
||||
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
|
||||
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
|
||||
server.join();
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_configuration() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
||||
let cases = [
|
||||
(
|
||||
"oversized UDP payload",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"packet length",
|
||||
),
|
||||
(
|
||||
"zero finite duration",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
duration: Some(Duration::ZERO),
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"duration must be greater than zero; use None for continuous mode",
|
||||
),
|
||||
(
|
||||
"zero interval",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
interval: Duration::ZERO,
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"interval must be greater than zero",
|
||||
),
|
||||
(
|
||||
"duration nanosecond overflow",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
duration: Some(too_large),
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"duration is too large to encode as nanoseconds",
|
||||
),
|
||||
(
|
||||
"interval nanosecond overflow",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
interval: too_large,
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"interval is too large to encode as nanoseconds",
|
||||
),
|
||||
(
|
||||
"invalid DSCP codepoint",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
dscp: 64,
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"dscp",
|
||||
),
|
||||
(
|
||||
// The unspecified clock is the absent-tag wire default, not a
|
||||
// client mode; a client that needs timestamps must name a clock.
|
||||
"unspecified clock",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
clock: Clock::Unspecified,
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"clock must be wall, monotonic, or both",
|
||||
),
|
||||
(
|
||||
"empty server fill",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
server_fill: Some("".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"server_fill",
|
||||
),
|
||||
(
|
||||
"oversized server fill",
|
||||
ClientConfig {
|
||||
request: SessionRequest {
|
||||
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
},
|
||||
"server_fill",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, config, expected_reason) in cases {
|
||||
assert!(
|
||||
matches!(
|
||||
Client::connect("127.0.0.1:2112", config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
),
|
||||
"{name} should fail with InvalidConfig ({expected_reason})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_open_timeout_under_200ms_is_rejected() {
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::from_millis(199)],
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Client::connect("127.0.0.1:2112", config),
|
||||
Err(ClientError::OpenTimeoutTooSmall { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_open_timeouts_is_rejected() {
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![],
|
||||
..Default::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Client::connect("127.0.0.1:2112", config),
|
||||
Err(ClientError::NoOpenTimeouts)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use super::*;
|
||||
use irtt_client::{Authentication, HmacKey};
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config();
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&missing, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let mut config = default_test_config();
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&bad, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config();
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let cleanup = &packets[1];
|
||||
assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, cleanup).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
use irtt_client::NegotiationPolicy;
|
||||
use irtt_client::{NegotiationResult, RunMode, WarningKind, MAX_UDP_PAYLOAD_LENGTH};
|
||||
use irtt_proto::{flags, Params, TimestampFields};
|
||||
use irtt_proto::{
|
||||
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock, HMAC_SIZE,
|
||||
MAGIC,
|
||||
};
|
||||
use std::thread;
|
||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
use std::time::SystemTime;
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
mod close;
|
||||
mod config;
|
||||
mod hmac;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod probes_replies;
|
||||
mod ttl;
|
||||
@@ -0,0 +1,139 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_test_rejects_non_close_open_reply() {
|
||||
let mut config = default_test_config();
|
||||
config.request.run_mode = RunMode::NoTest;
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::UnexpectedNoTestReply)
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_rejects_non_zero_token_with_close_reply() {
|
||||
let mut config = default_test_config();
|
||||
config.request.run_mode = RunMode::NoTest;
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, TOKEN);
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_strict_negotiation_rejects_changed_params() {
|
||||
let mut config = default_test_config();
|
||||
config.request.run_mode = RunMode::NoTest;
|
||||
let mut params = default_params();
|
||||
params.dscp = 1;
|
||||
let server = no_test_server(params, 0);
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_loose_negotiation_accepts_restricted_params() {
|
||||
for requested_duration in [Some(Duration::from_secs(3)), None] {
|
||||
let mut config = default_test_config();
|
||||
config.request.run_mode = RunMode::NoTest;
|
||||
config.request.duration = requested_duration;
|
||||
config.open.negotiation = NegotiationPolicy::Loose;
|
||||
let mut params = default_params();
|
||||
params.duration_ns = 1_500_000_000;
|
||||
let server = no_test_server(params.clone(), 0);
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
||||
assert_eq!(negotiated.peer_params, params);
|
||||
assert_eq!(
|
||||
negotiated.accepted.duration,
|
||||
Some(Duration::from_millis(1500))
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.changes,
|
||||
vec![irtt_client::NegotiationChange::DurationReduced {
|
||||
requested: requested_duration,
|
||||
negotiated: Duration::from_millis(1500),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
client.negotiation(),
|
||||
None,
|
||||
"no-test creates no live session"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_fails_after_no_test_completed() {
|
||||
let mut config = default_test_config();
|
||||
config.request.run_mode = RunMode::NoTest;
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, 0);
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
assert_no_test_completed(client.open().unwrap());
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(irtt_client::SendProbeError::NotCommitted(
|
||||
ClientError::AlreadyCompleted
|
||||
))
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operations_fail_after_no_test_completed_without_datagrams() {
|
||||
let mut config = default_test_config();
|
||||
config.request.run_mode = RunMode::NoTest;
|
||||
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, 0);
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(50)))
|
||||
.unwrap();
|
||||
assert_no_test_completed(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::AlreadyCompleted),
|
||||
Err(ClientError::AlreadyCompleted)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn receive_before_open_returns_not_open_without_datagrams() {
|
||||
let peer = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let config = default_test_config();
|
||||
// Bound the old implementation's blocking receive without sending a packet.
|
||||
|
||||
let mut client = Client::connect(peer.local_addr().unwrap().to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(50)))
|
||||
.unwrap();
|
||||
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
client.recv_available(RecvBudget { max_packets: 0 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::NotOpen),
|
||||
Err(ClientError::NotOpen),
|
||||
Err(ClientError::NotOpen)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Untrusted returned intervals must be rejected before managed scheduling,
|
||||
// even when loose negotiation permits legitimate server restrictions.
|
||||
#[test]
|
||||
fn loose_open_rejects_nonpositive_returned_intervals() {
|
||||
for interval_ns in [0, -1] {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns = interval_ns;
|
||||
let server = open_success_server(returned);
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
negotiation: NegotiationPolicy::Loose,
|
||||
..default_test_config().open
|
||||
},
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
assert!(
|
||||
matches!(client.open(), Err(ClientError::NegotiationRejected { .. })),
|
||||
"returned interval {interval_ns} must be rejected"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_when_already_open() {
|
||||
let params = default_params();
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_retries_after_first_timeout() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let (first, _) = recv_request(&socket, &tx);
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let params = Params::decode(&first[4..]).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
|
||||
..default_test_config().open
|
||||
},
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
assert_open_started(outcome);
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_version_mismatch_fails() {
|
||||
let mut config = default_test_config();
|
||||
config.open.negotiation = NegotiationPolicy::Loose;
|
||||
let mut params = default_params();
|
||||
params.protocol_version = 2;
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_rejection_fails_in_normal_mode() {
|
||||
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 | flags::FLAG_CLOSE, 0, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::from_millis(250), Duration::from_millis(250)],
|
||||
..default_test_config().open
|
||||
},
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
let started = Instant::now();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
let requests: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(700),
|
||||
"ignored datagrams restarted an attempt deadline"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_deadline_overflow_occurs_before_send() {
|
||||
let server = timeout_server(Duration::from_millis(250));
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::MAX],
|
||||
..default_test_config().open
|
||||
},
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::DurationOverflow)));
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_negotiation_failure_sends_cleanup_close() {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_timeout_after_all_timeouts() {
|
||||
let server = timeout_server(Duration::from_millis(700));
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
..default_test_config().open
|
||||
},
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(50)))
|
||||
.unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let mut wrong_type = valid.clone();
|
||||
wrong_type[3] = FLAG_REPLY;
|
||||
let mut reserved_flags = valid.clone();
|
||||
reserved_flags[3] |= 0x10;
|
||||
let mut bad_magic = valid.clone();
|
||||
bad_magic[0] ^= 0xff;
|
||||
let unexpected_hmac =
|
||||
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(b"unexpected"));
|
||||
|
||||
for packet in [
|
||||
vec![0_u8],
|
||||
bad_magic,
|
||||
wrong_type,
|
||||
reserved_flags,
|
||||
unexpected_hmac,
|
||||
valid,
|
||||
] {
|
||||
socket.send_to(&packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ignored_open_datagrams_eventually_time_out() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
for _ in 0..2 {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
socket.send_to(&MAGIC, peer).unwrap();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
..default_test_config().open
|
||||
},
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(50)))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_zero_token_normal_reply_is_terminal() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
|
||||
// deliberately non-compliant reply is built by encoding a normal
|
||||
// reply with a placeholder token and then zeroing the token field.
|
||||
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(server.addr.to_string(), default_test_config()).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
|
||||
server.join();
|
||||
}
|
||||
File renamed without changes.
+1
-4
@@ -2,7 +2,7 @@ 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) {
|
||||
fn assert_packet_meta_unavailable(packet_meta: &irtt_client::PacketMeta) {
|
||||
assert_eq!(packet_meta.traffic_class, None);
|
||||
assert_eq!(packet_meta.dscp, None);
|
||||
assert_eq!(packet_meta.ecn, None);
|
||||
@@ -20,11 +20,8 @@ fn kernel_rx_timestamp_unavailable_skip(test_name: &str) {
|
||||
}
|
||||
|
||||
mod burst_recv;
|
||||
mod interrupted_receive;
|
||||
mod packet_length;
|
||||
mod probe_schedule;
|
||||
mod reply_classification;
|
||||
mod reply_decode;
|
||||
mod reply_metadata;
|
||||
mod timeouts;
|
||||
mod timing_math;
|
||||
+24
-75
@@ -1,62 +1,5 @@
|
||||
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, ¶ms, 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, ¶ms, &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]
|
||||
@@ -80,14 +23,16 @@ fn short_echo_reply_does_not_emit_echo_reply() {
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 64,
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
request: SessionRequest {
|
||||
length: 64,
|
||||
..default_test_config().request
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
@@ -126,14 +71,16 @@ fn overlong_datagram_detection_uses_extra_receive_byte() {
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 4096,
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
request: SessionRequest {
|
||||
length: 4096,
|
||||
..default_test_config().request
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
@@ -169,14 +116,16 @@ fn exact_length_echo_reply_still_emits_echo_reply() {
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 4096,
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
request: SessionRequest {
|
||||
length: 4096,
|
||||
..default_test_config().request
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
..default_test_config()
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
+10
-16
@@ -22,14 +22,11 @@ fn duplicate_reply_emits_duplicate_event() {
|
||||
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();
|
||||
let config = default_test_config();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
@@ -76,14 +73,11 @@ fn out_of_order_reply_emits_late_event() {
|
||||
let reply0 = echo_reply_packet(TOKEN, seqs[0], ¶ms, &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();
|
||||
let config = default_test_config();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
client.send_probe().unwrap();
|
||||
@@ -0,0 +1,74 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recv_once_returns_empty_on_timeout() {
|
||||
let params = default_params();
|
||||
let server = open_success_server(params);
|
||||
let config = default_test_config();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(50)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(events.is_empty());
|
||||
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 = default_test_config();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.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, ¶ms));
|
||||
}
|
||||
other => panic!("expected EchoReply after shorter datagram, got {other:?}"),
|
||||
}
|
||||
server.join();
|
||||
}
|
||||
+6
-9
@@ -14,19 +14,16 @@ fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() {
|
||||
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_traffic_class_to_socket(&socket, peer, 184).unwrap();
|
||||
socket2::SockRef::from(&socket).set_tos_v4(184).unwrap();
|
||||
let reply_packet =
|
||||
echo_reply_packet(TOKEN, seq, ¶ms, &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();
|
||||
let config = default_test_config();
|
||||
let mut client = Client::connect(server.addr.to_string(), config).unwrap();
|
||||
client
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
client.send_probe().unwrap();
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user