Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -3,8 +3,7 @@
|
|||||||
# separate terms of service, privacy policy, and support
|
# separate terms of service, privacy policy, and support
|
||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
|
# Scan all dependencies on main and compare newly introduced vulnerabilities on PRs.
|
||||||
# in addition to a PR check which fails if new vulnerabilities are introduced.
|
|
||||||
#
|
#
|
||||||
# For more examples and options, including how to ignore specific vulnerabilities,
|
# For more examples and options, including how to ignore specific vulnerabilities,
|
||||||
# see https://google.github.io/osv-scanner/github-action/
|
# see https://google.github.io/osv-scanner/github-action/
|
||||||
@@ -20,6 +19,7 @@ on:
|
|||||||
- cron: "45 8 * * 1"
|
- cron: "45 8 * * 1"
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# Require writing security events to upload SARIF file to security tab
|
# Require writing security events to upload SARIF file to security tab
|
||||||
@@ -29,21 +29,12 @@ permissions:
|
|||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
jobs:
|
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:
|
scan-scheduled:
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
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@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||||
with:
|
|
||||||
# Example of specifying custom arguments
|
|
||||||
scan-args: |-
|
|
||||||
-r
|
|
||||||
--skip-git
|
|
||||||
./
|
|
||||||
scan-pr:
|
scan-pr:
|
||||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||||
with:
|
|
||||||
# Example of specifying custom arguments
|
|
||||||
scan-args: |-
|
|
||||||
-r
|
|
||||||
--skip-git
|
|
||||||
./
|
|
||||||
@@ -34,6 +34,37 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-client
|
## 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
|
### 0.5.3
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -119,6 +150,29 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-stats
|
## 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
|
### 0.5.1
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
@@ -138,6 +192,44 @@ release. The format is loosely based on
|
|||||||
|
|
||||||
## irtt-rs
|
## 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
|
### 0.7.1
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
|
|||||||
Generated
+144
-15
@@ -17,6 +17,15 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -199,6 +208,16 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -211,6 +230,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"num-traits",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -292,6 +322,12 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -317,6 +353,7 @@ dependencies = [
|
|||||||
"crossterm_winapi",
|
"crossterm_winapi",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"document-features",
|
"document-features",
|
||||||
|
"futures-core",
|
||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
@@ -376,9 +413,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctutils"
|
name = "ctutils"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
@@ -496,9 +533,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.18.0"
|
version = "1.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
@@ -552,6 +589,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "finl_unicode"
|
name = "finl_unicode"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -685,6 +728,30 @@ dependencies = [
|
|||||||
"typenum",
|
"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]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -715,7 +782,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
@@ -748,15 +815,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-rs"
|
name = "irtt-rs"
|
||||||
version = "0.7.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
|
"futures-core",
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
"irtt-proto",
|
"irtt-proto",
|
||||||
"irtt-server",
|
"irtt-server",
|
||||||
"irtt-stats",
|
"irtt-stats",
|
||||||
|
"nix 0.31.3",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -775,7 +845,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"irtt-client",
|
"irtt-client",
|
||||||
"measurement-stats",
|
"measurement-stats",
|
||||||
@@ -1044,9 +1114,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2-encode",
|
"objc2-encode",
|
||||||
]
|
]
|
||||||
@@ -1242,9 +1312,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
@@ -1601,6 +1671,12 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook"
|
name = "signal-hook"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -2150,12 +2226,65 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -2173,18 +2302,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.59"
|
version = "0.8.61"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
|
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.59"
|
version = "0.8.61"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
|
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+2
-2
@@ -18,9 +18,9 @@ homepage = "https://github.com/Lochnair/irtt-rs"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
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-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]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
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 / /
|
COPY --from=xx / /
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-rs"
|
name = "irtt-rs"
|
||||||
version = "0.7.1"
|
version = "0.8.0"
|
||||||
description = "IRTT-compatible client, TUI, and server applications"
|
description = "IRTT-compatible client, TUI, and server applications"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -42,25 +42,30 @@ doc = false
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["client", "server", "tui"]
|
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"]
|
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]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
|
||||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||||
crossterm = { version = "0.29.0", optional = true }
|
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"] }
|
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||||
irtt-client = { workspace = true, optional = true }
|
irtt-client = { workspace = true, optional = true }
|
||||||
irtt-proto = { workspace = true, optional = true }
|
irtt-proto = { workspace = true, optional = true }
|
||||||
irtt-server = { workspace = true, optional = true }
|
irtt-server = { workspace = true, optional = true }
|
||||||
irtt-stats = { workspace = true, optional = true }
|
irtt-stats = { workspace = true, optional = true }
|
||||||
ratatui = { version = "0.30.2", optional = true }
|
ratatui = { version = "0.30.2", optional = true }
|
||||||
tokio = { version = "1.53.2", 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]
|
[dev-dependencies]
|
||||||
irtt-server.workspace = true
|
irtt-server.workspace = true
|
||||||
tokio = { version = "1.53.2", 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]
|
[profile.release]
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ impl ClientArgs {
|
|||||||
///
|
///
|
||||||
/// Targets are only required here. `--list-columns` returns before this is
|
/// Targets are only required here. `--list-columns` returns before this is
|
||||||
/// called, so listing columns still needs no target.
|
/// 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() {
|
if self.targets_stdin && !self.is_continuous() {
|
||||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||||
}
|
}
|
||||||
@@ -105,6 +105,7 @@ impl ClientArgs {
|
|||||||
stdin_controlled: self.targets_stdin,
|
stdin_controlled: self.targets_stdin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use irtt_client::{
|
|||||||
ServerTiming, SignedDuration, WarningKind,
|
ServerTiming, SignedDuration, WarningKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate};
|
||||||
|
|
||||||
use super::args::{HeaderMode, OutputFormat};
|
use super::args::{HeaderMode, OutputFormat};
|
||||||
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
|
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ impl OutputConfig {
|
|||||||
&self,
|
&self,
|
||||||
event: &ClientEvent,
|
event: &ClientEvent,
|
||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
stats: Option<&EventRenderStats>,
|
stats: Option<&EventStatsUpdate>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let row = OutputRow::from_event(event);
|
let row = OutputRow::from_event(event);
|
||||||
if self.default_table_rows && row.is_default_table_hidden() {
|
if self.default_table_rows && row.is_default_table_hidden() {
|
||||||
@@ -133,10 +135,12 @@ impl OutputConfig {
|
|||||||
}
|
}
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
writeln!(out, "Aliases:").unwrap();
|
writeln!(out, "Aliases:").unwrap();
|
||||||
writeln!(out, " receive_delay, receive_delay_us").unwrap();
|
for column in ALL_COLUMNS {
|
||||||
writeln!(out, " send_delay, send_delay_us").unwrap();
|
let aliases = column.aliases();
|
||||||
writeln!(out, " server_processing").unwrap();
|
if !aliases.is_empty() {
|
||||||
writeln!(out, " server_received_count, server_received_window").unwrap();
|
writeln!(out, " {} ({})", aliases.join(", "), column.name()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
writeln!(out).unwrap();
|
writeln!(out).unwrap();
|
||||||
writeln!(out, "Special column sets:").unwrap();
|
writeln!(out, "Special column sets:").unwrap();
|
||||||
writeln!(
|
writeln!(
|
||||||
@@ -149,45 +153,9 @@ impl OutputConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub struct EventRenderStats {
|
|
||||||
pub contributed_sample: bool,
|
|
||||||
pub ipdv_pairs: Vec<IpdvPair>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct IpdvPair {
|
|
||||||
pub previous_seq: u32,
|
|
||||||
pub current_seq: u32,
|
|
||||||
pub rtt_ipdv: Duration,
|
|
||||||
pub send_ipdv: Option<Duration>,
|
|
||||||
pub receive_ipdv: Option<Duration>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<irtt_stats::EventStatsUpdate> for EventRenderStats {
|
|
||||||
fn from(value: irtt_stats::EventStatsUpdate) -> Self {
|
|
||||||
Self {
|
|
||||||
contributed_sample: value.contributed_sample,
|
|
||||||
ipdv_pairs: value.ipdv_pairs.into_iter().map(Into::into).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<irtt_stats::IpdvPairUpdate> for IpdvPair {
|
|
||||||
fn from(value: irtt_stats::IpdvPairUpdate) -> Self {
|
|
||||||
Self {
|
|
||||||
previous_seq: value.previous_seq,
|
|
||||||
current_seq: value.current_seq,
|
|
||||||
rtt_ipdv: value.rtt_ipdv,
|
|
||||||
send_ipdv: value.send_ipdv,
|
|
||||||
receive_ipdv: value.receive_ipdv,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct RenderContext<'a> {
|
struct RenderContext<'a> {
|
||||||
stats: Option<&'a EventRenderStats>,
|
stats: Option<&'a EventStatsUpdate>,
|
||||||
target: Option<&'a str>,
|
target: Option<&'a str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
}
|
}
|
||||||
@@ -368,6 +336,106 @@ impl OutputRow {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn seq(&self) -> Option<u32> {
|
||||||
|
match self {
|
||||||
|
Self::EchoSent { seq, .. } | Self::Loss { seq, .. } | Self::Duplicate { seq, .. } => {
|
||||||
|
Some(*seq)
|
||||||
|
}
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.seq),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote(&self) -> Option<SocketAddr> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.remote),
|
||||||
|
Self::SessionClosed { remote, .. }
|
||||||
|
| Self::EchoSent { remote, .. }
|
||||||
|
| Self::Duplicate { remote, .. } => Some(*remote),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.remote),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(&self) -> Option<u64> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) | Self::NoTestCompleted(row) => row.token,
|
||||||
|
Self::SessionClosed { token, .. } => Some(*token),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Self::EchoSent { bytes, .. } | Self::Duplicate { bytes, .. } => Some(*bytes),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.bytes),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(&self, context: RenderContext<'_>) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) => Some(format!(
|
||||||
|
"token={:#x} duration_ns={} interval_ns={} length={}",
|
||||||
|
row.token?, row.duration_ns, row.interval_ns, row.payload_length
|
||||||
|
)),
|
||||||
|
Self::NoTestCompleted(row) => Some(format!(
|
||||||
|
"duration_ns={} interval_ns={} length={}",
|
||||||
|
row.duration_ns, row.interval_ns, row.payload_length
|
||||||
|
)),
|
||||||
|
Self::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
|
||||||
|
Self::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
|
||||||
|
Self::Warning { message, .. } => Some(message.clone()),
|
||||||
|
Self::Loss { .. } => Some("timeout".to_owned()),
|
||||||
|
Self::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_wall(&self) -> Option<SystemTime> {
|
||||||
|
match self {
|
||||||
|
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.event_wall),
|
||||||
|
Self::SessionClosed { event_wall, .. } | Self::Warning { event_wall, .. } => {
|
||||||
|
Some(*event_wall)
|
||||||
|
}
|
||||||
|
Self::EchoSent {
|
||||||
|
client_send_wall, ..
|
||||||
|
}
|
||||||
|
| Self::Loss {
|
||||||
|
client_send_wall, ..
|
||||||
|
} => Some(*client_send_wall),
|
||||||
|
Self::Duplicate {
|
||||||
|
client_receive_wall,
|
||||||
|
..
|
||||||
|
} => Some(*client_receive_wall),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_send_wall(&self) -> Option<SystemTime> {
|
||||||
|
match self {
|
||||||
|
Self::EchoSent {
|
||||||
|
client_send_wall, ..
|
||||||
|
}
|
||||||
|
| Self::Loss {
|
||||||
|
client_send_wall, ..
|
||||||
|
} => Some(*client_send_wall),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => reply.client_send_wall,
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_receive_wall(&self) -> Option<SystemTime> {
|
||||||
|
match self {
|
||||||
|
Self::Duplicate {
|
||||||
|
client_receive_wall,
|
||||||
|
..
|
||||||
|
} => Some(*client_receive_wall),
|
||||||
|
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -415,275 +483,92 @@ struct ReplyRow {
|
|||||||
packet_meta: PacketMeta,
|
packet_meta: PacketMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only schema metadata is generated here; cell_for keeps rendering behavior explicit.
|
||||||
|
macro_rules! columns {
|
||||||
|
($($variant:ident($name:literal, [$($alias:literal),*], $width:expr, $align:ident, $description:literal);)*) => {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Column {
|
enum Column { $($variant),* }
|
||||||
Target,
|
|
||||||
Event,
|
|
||||||
Seq,
|
|
||||||
Remote,
|
|
||||||
Token,
|
|
||||||
Rtt,
|
|
||||||
RttUs,
|
|
||||||
RawRttUs,
|
|
||||||
EffectiveRttUs,
|
|
||||||
AdjustedRttUs,
|
|
||||||
ReceiveDelay,
|
|
||||||
ReceiveDelayUs,
|
|
||||||
SendDelay,
|
|
||||||
SendDelayUs,
|
|
||||||
Ipdv,
|
|
||||||
IpdvUs,
|
|
||||||
ServerProcessing,
|
|
||||||
ServerProcessingUs,
|
|
||||||
Bytes,
|
|
||||||
SendCallUs,
|
|
||||||
TimerErrorUs,
|
|
||||||
HighestSeen,
|
|
||||||
ServerReceivedCount,
|
|
||||||
ServerReceivedWindow,
|
|
||||||
Dscp,
|
|
||||||
Ecn,
|
|
||||||
TrafficClass,
|
|
||||||
KernelRxNs,
|
|
||||||
WarningKind,
|
|
||||||
Message,
|
|
||||||
EventWallNs,
|
|
||||||
ClientSendWallNs,
|
|
||||||
ClientReceiveWallNs,
|
|
||||||
DurationNs,
|
|
||||||
IntervalNs,
|
|
||||||
PayloadLength,
|
|
||||||
ServerReceiveWallNs,
|
|
||||||
ServerReceiveMonoNs,
|
|
||||||
ServerSendWallNs,
|
|
||||||
ServerSendMonoNs,
|
|
||||||
ServerMidpointWallNs,
|
|
||||||
ServerMidpointMonoNs,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_COLUMNS: &[Column] = &[
|
const ALL_COLUMNS: &[Column] = &[$(Column::$variant),*];
|
||||||
Column::Target,
|
|
||||||
Column::Event,
|
|
||||||
Column::Seq,
|
|
||||||
Column::Remote,
|
|
||||||
Column::Token,
|
|
||||||
Column::Rtt,
|
|
||||||
Column::RttUs,
|
|
||||||
Column::RawRttUs,
|
|
||||||
Column::EffectiveRttUs,
|
|
||||||
Column::AdjustedRttUs,
|
|
||||||
Column::ReceiveDelay,
|
|
||||||
Column::ReceiveDelayUs,
|
|
||||||
Column::SendDelay,
|
|
||||||
Column::SendDelayUs,
|
|
||||||
Column::Ipdv,
|
|
||||||
Column::IpdvUs,
|
|
||||||
Column::ServerProcessing,
|
|
||||||
Column::ServerProcessingUs,
|
|
||||||
Column::Bytes,
|
|
||||||
Column::SendCallUs,
|
|
||||||
Column::TimerErrorUs,
|
|
||||||
Column::HighestSeen,
|
|
||||||
Column::ServerReceivedCount,
|
|
||||||
Column::ServerReceivedWindow,
|
|
||||||
Column::Dscp,
|
|
||||||
Column::Ecn,
|
|
||||||
Column::TrafficClass,
|
|
||||||
Column::KernelRxNs,
|
|
||||||
Column::WarningKind,
|
|
||||||
Column::Message,
|
|
||||||
Column::EventWallNs,
|
|
||||||
Column::ClientSendWallNs,
|
|
||||||
Column::ClientReceiveWallNs,
|
|
||||||
Column::DurationNs,
|
|
||||||
Column::IntervalNs,
|
|
||||||
Column::PayloadLength,
|
|
||||||
Column::ServerReceiveWallNs,
|
|
||||||
Column::ServerReceiveMonoNs,
|
|
||||||
Column::ServerSendWallNs,
|
|
||||||
Column::ServerSendMonoNs,
|
|
||||||
Column::ServerMidpointWallNs,
|
|
||||||
Column::ServerMidpointMonoNs,
|
|
||||||
];
|
|
||||||
|
|
||||||
impl Column {
|
impl Column {
|
||||||
fn name(self) -> &'static str {
|
fn name(self) -> &'static str {
|
||||||
match self {
|
match self { $(Self::$variant => $name),* }
|
||||||
Self::Target => "target",
|
|
||||||
Self::Event => "event",
|
|
||||||
Self::Seq => "seq",
|
|
||||||
Self::Remote => "remote",
|
|
||||||
Self::Token => "token",
|
|
||||||
Self::Rtt => "rtt",
|
|
||||||
Self::RttUs => "rtt_us",
|
|
||||||
Self::RawRttUs => "raw_rtt_us",
|
|
||||||
Self::EffectiveRttUs => "effective_rtt_us",
|
|
||||||
Self::AdjustedRttUs => "adjusted_rtt_us",
|
|
||||||
Self::ReceiveDelay => "rd",
|
|
||||||
Self::ReceiveDelayUs => "rd_us",
|
|
||||||
Self::SendDelay => "sd",
|
|
||||||
Self::SendDelayUs => "sd_us",
|
|
||||||
Self::Ipdv => "ipdv",
|
|
||||||
Self::IpdvUs => "ipdv_us",
|
|
||||||
Self::ServerProcessing => "proc",
|
|
||||||
Self::ServerProcessingUs => "server_processing_us",
|
|
||||||
Self::Bytes => "bytes",
|
|
||||||
Self::SendCallUs => "send_call_us",
|
|
||||||
Self::TimerErrorUs => "timer_error_us",
|
|
||||||
Self::HighestSeen => "highest_seen",
|
|
||||||
Self::ServerReceivedCount => "server_received",
|
|
||||||
Self::ServerReceivedWindow => "server_window",
|
|
||||||
Self::Dscp => "dscp",
|
|
||||||
Self::Ecn => "ecn",
|
|
||||||
Self::TrafficClass => "traffic_class",
|
|
||||||
Self::KernelRxNs => "kernel_rx_ns",
|
|
||||||
Self::WarningKind => "warning_kind",
|
|
||||||
Self::Message => "message",
|
|
||||||
Self::EventWallNs => "event_wall_ns",
|
|
||||||
Self::ClientSendWallNs => "client_send_wall_ns",
|
|
||||||
Self::ClientReceiveWallNs => "client_receive_wall_ns",
|
|
||||||
Self::DurationNs => "duration_ns",
|
|
||||||
Self::IntervalNs => "interval_ns",
|
|
||||||
Self::PayloadLength => "payload_length",
|
|
||||||
Self::ServerReceiveWallNs => "server_receive_wall_ns",
|
|
||||||
Self::ServerReceiveMonoNs => "server_receive_mono_ns",
|
|
||||||
Self::ServerSendWallNs => "server_send_wall_ns",
|
|
||||||
Self::ServerSendMonoNs => "server_send_mono_ns",
|
|
||||||
Self::ServerMidpointWallNs => "server_midpoint_wall_ns",
|
|
||||||
Self::ServerMidpointMonoNs => "server_midpoint_mono_ns",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse(input: &str) -> Option<Self> {
|
fn parse(input: &str) -> Option<Self> {
|
||||||
Some(match input {
|
match input {
|
||||||
"target" => Self::Target,
|
$($name $(| $alias)* => Some(Self::$variant),)*
|
||||||
"event" => Self::Event,
|
_ => None,
|
||||||
"seq" => Self::Seq,
|
}
|
||||||
"remote" => Self::Remote,
|
}
|
||||||
"token" => Self::Token,
|
|
||||||
"rtt" => Self::Rtt,
|
fn aliases(self) -> &'static [&'static str] {
|
||||||
"rtt_us" => Self::RttUs,
|
match self { $(Self::$variant => &[$($alias),*]),* }
|
||||||
"raw_rtt_us" => Self::RawRttUs,
|
|
||||||
"effective_rtt_us" => Self::EffectiveRttUs,
|
|
||||||
"adjusted_rtt_us" => Self::AdjustedRttUs,
|
|
||||||
"rd" | "receive_delay" => Self::ReceiveDelay,
|
|
||||||
"rd_us" | "receive_delay_us" => Self::ReceiveDelayUs,
|
|
||||||
"sd" | "send_delay" => Self::SendDelay,
|
|
||||||
"sd_us" | "send_delay_us" => Self::SendDelayUs,
|
|
||||||
"ipdv" => Self::Ipdv,
|
|
||||||
"ipdv_us" => Self::IpdvUs,
|
|
||||||
"proc" | "server_processing" => Self::ServerProcessing,
|
|
||||||
"server_processing_us" => Self::ServerProcessingUs,
|
|
||||||
"bytes" => Self::Bytes,
|
|
||||||
"send_call_us" => Self::SendCallUs,
|
|
||||||
"timer_error_us" => Self::TimerErrorUs,
|
|
||||||
"highest_seen" => Self::HighestSeen,
|
|
||||||
"server_received" | "server_received_count" => Self::ServerReceivedCount,
|
|
||||||
"server_window" | "server_received_window" => Self::ServerReceivedWindow,
|
|
||||||
"dscp" => Self::Dscp,
|
|
||||||
"ecn" => Self::Ecn,
|
|
||||||
"traffic_class" => Self::TrafficClass,
|
|
||||||
"kernel_rx_ns" => Self::KernelRxNs,
|
|
||||||
"warning_kind" => Self::WarningKind,
|
|
||||||
"message" => Self::Message,
|
|
||||||
"event_wall_ns" => Self::EventWallNs,
|
|
||||||
"client_send_wall_ns" => Self::ClientSendWallNs,
|
|
||||||
"client_receive_wall_ns" => Self::ClientReceiveWallNs,
|
|
||||||
"duration_ns" => Self::DurationNs,
|
|
||||||
"interval_ns" => Self::IntervalNs,
|
|
||||||
"payload_length" => Self::PayloadLength,
|
|
||||||
"server_receive_wall_ns" => Self::ServerReceiveWallNs,
|
|
||||||
"server_receive_mono_ns" => Self::ServerReceiveMonoNs,
|
|
||||||
"server_send_wall_ns" => Self::ServerSendWallNs,
|
|
||||||
"server_send_mono_ns" => Self::ServerSendMonoNs,
|
|
||||||
"server_midpoint_wall_ns" => Self::ServerMidpointWallNs,
|
|
||||||
"server_midpoint_mono_ns" => Self::ServerMidpointMonoNs,
|
|
||||||
_ => return None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn description(self) -> &'static str {
|
fn description(self) -> &'static str {
|
||||||
match self {
|
match self { $(Self::$variant => $description),* }
|
||||||
Self::Target => "logical CLI target label",
|
|
||||||
Self::Event => "event kind",
|
|
||||||
Self::Seq => "probe sequence number",
|
|
||||||
Self::Remote => "remote socket address",
|
|
||||||
Self::Token => "session token as hexadecimal",
|
|
||||||
Self::Rtt => "human-readable effective RTT",
|
|
||||||
Self::RttUs => "effective RTT in signed microseconds",
|
|
||||||
Self::RawRttUs => "raw client send-to-receive RTT in microseconds",
|
|
||||||
Self::EffectiveRttUs => "effective RTT in signed microseconds",
|
|
||||||
Self::AdjustedRttUs => "adjusted RTT in signed microseconds",
|
|
||||||
Self::ReceiveDelay => "human-readable server-to-client delay",
|
|
||||||
Self::ReceiveDelayUs => "server-to-client delay in signed microseconds",
|
|
||||||
Self::SendDelay => "human-readable client-to-server delay",
|
|
||||||
Self::SendDelayUs => "client-to-server delay in signed microseconds",
|
|
||||||
Self::Ipdv => "human-readable round-trip IPDV for adjacent samples",
|
|
||||||
Self::IpdvUs => "round-trip IPDV in microseconds",
|
|
||||||
Self::ServerProcessing => "human-readable server processing time",
|
|
||||||
Self::ServerProcessingUs => "server processing time in microseconds",
|
|
||||||
Self::Bytes => "packet bytes for packet events",
|
|
||||||
Self::SendCallUs => "send system call duration in microseconds",
|
|
||||||
Self::TimerErrorUs => "scheduled-vs-actual send timer error in microseconds",
|
|
||||||
Self::HighestSeen => "highest sequence seen when a late reply arrived",
|
|
||||||
Self::ServerReceivedCount => "server-reported received packet count",
|
|
||||||
Self::ServerReceivedWindow => "server-reported received window as hexadecimal",
|
|
||||||
Self::Dscp => "received packet DSCP codepoint",
|
|
||||||
Self::Ecn => "received packet ECN bits",
|
|
||||||
Self::TrafficClass => "received packet traffic class byte",
|
|
||||||
Self::KernelRxNs => "kernel receive timestamp as Unix nanoseconds",
|
|
||||||
Self::WarningKind => "warning classifier",
|
|
||||||
Self::Message => "warning or lifecycle message",
|
|
||||||
Self::EventWallNs => "event wall timestamp as Unix nanoseconds",
|
|
||||||
Self::ClientSendWallNs => "client send wall timestamp as Unix nanoseconds",
|
|
||||||
Self::ClientReceiveWallNs => "client receive wall timestamp as Unix nanoseconds",
|
|
||||||
Self::DurationNs => "negotiated test duration in nanoseconds",
|
|
||||||
Self::IntervalNs => "negotiated probe interval in nanoseconds",
|
|
||||||
Self::PayloadLength => "negotiated payload length",
|
|
||||||
Self::ServerReceiveWallNs => "server receive wall timestamp in nanoseconds",
|
|
||||||
Self::ServerReceiveMonoNs => "server receive monotonic timestamp in nanoseconds",
|
|
||||||
Self::ServerSendWallNs => "server send wall timestamp in nanoseconds",
|
|
||||||
Self::ServerSendMonoNs => "server send monotonic timestamp in nanoseconds",
|
|
||||||
Self::ServerMidpointWallNs => "server midpoint wall timestamp in nanoseconds",
|
|
||||||
Self::ServerMidpointMonoNs => "server midpoint monotonic timestamp in nanoseconds",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn table_width(self) -> usize {
|
fn table_width(self) -> usize {
|
||||||
match self {
|
match self { $(Self::$variant => $width),* }
|
||||||
Self::Target => 18,
|
|
||||||
Self::Event => 17,
|
|
||||||
Self::Seq => 6,
|
|
||||||
Self::Remote => 21,
|
|
||||||
Self::Token => 18,
|
|
||||||
Self::Rtt
|
|
||||||
| Self::ReceiveDelay
|
|
||||||
| Self::SendDelay
|
|
||||||
| Self::Ipdv
|
|
||||||
| Self::ServerProcessing => 9,
|
|
||||||
Self::Message => 24,
|
|
||||||
Self::WarningKind => 28,
|
|
||||||
Self::ServerReceivedWindow => 13,
|
|
||||||
Self::ServerReceivedCount | Self::HighestSeen => 15,
|
|
||||||
Self::Dscp | Self::Ecn => 4,
|
|
||||||
Self::TrafficClass => 13,
|
|
||||||
_ => self.name().len().max(10),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn align_right(self) -> bool {
|
fn align_right(self) -> bool {
|
||||||
!matches!(
|
match self { $(Self::$variant => columns!(@align $align)),* }
|
||||||
self,
|
|
||||||
Self::Target
|
|
||||||
| Self::Event
|
|
||||||
| Self::Remote
|
|
||||||
| Self::Token
|
|
||||||
| Self::Message
|
|
||||||
| Self::WarningKind
|
|
||||||
| Self::ServerReceivedWindow
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
(@align left) => { false };
|
||||||
|
(@align right) => { true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variant(canonical name, aliases, table width, alignment, description).
|
||||||
|
columns! {
|
||||||
|
Target("target", [], 18, left, "logical CLI target label");
|
||||||
|
Event("event", [], 17, left, "event kind");
|
||||||
|
Seq("seq", [], 6, right, "probe sequence number");
|
||||||
|
Remote("remote", [], 21, left, "remote socket address");
|
||||||
|
Token("token", [], 18, left, "session token as hexadecimal");
|
||||||
|
Rtt("rtt", [], 9, right, "human-readable effective RTT");
|
||||||
|
RttUs("rtt_us", [], 10, right, "effective RTT in signed microseconds");
|
||||||
|
RawRttUs("raw_rtt_us", [], 10, right, "raw client send-to-receive RTT in microseconds");
|
||||||
|
EffectiveRttUs("effective_rtt_us", [], 16, right, "effective RTT in signed microseconds");
|
||||||
|
AdjustedRttUs("adjusted_rtt_us", [], 15, right, "adjusted RTT in signed microseconds");
|
||||||
|
ReceiveDelay("rd", ["receive_delay"], 9, right, "human-readable server-to-client delay");
|
||||||
|
ReceiveDelayUs("rd_us", ["receive_delay_us"], 10, right, "server-to-client delay in signed microseconds");
|
||||||
|
SendDelay("sd", ["send_delay"], 9, right, "human-readable client-to-server delay");
|
||||||
|
SendDelayUs("sd_us", ["send_delay_us"], 10, right, "client-to-server delay in signed microseconds");
|
||||||
|
Ipdv("ipdv", [], 9, right, "human-readable round-trip IPDV for adjacent samples");
|
||||||
|
IpdvUs("ipdv_us", [], 10, right, "round-trip IPDV in microseconds");
|
||||||
|
ServerProcessing("proc", ["server_processing"], 9, right, "human-readable server processing time");
|
||||||
|
ServerProcessingUs("server_processing_us", [], 20, right, "server processing time in microseconds");
|
||||||
|
Bytes("bytes", [], 10, right, "packet bytes for packet events");
|
||||||
|
SendCallUs("send_call_us", [], 12, right, "send system call duration in microseconds");
|
||||||
|
TimerErrorUs("timer_error_us", [], 14, right, "scheduled-vs-actual send timer error in microseconds");
|
||||||
|
HighestSeen("highest_seen", [], 15, right, "highest sequence seen when a late reply arrived");
|
||||||
|
ServerReceivedCount("server_received", ["server_received_count"], 15, right, "server-reported received packet count");
|
||||||
|
ServerReceivedWindow("server_window", ["server_received_window"], 13, left, "server-reported received window as hexadecimal");
|
||||||
|
Dscp("dscp", [], 4, right, "received packet DSCP codepoint");
|
||||||
|
Ecn("ecn", [], 4, right, "received packet ECN bits");
|
||||||
|
TrafficClass("traffic_class", [], 13, right, "received packet traffic class byte");
|
||||||
|
KernelRxNs("kernel_rx_ns", [], 12, right, "kernel receive timestamp as Unix nanoseconds");
|
||||||
|
WarningKind("warning_kind", [], 28, left, "warning classifier");
|
||||||
|
Message("message", [], 24, left, "warning or lifecycle message");
|
||||||
|
EventWallNs("event_wall_ns", [], 13, right, "event wall timestamp as Unix nanoseconds");
|
||||||
|
ClientSendWallNs("client_send_wall_ns", [], 19, right, "client send wall timestamp as Unix nanoseconds");
|
||||||
|
ClientReceiveWallNs("client_receive_wall_ns", [], 22, right, "client receive wall timestamp as Unix nanoseconds");
|
||||||
|
DurationNs("duration_ns", [], 11, right, "negotiated test duration in nanoseconds");
|
||||||
|
IntervalNs("interval_ns", [], 11, right, "negotiated probe interval in nanoseconds");
|
||||||
|
PayloadLength("payload_length", [], 14, right, "negotiated payload length");
|
||||||
|
ServerReceiveWallNs("server_receive_wall_ns", [], 22, right, "server receive wall timestamp in nanoseconds");
|
||||||
|
ServerReceiveMonoNs("server_receive_mono_ns", [], 22, right, "server receive monotonic timestamp in nanoseconds");
|
||||||
|
ServerSendWallNs("server_send_wall_ns", [], 19, right, "server send wall timestamp in nanoseconds");
|
||||||
|
ServerSendMonoNs("server_send_mono_ns", [], 19, right, "server send monotonic timestamp in nanoseconds");
|
||||||
|
ServerMidpointWallNs("server_midpoint_wall_ns", [], 23, right, "server midpoint wall timestamp in nanoseconds");
|
||||||
|
ServerMidpointMonoNs("server_midpoint_mono_ns", [], 23, right, "server midpoint monotonic timestamp in nanoseconds");
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum CellValue {
|
enum CellValue {
|
||||||
@@ -890,9 +775,11 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
match column {
|
match column {
|
||||||
Column::Target => context.target.map(CellValue::text),
|
Column::Target => context.target.map(CellValue::text),
|
||||||
Column::Event => Some(CellValue::text(row.event_name())),
|
Column::Event => Some(CellValue::text(row.event_name())),
|
||||||
Column::Seq => row_seq(row).map(|seq| CellValue::Unsigned(u128::from(seq))),
|
Column::Seq => row.seq().map(|seq| CellValue::Unsigned(u128::from(seq))),
|
||||||
Column::Remote => row_remote(row).map(|remote| CellValue::text(remote.to_string())),
|
Column::Remote => row
|
||||||
Column::Token => row_token(row).map(|token| CellValue::Hex(u128::from(token))),
|
.remote()
|
||||||
|
.map(|remote| CellValue::text(remote.to_string())),
|
||||||
|
Column::Token => row.token().map(|token| CellValue::Hex(u128::from(token))),
|
||||||
Column::Rtt => row.reply().and_then(|reply| {
|
Column::Rtt => row.reply().and_then(|reply| {
|
||||||
reply
|
reply
|
||||||
.rtt
|
.rtt
|
||||||
@@ -930,10 +817,12 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
.reply()
|
.reply()
|
||||||
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
||||||
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
||||||
Column::Ipdv => row_seq(row)
|
Column::Ipdv => row
|
||||||
|
.seq()
|
||||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
.and_then(|seq| ipdv_pair(context.stats, seq))
|
||||||
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))),
|
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))),
|
||||||
Column::IpdvUs => row_seq(row)
|
Column::IpdvUs => row
|
||||||
|
.seq()
|
||||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
.and_then(|seq| ipdv_pair(context.stats, seq))
|
||||||
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
|
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
|
||||||
Column::ServerProcessing => row
|
Column::ServerProcessing => row
|
||||||
@@ -944,7 +833,7 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
.reply()
|
.reply()
|
||||||
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
||||||
.map(|value| CellValue::Unsigned(duration_us(value))),
|
.map(|value| CellValue::Unsigned(duration_us(value))),
|
||||||
Column::Bytes => row_bytes(row).map(|bytes| CellValue::Unsigned(bytes as u128)),
|
Column::Bytes => row.bytes().map(|bytes| CellValue::Unsigned(bytes as u128)),
|
||||||
Column::SendCallUs => match row {
|
Column::SendCallUs => match row {
|
||||||
OutputRow::EchoSent { send_call, .. } => {
|
OutputRow::EchoSent { send_call, .. } => {
|
||||||
Some(CellValue::Unsigned(duration_us(*send_call)))
|
Some(CellValue::Unsigned(duration_us(*send_call)))
|
||||||
@@ -992,14 +881,17 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
|
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
|
||||||
_ => None,
|
_ => None,
|
||||||
},
|
},
|
||||||
Column::Message => row_message(row, context).map(CellValue::text),
|
Column::Message => row.message(context).map(CellValue::text),
|
||||||
Column::EventWallNs => row_event_wall(row)
|
Column::EventWallNs => row
|
||||||
|
.event_wall()
|
||||||
.and_then(wall_time_ns)
|
.and_then(wall_time_ns)
|
||||||
.map(CellValue::Unsigned),
|
.map(CellValue::Unsigned),
|
||||||
Column::ClientSendWallNs => row_client_send_wall(row)
|
Column::ClientSendWallNs => row
|
||||||
|
.client_send_wall()
|
||||||
.and_then(wall_time_ns)
|
.and_then(wall_time_ns)
|
||||||
.map(CellValue::Unsigned),
|
.map(CellValue::Unsigned),
|
||||||
Column::ClientReceiveWallNs => row_client_receive_wall(row)
|
Column::ClientReceiveWallNs => row
|
||||||
|
.client_receive_wall()
|
||||||
.and_then(wall_time_ns)
|
.and_then(wall_time_ns)
|
||||||
.map(CellValue::Unsigned),
|
.map(CellValue::Unsigned),
|
||||||
Column::DurationNs => match row {
|
Column::DurationNs => match row {
|
||||||
@@ -1029,110 +921,6 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_seq(row: &OutputRow) -> Option<u32> {
|
|
||||||
match row {
|
|
||||||
OutputRow::EchoSent { seq, .. }
|
|
||||||
| OutputRow::Loss { seq, .. }
|
|
||||||
| OutputRow::Duplicate { seq, .. } => Some(*seq),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.seq),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_remote(row: &OutputRow) -> Option<SocketAddr> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.remote),
|
|
||||||
OutputRow::SessionClosed { remote, .. }
|
|
||||||
| OutputRow::EchoSent { remote, .. }
|
|
||||||
| OutputRow::Duplicate { remote, .. } => Some(*remote),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.remote),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_token(row: &OutputRow) -> Option<u64> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => row.token,
|
|
||||||
OutputRow::SessionClosed { token, .. } => Some(*token),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_bytes(row: &OutputRow) -> Option<usize> {
|
|
||||||
match row {
|
|
||||||
OutputRow::EchoSent { bytes, .. } | OutputRow::Duplicate { bytes, .. } => Some(*bytes),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => Some(reply.bytes),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_message(row: &OutputRow, context: RenderContext<'_>) -> Option<String> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) => Some(format!(
|
|
||||||
"token={:#x} duration_ns={} interval_ns={} length={}",
|
|
||||||
row.token?, row.duration_ns, row.interval_ns, row.payload_length
|
|
||||||
)),
|
|
||||||
OutputRow::NoTestCompleted(row) => Some(format!(
|
|
||||||
"duration_ns={} interval_ns={} length={}",
|
|
||||||
row.duration_ns, row.interval_ns, row.payload_length
|
|
||||||
)),
|
|
||||||
OutputRow::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
|
|
||||||
OutputRow::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
|
|
||||||
OutputRow::Warning { message, .. } => Some(message.clone()),
|
|
||||||
OutputRow::Loss { .. } => Some("timeout".to_owned()),
|
|
||||||
OutputRow::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_event_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
||||||
match row {
|
|
||||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => Some(row.event_wall),
|
|
||||||
OutputRow::SessionClosed { event_wall, .. } | OutputRow::Warning { event_wall, .. } => {
|
|
||||||
Some(*event_wall)
|
|
||||||
}
|
|
||||||
OutputRow::EchoSent {
|
|
||||||
client_send_wall, ..
|
|
||||||
}
|
|
||||||
| OutputRow::Loss {
|
|
||||||
client_send_wall, ..
|
|
||||||
} => Some(*client_send_wall),
|
|
||||||
OutputRow::Duplicate {
|
|
||||||
client_receive_wall,
|
|
||||||
..
|
|
||||||
} => Some(*client_receive_wall),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
|
|
||||||
Some(reply.client_receive_wall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_client_send_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
||||||
match row {
|
|
||||||
OutputRow::EchoSent {
|
|
||||||
client_send_wall, ..
|
|
||||||
}
|
|
||||||
| OutputRow::Loss {
|
|
||||||
client_send_wall, ..
|
|
||||||
} => Some(*client_send_wall),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => reply.client_send_wall,
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_client_receive_wall(row: &OutputRow) -> Option<SystemTime> {
|
|
||||||
match row {
|
|
||||||
OutputRow::Duplicate {
|
|
||||||
client_receive_wall,
|
|
||||||
..
|
|
||||||
} => Some(*client_receive_wall),
|
|
||||||
OutputRow::EchoReply(reply) | OutputRow::Late { reply, .. } => {
|
|
||||||
Some(reply.client_receive_wall)
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn server_timing_i64(
|
fn server_timing_i64(
|
||||||
row: &OutputRow,
|
row: &OutputRow,
|
||||||
select: impl FnOnce(ServerTiming) -> Option<i64>,
|
select: impl FnOnce(ServerTiming) -> Option<i64>,
|
||||||
@@ -1143,7 +931,7 @@ fn server_timing_i64(
|
|||||||
.map(|value| CellValue::Integer(i128::from(value)))
|
.map(|value| CellValue::Integer(i128::from(value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ipdv_pair(stats: Option<&EventRenderStats>, seq: u32) -> Option<&IpdvPair> {
|
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
|
||||||
let stats = stats?;
|
let stats = stats?;
|
||||||
stats
|
stats
|
||||||
.ipdv_pairs
|
.ipdv_pairs
|
||||||
|
|||||||
@@ -1,35 +1,31 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
|
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
|
||||||
io::{self, BufRead, Write},
|
io::{self, BufRead, Write},
|
||||||
sync::{atomic::AtomicBool, Arc, Mutex},
|
sync::Arc,
|
||||||
thread,
|
thread,
|
||||||
time::Duration,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
managed::{
|
managed::{
|
||||||
BlockingManagedClient, ManagedClientHandle, ManagedCommandApplyError, ManagedEndReason,
|
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
|
||||||
ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError, ManagedStatus,
|
ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||||
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
|
||||||
},
|
},
|
||||||
ClientEvent,
|
ClientEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{args::ClientArgs, output::OutputConfig};
|
||||||
args::ClientArgs,
|
|
||||||
output::{EventRenderStats, OutputConfig},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::shared::client::{
|
use crate::shared::client::{
|
||||||
expected_probe_count, is_shutdown_requested, parse_stdin_target_set,
|
expected_probe_count, parse_stdin_target_set,
|
||||||
session::{
|
session::{
|
||||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||||
request_managed_stop_once, should_print_final_summary, ManagedDrainState,
|
request_managed_stop_once, should_print_final_summary,
|
||||||
},
|
},
|
||||||
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
worker::ManagedWorker,
|
||||||
|
ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
};
|
};
|
||||||
|
|
||||||
use irtt_stats::{StatsCollector, StatsConfig};
|
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
|
||||||
|
|
||||||
const MIB: u64 = 1024 * 1024;
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
@@ -40,20 +36,20 @@ const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
|||||||
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
||||||
|
|
||||||
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
||||||
const MANAGED_EVENT_WAIT_SLICE: Duration = Duration::from_millis(20);
|
|
||||||
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
|
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
pub fn run_stream(
|
pub async fn run_stream(
|
||||||
args: ClientArgs,
|
args: ClientArgs,
|
||||||
shutdown_requested: &AtomicBool,
|
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if args.list_columns {
|
if args.list_columns {
|
||||||
print!("{}", OutputConfig::list_columns());
|
print!("{}", OutputConfig::list_columns());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let setup = args
|
let setup = tokio::select! {
|
||||||
.prepare()
|
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||||
|
};
|
||||||
let multi_target = setup.is_multi_target();
|
let multi_target = setup.is_multi_target();
|
||||||
let output_config = OutputConfig::new(
|
let output_config = OutputConfig::new(
|
||||||
args.format,
|
args.format,
|
||||||
@@ -63,7 +59,7 @@ pub fn run_stream(
|
|||||||
)
|
)
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||||
if setup.stdin_controlled {
|
if setup.stdin_controlled {
|
||||||
return run_stdin_stream(setup, output_config, shutdown_requested);
|
return run_stdin_stream(setup, output_config, shutdown).await;
|
||||||
}
|
}
|
||||||
let continuous = args.is_continuous();
|
let continuous = args.is_continuous();
|
||||||
let target_count = setup.target_count();
|
let target_count = setup.target_count();
|
||||||
@@ -71,14 +67,13 @@ pub fn run_stream(
|
|||||||
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
|
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
|
||||||
eprintln!("{warning}");
|
eprintln!("{warning}");
|
||||||
}
|
}
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
if *shutdown.borrow() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
|
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
|
||||||
setup.managed_config(),
|
let mut events = handle.subscribe()?;
|
||||||
setup.managed_targets(),
|
let mut status = handle.subscribe_status();
|
||||||
)?;
|
let owner = ManagedWorker::start(task, handle.clone())?;
|
||||||
let handle = owner.handle();
|
|
||||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||||
let mut stream_output = StreamOutput {
|
let mut stream_output = StreamOutput {
|
||||||
config: output_config,
|
config: output_config,
|
||||||
@@ -102,9 +97,9 @@ pub fn run_stream(
|
|||||||
let mut dropped_events = 0_u64;
|
let mut dropped_events = 0_u64;
|
||||||
let mut interrupted = false;
|
let mut interrupted = false;
|
||||||
let mut stop_requested = false;
|
let mut stop_requested = false;
|
||||||
let mut subscription_closed = false;
|
|
||||||
loop {
|
loop {
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
let snapshot = status.borrow_and_update().clone();
|
||||||
|
if *shutdown.borrow() {
|
||||||
interrupted = true;
|
interrupted = true;
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
if request_managed_stop_once(&mut stop_requested) {
|
||||||
drop(handle.stop());
|
drop(handle.stop());
|
||||||
@@ -113,38 +108,33 @@ pub fn run_stream(
|
|||||||
if request_managed_stop_for_peer_close(
|
if request_managed_stop_for_peer_close(
|
||||||
continuous,
|
continuous,
|
||||||
interrupted,
|
interrupted,
|
||||||
handle.status().peer_closed_target_outcomes,
|
snapshot.peer_closed_target_outcomes,
|
||||||
&mut stop_requested,
|
&mut stop_requested,
|
||||||
) {
|
) {
|
||||||
drop(handle.stop());
|
drop(handle.stop());
|
||||||
}
|
}
|
||||||
let drain_state = drain_events(
|
if snapshot.final_outcome.is_some() {
|
||||||
&mut events,
|
|
||||||
&mut stream_output,
|
|
||||||
&mut stats,
|
|
||||||
&mut terminal_targets,
|
|
||||||
&mut dropped_events,
|
|
||||||
)?;
|
|
||||||
match drain_state {
|
|
||||||
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
|
|
||||||
ManagedDrainState::Closed => subscription_closed = true,
|
|
||||||
ManagedDrainState::BudgetExhausted => {}
|
|
||||||
}
|
|
||||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown.changed(), if !interrupted => {}
|
||||||
|
changed = status.changed() => { if changed.is_err() { break; } }
|
||||||
|
event = events.recv() => match event {
|
||||||
|
Ok(event) => process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
dropped_events = dropped_events.saturating_add(count);
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if interrupted {
|
if interrupted {
|
||||||
eprintln!("interrupted, closing managed run...");
|
eprintln!("interrupted, closing managed run...");
|
||||||
}
|
}
|
||||||
let outcome = owner.join()?;
|
let outcome = owner.join().await?;
|
||||||
drain_final_events(
|
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||||
&mut events,
|
process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
|
||||||
&mut stream_output,
|
})?;
|
||||||
&mut stats,
|
|
||||||
&mut terminal_targets,
|
|
||||||
&mut dropped_events,
|
|
||||||
)?;
|
|
||||||
if let Some(warning) = dropped_event_warning(dropped_events) {
|
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||||
eprintln!("{warning}");
|
eprintln!("{warning}");
|
||||||
}
|
}
|
||||||
@@ -159,7 +149,7 @@ pub fn run_stream(
|
|||||||
report_target_failure(target);
|
report_target_failure(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
interrupted |= *shutdown.borrow();
|
||||||
let terminal_error = match &outcome.end_reason {
|
let terminal_error = match &outcome.end_reason {
|
||||||
ManagedEndReason::DriverFailed(failure) => {
|
ManagedEndReason::DriverFailed(failure) => {
|
||||||
Some(format!("managed driver failed: {failure}"))
|
Some(format!("managed driver failed: {failure}"))
|
||||||
@@ -201,98 +191,9 @@ pub fn run_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct StdinTargetSet {
|
enum StdinUpdate {
|
||||||
revision: u64,
|
Targets(Vec<TargetSpec>),
|
||||||
targets: Vec<ManagedTargetConfig>,
|
Stop(StdinStop),
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct StdinMailboxState {
|
|
||||||
next_revision: u64,
|
|
||||||
latest: Option<StdinTargetSet>,
|
|
||||||
eof: bool,
|
|
||||||
fatal: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct StdinMailbox {
|
|
||||||
state: Mutex<StdinMailboxState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StdinMailbox {
|
|
||||||
fn publish(&self, targets: Vec<ManagedTargetConfig>) {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
state.next_revision = state.next_revision.saturating_add(1);
|
|
||||||
state.latest = Some(StdinTargetSet {
|
|
||||||
revision: state.next_revision,
|
|
||||||
targets,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(&self) {
|
|
||||||
self.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.eof = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fail(&self, error: String) {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
state.fatal = Some(error);
|
|
||||||
state.latest = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn latest(&self) -> Option<StdinTargetSet> {
|
|
||||||
self.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.latest
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_if_current(&self, revision: u64) {
|
|
||||||
let mut state = self
|
|
||||||
.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
if state
|
|
||||||
.latest
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|set| set.revision == revision)
|
|
||||||
{
|
|
||||||
state.latest = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_current(&self, revision: u64) -> bool {
|
|
||||||
self.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.latest
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|set| set.revision == revision)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fatal(&self) -> Option<String> {
|
|
||||||
self.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.fatal
|
|
||||||
.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_finished(&self) -> bool {
|
|
||||||
self.state
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.eof
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_stdin_record<R: BufRead>(
|
fn read_stdin_record<R: BufRead>(
|
||||||
@@ -371,7 +272,10 @@ fn read_stdin_record<R: BufRead>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
|
fn read_stdin_target_sets<R: BufRead>(
|
||||||
|
reader: &mut R,
|
||||||
|
updates: &tokio::sync::watch::Sender<StdinUpdate>,
|
||||||
|
) {
|
||||||
let mut line = 0_u64;
|
let mut line = 0_u64;
|
||||||
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||||
loop {
|
loop {
|
||||||
@@ -383,101 +287,41 @@ fn read_stdin_target_sets<R: BufRead>(reader: &mut R, mailbox: &StdinMailbox) {
|
|||||||
}
|
}
|
||||||
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
||||||
Ok(targets) => {
|
Ok(targets) => {
|
||||||
mailbox.publish(targets.into_iter().map(|target| target.managed).collect())
|
updates.send_replace(StdinUpdate::Targets(targets));
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
mailbox.fail(format!("invalid --targets-stdin line {line}: {error}"));
|
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
|
||||||
|
"invalid --targets-stdin line {line}: {error}"
|
||||||
|
))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
mailbox.finish();
|
updates.send_replace(StdinUpdate::Stop(StdinStop::Eof));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
mailbox.fail(format!("failed to read --targets-stdin line {}", line + 1));
|
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
|
||||||
|
"failed to read --targets-stdin line {}",
|
||||||
|
line + 1
|
||||||
|
))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_stdin_target_reader(mailbox: Arc<StdinMailbox>) -> io::Result<()> {
|
fn spawn_stdin_target_reader(updates: tokio::sync::watch::Sender<StdinUpdate>) -> io::Result<()> {
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("irtt-targets-stdin".to_owned())
|
.name("irtt-targets-stdin".to_owned())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
read_stdin_target_sets(&mut stdin.lock(), &mailbox);
|
read_stdin_target_sets(&mut stdin.lock(), &updates);
|
||||||
})
|
})
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct RetryAfterStatus {
|
|
||||||
revision: u64,
|
|
||||||
status: Arc<ManagedStatus>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn retry_waits_for_status_change(
|
|
||||||
retry_after: &mut Option<RetryAfterStatus>,
|
|
||||||
target_set: &StdinTargetSet,
|
|
||||||
status: &Arc<ManagedStatus>,
|
|
||||||
) -> bool {
|
|
||||||
let Some(retry) = retry_after else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if retry.revision != target_set.revision {
|
|
||||||
*retry_after = None;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Arc::ptr_eq(&retry.status, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn retry_after_capacity_rejection(
|
|
||||||
mailbox: &StdinMailbox,
|
|
||||||
target_set: &StdinTargetSet,
|
|
||||||
status: Arc<ManagedStatus>,
|
|
||||||
) -> Option<RetryAfterStatus> {
|
|
||||||
mailbox
|
|
||||||
.is_current(target_set.revision)
|
|
||||||
.then_some(RetryAfterStatus {
|
|
||||||
revision: target_set.revision,
|
|
||||||
status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_latest_stdin_target_set(
|
|
||||||
handle: &ManagedClientHandle,
|
|
||||||
mailbox: &StdinMailbox,
|
|
||||||
retry_after: &mut Option<RetryAfterStatus>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let Some(target_set) = mailbox.latest() else {
|
|
||||||
*retry_after = None;
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let status = handle.status();
|
|
||||||
if retry_waits_for_status_change(retry_after, &target_set, &status) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let receipt = handle
|
|
||||||
.update_targets(target_set.targets.clone())
|
|
||||||
.map_err(|_| "failed to submit --targets-stdin update".to_owned())?;
|
|
||||||
match receipt.blocking_wait() {
|
|
||||||
Ok(_) => {
|
|
||||||
mailbox.clear_if_current(target_set.revision);
|
|
||||||
*retry_after = None;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
|
||||||
*retry_after = retry_after_capacity_rejection(mailbox, &target_set, status);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(_) => Err("--targets-stdin update was rejected".to_owned()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BoundedTargetSet {
|
struct BoundedTargetSet {
|
||||||
limit: usize,
|
limit: usize,
|
||||||
order: VecDeque<TargetInstance>,
|
order: VecDeque<TargetInstance>,
|
||||||
@@ -525,12 +369,8 @@ fn process_stdin_event<W: Write>(
|
|||||||
let collector = stats
|
let collector = stats
|
||||||
.entry(target.clone())
|
.entry(target.clone())
|
||||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||||
print_events_with_stats(
|
let update = collector.process(&event);
|
||||||
stream_output,
|
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
|
||||||
std::slice::from_ref(&event),
|
|
||||||
Some(target.id.as_str()),
|
|
||||||
collector,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
ManagedEvent::TargetFinished { outcome }
|
ManagedEvent::TargetFinished { outcome }
|
||||||
if terminal_targets.insert(outcome.target.clone()) =>
|
if terminal_targets.insert(outcome.target.clone()) =>
|
||||||
@@ -542,37 +382,6 @@ fn process_stdin_event<W: Write>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_stdin_events<W: Write>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
|
||||||
terminal_targets: &mut BoundedTargetSet,
|
|
||||||
dropped_events: &mut u64,
|
|
||||||
) -> io::Result<ManagedDrainState> {
|
|
||||||
drain_managed_events(events, dropped_events, |event| {
|
|
||||||
process_stdin_event(event, stream_output, stats, terminal_targets)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_final_stdin_events<W: Write>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
|
||||||
terminal_targets: &mut BoundedTargetSet,
|
|
||||||
dropped_events: &mut u64,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
loop {
|
|
||||||
match events.try_recv() {
|
|
||||||
Ok(event) => process_stdin_event(event, stream_output, stats, terminal_targets)?,
|
|
||||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
|
||||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
|
||||||
*dropped_events = dropped_events.saturating_add(count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconcile_stdin_stats(
|
fn reconcile_stdin_stats(
|
||||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||||
status: &ManagedStatus,
|
status: &ManagedStatus,
|
||||||
@@ -613,36 +422,20 @@ enum StdinStop {
|
|||||||
Fatal(String),
|
Fatal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stdin_stop_request(
|
async fn run_stdin_stream(
|
||||||
shutdown_requested: &AtomicBool,
|
|
||||||
mailbox: &StdinMailbox,
|
|
||||||
) -> Option<StdinStop> {
|
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
|
||||||
Some(StdinStop::Interrupted)
|
|
||||||
} else if let Some(error) = mailbox.fatal() {
|
|
||||||
Some(StdinStop::Fatal(error))
|
|
||||||
} else if mailbox.is_finished() {
|
|
||||||
Some(StdinStop::Eof)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_stdin_stream(
|
|
||||||
setup: ManagedRunSetup,
|
setup: ManagedRunSetup,
|
||||||
output_config: OutputConfig,
|
output_config: OutputConfig,
|
||||||
shutdown_requested: &AtomicBool,
|
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
if *shutdown.borrow() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let (owner, mut events) = BlockingManagedClient::start_with_subscription(
|
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
|
||||||
setup.managed_config(),
|
let mut events = handle.subscribe()?;
|
||||||
setup.managed_targets(),
|
let mut status = handle.subscribe_status();
|
||||||
)?;
|
let owner = ManagedWorker::start(task, handle.clone())?;
|
||||||
let handle = owner.handle();
|
let (updates, mut stdin) = tokio::sync::watch::channel(StdinUpdate::Targets(Vec::new()));
|
||||||
let mailbox = Arc::new(StdinMailbox::default());
|
spawn_stdin_target_reader(updates)?;
|
||||||
spawn_stdin_target_reader(Arc::clone(&mailbox))?;
|
|
||||||
|
|
||||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||||
let mut stream_output = StreamOutput {
|
let mut stream_output = StreamOutput {
|
||||||
@@ -654,59 +447,138 @@ fn run_stdin_stream(
|
|||||||
};
|
};
|
||||||
let mut stats = BTreeMap::new();
|
let mut stats = BTreeMap::new();
|
||||||
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
||||||
|
let mut stdin_changed = false;
|
||||||
|
let mut preparation = setup.preparation.clone();
|
||||||
|
let mut desired_specs = None;
|
||||||
|
let mut preparing = None;
|
||||||
|
let mut desired = None;
|
||||||
|
let mut pending = None;
|
||||||
|
let mut submitted = Vec::new();
|
||||||
let mut retry_after = None;
|
let mut retry_after = None;
|
||||||
let mut dropped_events = 0_u64;
|
let mut dropped_events = 0_u64;
|
||||||
let mut stop_requested = false;
|
let mut stop_requested = false;
|
||||||
let mut stop = None;
|
let mut stop = None;
|
||||||
let mut summary_targets = None;
|
let mut summary_targets = None;
|
||||||
let mut subscription_closed = false;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let status = handle.status();
|
let snapshot = status.borrow_and_update().clone();
|
||||||
if stop.is_none() {
|
if stop.is_none() {
|
||||||
if let Some(requested_stop) = stdin_stop_request(shutdown_requested, &mailbox) {
|
if *shutdown.borrow() {
|
||||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
|
stop = Some(StdinStop::Interrupted);
|
||||||
stop = Some(requested_stop);
|
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
stdin_changed = false;
|
||||||
drop(handle.stop());
|
match stdin.borrow_and_update().clone() {
|
||||||
|
StdinUpdate::Targets(specs) => {
|
||||||
|
desired_specs = Some(specs);
|
||||||
|
desired = None;
|
||||||
|
retry_after = None;
|
||||||
}
|
}
|
||||||
} else if let Err(error) =
|
StdinUpdate::Stop(reason) => stop = Some(reason),
|
||||||
apply_latest_stdin_target_set(&handle, &mailbox, &mut retry_after)
|
}
|
||||||
|
}
|
||||||
|
if stop.is_none() && preparing.is_none() {
|
||||||
|
if let Some(specs) = desired_specs.take() {
|
||||||
|
// Only one preparation runs at a time. Newer records supersede
|
||||||
|
// its result without piling up uncancellable system DNS jobs.
|
||||||
|
preparing = Some(Box::pin(
|
||||||
|
preparation
|
||||||
|
.clone()
|
||||||
|
.prepare(specs, Some(STDIN_MAX_DESIRED_TARGETS)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stop.is_none()
|
||||||
|
&& pending.is_none()
|
||||||
|
&& !retry_after
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|previous| Arc::ptr_eq(previous, &snapshot))
|
||||||
{
|
{
|
||||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status));
|
if let Some(targets) = desired.take() {
|
||||||
stop = Some(StdinStop::Fatal(error));
|
submitted = targets;
|
||||||
|
match handle.update_targets(submitted.clone()) {
|
||||||
|
Ok(receipt) => {
|
||||||
|
pending = Some(receipt);
|
||||||
|
retry_after = Some(snapshot.clone());
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
stop = Some(StdinStop::Fatal(
|
||||||
|
"failed to submit --targets-stdin update".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stop.is_some() {
|
||||||
|
preparing = None;
|
||||||
|
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
if request_managed_stop_once(&mut stop_requested) {
|
||||||
drop(handle.stop());
|
drop(handle.stop());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref());
|
||||||
let drain_state = drain_stdin_events(
|
if snapshot.final_outcome.is_some() {
|
||||||
&mut events,
|
|
||||||
&mut stream_output,
|
|
||||||
&mut stats,
|
|
||||||
&mut terminal_targets,
|
|
||||||
&mut dropped_events,
|
|
||||||
)?;
|
|
||||||
reconcile_stdin_stats(&mut stats, &handle.status(), summary_targets.as_ref());
|
|
||||||
match drain_state {
|
|
||||||
ManagedDrainState::Empty => thread::sleep(MANAGED_EVENT_WAIT_SLICE),
|
|
||||||
ManagedDrainState::Closed => subscription_closed = true,
|
|
||||||
ManagedDrainState::BudgetExhausted => {}
|
|
||||||
}
|
|
||||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = shutdown.changed(), if stop.is_none() => {}
|
||||||
|
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
|
||||||
|
changed = status.changed() => { if changed.is_err() { break; } }
|
||||||
|
result = async { preparing.as_mut().expect("guarded target preparation").await }, if preparing.is_some() && stop.is_none() => {
|
||||||
|
preparing = None;
|
||||||
|
// Let the next loop consume any newer record, including EOF,
|
||||||
|
// before considering this prepared revision for submission.
|
||||||
|
if desired_specs.is_none() && !stdin.has_changed().unwrap_or(true) {
|
||||||
|
match result {
|
||||||
|
Ok((targets, next_preparation)) => {
|
||||||
|
preparation = next_preparation;
|
||||||
|
desired = Some(targets.into_iter().map(|target| target.managed).collect());
|
||||||
|
retry_after = None;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}")));
|
||||||
|
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
|
||||||
|
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
|
||||||
|
pending = None;
|
||||||
|
match result {
|
||||||
|
Ok(_) => retry_after = None,
|
||||||
|
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
||||||
|
// A newer desired set supersedes this rejected set. Otherwise,
|
||||||
|
// retry only after the driver's status advances from submission.
|
||||||
|
if desired.is_none() && desired_specs.is_none() && preparing.is_none() {
|
||||||
|
desired = Some(std::mem::take(&mut submitted));
|
||||||
|
} else {
|
||||||
|
retry_after = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned()));
|
||||||
|
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
|
||||||
|
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = events.recv() => match event {
|
||||||
|
Ok(event) => {
|
||||||
|
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?;
|
||||||
|
reconcile_stdin_stats(&mut stats, &status.borrow(), summary_targets.as_ref());
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||||
|
dropped_events = dropped_events.saturating_add(count);
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outcome = owner.join()?;
|
let outcome = owner.join().await?;
|
||||||
drain_final_stdin_events(
|
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||||
&mut events,
|
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
|
||||||
&mut stream_output,
|
})?;
|
||||||
&mut stats,
|
|
||||||
&mut terminal_targets,
|
|
||||||
&mut dropped_events,
|
|
||||||
)?;
|
|
||||||
if let Some(warning) = dropped_event_warning(dropped_events) {
|
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||||
eprintln!("{warning}");
|
eprintln!("{warning}");
|
||||||
}
|
}
|
||||||
@@ -766,12 +638,8 @@ fn process_event<W: Write>(
|
|||||||
let collector = stats
|
let collector = stats
|
||||||
.entry(target.id.as_str().to_owned())
|
.entry(target.id.as_str().to_owned())
|
||||||
.or_insert_with(|| StatsCollector::new(stats_config(false)));
|
.or_insert_with(|| StatsCollector::new(stats_config(false)));
|
||||||
print_events_with_stats(
|
let update = collector.process(&event);
|
||||||
stream_output,
|
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
|
||||||
std::slice::from_ref(&event),
|
|
||||||
Some(target.id.as_str()),
|
|
||||||
collector,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
ManagedEvent::TargetFinished { outcome } => {
|
ManagedEvent::TargetFinished { outcome } => {
|
||||||
terminal_targets.insert(outcome.target.clone());
|
terminal_targets.insert(outcome.target.clone());
|
||||||
@@ -782,37 +650,6 @@ fn process_event<W: Write>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn drain_events<W: Write>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
stats: &mut BTreeMap<String, StatsCollector>,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
dropped_events: &mut u64,
|
|
||||||
) -> io::Result<ManagedDrainState> {
|
|
||||||
drain_managed_events(events, dropped_events, |event| {
|
|
||||||
process_event(event, stream_output, stats, terminal_targets)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drain_final_events<W: Write>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
stats: &mut BTreeMap<String, StatsCollector>,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
dropped_events: &mut u64,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
loop {
|
|
||||||
match events.try_recv() {
|
|
||||||
Ok(event) => process_event(event, stream_output, stats, terminal_targets)?,
|
|
||||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
|
||||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
|
||||||
*dropped_events = dropped_events.saturating_add(count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn report_target_failure(target: &ManagedTargetOutcome) {
|
fn report_target_failure(target: &ManagedTargetOutcome) {
|
||||||
for message in target_failure_messages(target) {
|
for message in target_failure_messages(target) {
|
||||||
eprintln!("{message}");
|
eprintln!("{message}");
|
||||||
@@ -847,18 +684,16 @@ struct StreamOutput<'a, W: Write> {
|
|||||||
out: &'a mut W,
|
out: &'a mut W,
|
||||||
}
|
}
|
||||||
impl<W: Write> StreamOutput<'_, W> {
|
impl<W: Write> StreamOutput<'_, W> {
|
||||||
fn print_events(
|
fn print_event(
|
||||||
&mut self,
|
&mut self,
|
||||||
events: &[ClientEvent],
|
event: &ClientEvent,
|
||||||
target: Option<&str>,
|
target: Option<&str>,
|
||||||
stats_updates: &[EventRenderStats],
|
update: &EventStatsUpdate,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
self.print_header()?;
|
self.print_header()?;
|
||||||
for (event, stats_update) in events.iter().zip(stats_updates) {
|
if let Some(line) = self.config.render_event(event, target, Some(update)) {
|
||||||
if let Some(line) = self.config.render_event(event, target, Some(stats_update)) {
|
|
||||||
writeln!(self.out, "{line}")?;
|
writeln!(self.out, "{line}")?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn print_header(&mut self) -> io::Result<()> {
|
fn print_header(&mut self) -> io::Result<()> {
|
||||||
@@ -890,19 +725,6 @@ impl<W: Write> StreamOutput<'_, W> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_events_with_stats<W: Write>(
|
|
||||||
stream_output: &mut StreamOutput<'_, W>,
|
|
||||||
events: &[ClientEvent],
|
|
||||||
target: Option<&str>,
|
|
||||||
stats: &mut StatsCollector,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
let updates = events
|
|
||||||
.iter()
|
|
||||||
.map(|event| EventRenderStats::from(stats.process(event)))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
stream_output.print_events(events, target, &updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stats_config(continuous: bool) -> StatsConfig {
|
fn stats_config(continuous: bool) -> StatsConfig {
|
||||||
if continuous {
|
if continuous {
|
||||||
StatsConfig::continuous()
|
StatsConfig::continuous()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl TuiArgs {
|
|||||||
///
|
///
|
||||||
/// The TUI always requires a target: the parser rejects an empty target
|
/// The TUI always requires a target: the parser rejects an empty target
|
||||||
/// list, and this rejects a target set that cannot be labelled uniquely.
|
/// 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(
|
prepare_managed_run(
|
||||||
&self.common,
|
&self.common,
|
||||||
self.duration,
|
self.duration,
|
||||||
@@ -59,6 +59,7 @@ impl TuiArgs {
|
|||||||
stdin_controlled: false,
|
stdin_controlled: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_continuous(&self) -> bool {
|
pub fn is_continuous(&self) -> bool {
|
||||||
|
|||||||
+213
-291
@@ -1,149 +1,232 @@
|
|||||||
use std::{
|
use std::{future::poll_fn, io, time::Duration};
|
||||||
collections::HashSet,
|
|
||||||
io,
|
|
||||||
sync::atomic::{AtomicBool, Ordering},
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
use super::ui::{TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||||
use crate::{
|
use crate::{
|
||||||
cmd::tui::args::TuiArgs,
|
cmd::tui::args::TuiArgs,
|
||||||
shared::client::{
|
shared::client::{
|
||||||
is_shutdown_requested,
|
session::{drain_final_events, request_managed_stop_once},
|
||||||
session::{
|
worker::ManagedWorker,
|
||||||
drain_managed_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
|
||||||
request_managed_stop_once, ManagedDrainState,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::{
|
||||||
use irtt_client::managed::{
|
event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers},
|
||||||
BlockingManagedClient, ManagedEndReason, ManagedEvent, ManagedEventSubscription,
|
terminal,
|
||||||
ManagedEventTryRecvError, TargetInstance,
|
};
|
||||||
|
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 RETRY_DELAY: Duration = Duration::from_millis(1500);
|
||||||
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
|
|
||||||
const INPUT_EVENT_WORK_BUDGET: usize = 128;
|
|
||||||
|
|
||||||
pub fn run_tui(
|
const RENDER_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
pub async fn run_tui(
|
||||||
args: TuiArgs,
|
args: TuiArgs,
|
||||||
shutdown_requested: &AtomicBool,
|
mut shutdown: watch::Receiver<bool>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let setup = args
|
let setup = tokio::select! {
|
||||||
.prepare()
|
result = args.prepare() => result.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?,
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
_ = shutdown.wait_for(|requested| *requested) => return Ok(()),
|
||||||
|
};
|
||||||
let continuous = args.is_continuous();
|
let continuous = args.is_continuous();
|
||||||
|
if *shutdown.borrow() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut terminal = TuiTerminal::enter()?;
|
let mut terminal = TuiTerminal::enter()?;
|
||||||
let mut state = TuiState::with_target_labels(
|
let mut state = TuiState::with_target_labels(
|
||||||
TuiConfig::from_args(&args, &setup),
|
TuiConfig::from_args(&args, &setup),
|
||||||
setup.targets.iter().map(|target| target.label.clone()),
|
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);
|
state.set_status(TuiStatus::Opening);
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(
|
let desired_targets = setup.managed_targets();
|
||||||
setup.managed_config(),
|
let mut config = setup.managed_config();
|
||||||
setup.managed_targets(),
|
if continuous {
|
||||||
) {
|
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
}
|
||||||
|
let (task, handle) = match ManagedClient::task(config, desired_targets.clone()) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
state.set_error(error.to_string());
|
state.set_error(error.to_string());
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
return Err(Box::new(error));
|
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 interrupted = false;
|
||||||
let mut stop_requested = false;
|
let mut stop_requested = false;
|
||||||
let mut terminal_targets = HashSet::new();
|
let retry = sleep(RETRY_DELAY);
|
||||||
let mut dropped_events = 0;
|
tokio::pin!(retry);
|
||||||
let mut subscription_closed = false;
|
let mut retry_armed = false;
|
||||||
loop {
|
let mut update_receipt = None;
|
||||||
if is_shutdown_requested(shutdown_requested) {
|
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;
|
interrupted = true;
|
||||||
if request_managed_stop_once(&mut stop_requested) {
|
|
||||||
drop(handle.stop());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if request_managed_stop_for_peer_close(
|
|
||||||
continuous,
|
|
||||||
interrupted,
|
|
||||||
handle.status().peer_closed_target_outcomes,
|
|
||||||
&mut stop_requested,
|
|
||||||
) {
|
|
||||||
drop(handle.stop());
|
|
||||||
}
|
|
||||||
let previous_dropped = dropped_events;
|
|
||||||
let drain_state = drain_tui_events(
|
|
||||||
&mut events,
|
|
||||||
&mut state,
|
|
||||||
&mut terminal_targets,
|
|
||||||
&mut dropped_events,
|
|
||||||
);
|
|
||||||
if dropped_events != previous_dropped {
|
|
||||||
// Data loss changes the meaning of displayed statistics, even while
|
|
||||||
// ordinary measurement refreshes are paused.
|
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
|
||||||
}
|
|
||||||
match drain_state {
|
|
||||||
ManagedDrainState::Empty => {
|
|
||||||
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
|
|
||||||
}
|
|
||||||
ManagedDrainState::Closed => subscription_closed = true,
|
|
||||||
ManagedDrainState::BudgetExhausted => {}
|
|
||||||
}
|
|
||||||
if handle.status().final_outcome.is_some() || subscription_closed {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, false)?;
|
|
||||||
}
|
|
||||||
if interrupted {
|
|
||||||
state.set_status(TuiStatus::Interrupted);
|
state.set_status(TuiStatus::Interrupted);
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
force_render = true;
|
||||||
}
|
}
|
||||||
state.set_status(TuiStatus::Closing);
|
changed = status.changed(), if !status_closed => {
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
status_closed = changed.is_err();
|
||||||
let outcome = owner.join()?;
|
state.process_managed_status(&status.borrow_and_update());
|
||||||
drain_final_tui_events(
|
force_render = true;
|
||||||
&mut events,
|
}
|
||||||
&mut state,
|
event = events.recv(), if !events_closed => match event {
|
||||||
&mut terminal_targets,
|
Ok(event) => force_render = process_tui_event(
|
||||||
&mut dropped_events,
|
event, &mut state,
|
||||||
);
|
),
|
||||||
|
Err(RecvError::Lagged(count)) => {
|
||||||
|
dropped_events = dropped_events.saturating_add(count);
|
||||||
state.mark_dropped_managed_events(dropped_events);
|
state.mark_dropped_managed_events(dropped_events);
|
||||||
if outcome.discarded_target_outcomes != 0 {
|
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 interrupted && request_managed_stop_once(&mut stop_requested) {
|
||||||
|
drop(handle.stop());
|
||||||
|
}
|
||||||
|
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 force_render {
|
||||||
|
terminal.draw(&state)?;
|
||||||
|
// Schedule from the completed draw, never catch up missed frames.
|
||||||
|
render.as_mut().reset(Instant::now() + RENDER_INTERVAL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
state.set_status(TuiStatus::Closing);
|
||||||
|
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 !continuous && outcome.discarded_target_outcomes != 0 {
|
||||||
state.set_run_error(format!(
|
state.set_run_error(format!(
|
||||||
"{} final target outcomes were discarded",
|
"{} final target outcomes were discarded",
|
||||||
outcome.discarded_target_outcomes
|
outcome.discarded_target_outcomes
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
for target in outcome.recent_target_outcomes.iter() {
|
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 |= *shutdown.borrow();
|
||||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
|
||||||
let error = match &outcome.end_reason {
|
let error = match &outcome.end_reason {
|
||||||
ManagedEndReason::DriverFailed(failure) => {
|
ManagedEndReason::DriverFailed(failure) => {
|
||||||
Some(format!("managed driver failed: {failure}"))
|
Some(format!("managed driver failed: {failure}"))
|
||||||
}
|
}
|
||||||
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
_ => (!interrupted
|
||||||
.or_else(|| {
|
|
||||||
(!interrupted
|
|
||||||
&& outcome.successful_target_outcomes == 0
|
&& outcome.successful_target_outcomes == 0
|
||||||
&& outcome.failed_target_outcomes > 0)
|
&& outcome.failed_target_outcomes > 0)
|
||||||
.then(|| {
|
.then(|| {
|
||||||
@@ -151,206 +234,45 @@ pub fn run_tui(
|
|||||||
"no managed target completed successfully ({} failed)",
|
"no managed target completed successfully ({} failed)",
|
||||||
outcome.failed_target_outcomes
|
outcome.failed_target_outcomes
|
||||||
)
|
)
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
if let Some(error) = error {
|
if let Some(error) = error {
|
||||||
state.set_run_error(error.clone());
|
state.set_run_error(error.clone());
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
state.set_status(TuiStatus::Complete);
|
state.set_status(TuiStatus::Complete);
|
||||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
terminal.draw(&state)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_tui_event(
|
fn process_tui_event(event: ManagedEvent, state: &mut TuiState) -> bool {
|
||||||
event: ManagedEvent,
|
|
||||||
state: &mut TuiState,
|
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
|
||||||
) {
|
|
||||||
match event {
|
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 } => {
|
ManagedEvent::TargetFinished { outcome } => {
|
||||||
terminal_targets.insert(outcome.target.clone());
|
|
||||||
state.process_target_outcome(&outcome);
|
state.process_target_outcome(&outcome);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
_ => {}
|
ManagedEvent::TargetStateChanged {
|
||||||
}
|
target,
|
||||||
}
|
lifecycle:
|
||||||
fn drain_tui_events(
|
ManagedTargetLifecycle::Pending
|
||||||
events: &mut ManagedEventSubscription,
|
| ManagedTargetLifecycle::Connecting
|
||||||
state: &mut TuiState,
|
| ManagedTargetLifecycle::Opening,
|
||||||
terminal_targets: &mut HashSet<TargetInstance>,
|
} => {
|
||||||
dropped_events: &mut u64,
|
state.process_target_opening(&target);
|
||||||
) -> ManagedDrainState {
|
true
|
||||||
let previous_dropped = *dropped_events;
|
}
|
||||||
let drained = drain_managed_events(events, dropped_events, |event| {
|
_ => false,
|
||||||
process_tui_event(event, state, terminal_targets);
|
|
||||||
Ok::<(), std::convert::Infallible>(())
|
|
||||||
})
|
|
||||||
.expect("processing TUI managed events is infallible");
|
|
||||||
if *dropped_events != previous_dropped {
|
|
||||||
state.mark_dropped_managed_events(*dropped_events);
|
|
||||||
}
|
|
||||||
drained
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use ratatui::{backend::TestBackend, Terminal};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() {
|
|
||||||
for paused in [false, true] {
|
|
||||||
// The production subscription is a broadcast receiver. Overflow the
|
|
||||||
// real channel rather than synthesizing a lag result or UI state.
|
|
||||||
let (sender, mut events) = tokio::sync::broadcast::channel(1);
|
|
||||||
sender.send(ManagedEvent::Started).unwrap();
|
|
||||||
sender.send(ManagedEvent::Started).unwrap();
|
|
||||||
let mut state = TuiState::default();
|
|
||||||
if paused {
|
|
||||||
state.toggle_pause();
|
|
||||||
}
|
|
||||||
let mut dropped = 0;
|
|
||||||
let mut terminal_targets = HashSet::new();
|
|
||||||
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
|
|
||||||
assert_eq!(dropped, 1);
|
|
||||||
let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap();
|
|
||||||
for _ in 0..2 {
|
|
||||||
// Empty drains must not clear the warning on later redraws.
|
|
||||||
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
|
|
||||||
terminal
|
|
||||||
.draw(|frame| super::super::ui::draw_dashboard(frame, &state))
|
|
||||||
.unwrap();
|
|
||||||
let text = terminal
|
|
||||||
.backend()
|
|
||||||
.buffer()
|
|
||||||
.content()
|
|
||||||
.iter()
|
|
||||||
.map(|cell| cell.symbol())
|
|
||||||
.collect::<String>();
|
|
||||||
assert!(text.contains("incomplete:dropped=1"));
|
|
||||||
assert_eq!(text.contains("display paused"), paused);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1152
-949
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::sync::{atomic::AtomicBool, Arc};
|
||||||
use std::{env, ffi::OsString, process::ExitCode};
|
use std::{env, ffi::OsString, process::ExitCode};
|
||||||
|
|
||||||
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
|
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;
|
use crate::signal::install_signal_handler;
|
||||||
|
|
||||||
/// Entry point for the `irtt-rs` multicall dispatcher binary.
|
/// 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`.
|
/// resulting flag to `f`.
|
||||||
#[cfg(any(feature = "client", feature = "server", feature = "tui"))]
|
#[cfg(feature = "server")]
|
||||||
fn with_shutdown_flag(
|
fn with_shutdown_flag(
|
||||||
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
||||||
) -> 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>> {
|
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
with_shutdown_flag(|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);
|
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||||
crate::cmd::client::run_stream(args, shutdown_requested)
|
tokio::runtime::Builder::new_current_thread()
|
||||||
})
|
.enable_all()
|
||||||
|
.build()?
|
||||||
|
.block_on(crate::cmd::client::run_stream(args, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "client"))]
|
#[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>> {
|
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
with_shutdown_flag(|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);
|
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||||
crate::cmd::tui::run_tui(args, shutdown_requested)
|
tokio::runtime::Builder::new_current_thread()
|
||||||
})
|
.enable_all()
|
||||||
|
.build()?
|
||||||
|
.block_on(crate::cmd::tui::run_tui(args, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "tui"))]
|
#[cfg(not(feature = "tui"))]
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ use irtt_proto::{Clock, ReceivedStats, StampAt};
|
|||||||
|
|
||||||
#[derive(Clone, clap::Args)]
|
#[derive(Clone, clap::Args)]
|
||||||
pub struct CommonClientArgs {
|
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.
|
/// Probe interval.
|
||||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||||
pub interval: Duration,
|
pub interval: Duration,
|
||||||
@@ -66,6 +78,9 @@ pub struct CommonClientArgs {
|
|||||||
impl fmt::Debug for CommonClientArgs {
|
impl fmt::Debug for CommonClientArgs {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("CommonClientArgs")
|
f.debug_struct("CommonClientArgs")
|
||||||
|
.field("ipv4", &self.ipv4)
|
||||||
|
.field("ipv6", &self.ipv6)
|
||||||
|
.field("dual_stack", &self.dual_stack)
|
||||||
.field("interval", &self.interval)
|
.field("interval", &self.interval)
|
||||||
.field("length", &self.length)
|
.field("length", &self.length)
|
||||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy, SessionRequest,
|
AddressFamily, Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy,
|
||||||
SocketConfig,
|
SessionRequest, SocketConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::args::CommonClientArgs;
|
use super::args::CommonClientArgs;
|
||||||
|
|
||||||
impl CommonClientArgs {
|
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.
|
/// Build the shared client configuration template for a managed run.
|
||||||
///
|
///
|
||||||
/// The config is endpoint-independent; managed targets own their addresses.
|
/// The config is endpoint-independent; managed targets own their addresses.
|
||||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
||||||
ClientConfig {
|
ClientConfig {
|
||||||
|
address_family: self.address_family(),
|
||||||
open: OpenPolicy {
|
open: OpenPolicy {
|
||||||
negotiation: if self.loose {
|
negotiation: if self.loose {
|
||||||
NegotiationPolicy::Loose
|
NegotiationPolicy::Loose
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod config;
|
|||||||
pub mod prepare;
|
pub mod prepare;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod targets;
|
pub mod targets;
|
||||||
|
pub(crate) mod worker;
|
||||||
|
|
||||||
pub use args::{
|
pub use args::{
|
||||||
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
||||||
@@ -13,8 +14,7 @@ pub use prepare::{
|
|||||||
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||||
};
|
};
|
||||||
pub use session::is_shutdown_requested;
|
|
||||||
pub use targets::{
|
pub use targets::{
|
||||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
parse_stdin_target_set, parse_target, target_specs, GroupPacingArg, PreparedTarget, TargetArg,
|
||||||
PreparedTarget, TargetArg, TargetSpec,
|
TargetPreparation, TargetSpec,
|
||||||
};
|
};
|
||||||
@@ -16,7 +16,7 @@ use irtt_client::{
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
args::CommonClientArgs,
|
args::CommonClientArgs,
|
||||||
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
targets::{target_specs, GroupPacingArg, TargetArg, TargetPreparation},
|
||||||
PreparedTarget,
|
PreparedTarget,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ pub struct ManagedRunSetup {
|
|||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
/// Whether this stream may replace its desired set from stdin.
|
/// Whether this stream may replace its desired set from stdin.
|
||||||
pub stdin_controlled: bool,
|
pub stdin_controlled: bool,
|
||||||
|
/// Preparation policy and family selections for later stdin declarations.
|
||||||
|
pub preparation: TargetPreparation,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManagedRunSetup {
|
impl ManagedRunSetup {
|
||||||
@@ -98,7 +100,7 @@ impl ManagedRunSetup {
|
|||||||
///
|
///
|
||||||
/// Targets are validated before any configuration is built, so a bad target set
|
/// Targets are validated before any configuration is built, so a bad target set
|
||||||
/// fails before the caller can act on a half-prepared run.
|
/// fails before the caller can act on a half-prepared run.
|
||||||
pub fn prepare_managed_run(
|
pub async fn prepare_managed_run(
|
||||||
common: &CommonClientArgs,
|
common: &CommonClientArgs,
|
||||||
duration: Duration,
|
duration: Duration,
|
||||||
selection: TargetSelection<'_>,
|
selection: TargetSelection<'_>,
|
||||||
@@ -113,11 +115,19 @@ pub fn prepare_managed_run(
|
|||||||
} else {
|
} else {
|
||||||
target_specs(selection.targets)?
|
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 {
|
Ok(ManagedRunSetup {
|
||||||
targets,
|
targets,
|
||||||
client: common.to_client_config(duration),
|
client: common.to_client_config(duration),
|
||||||
pacing: selection.pacing.into(),
|
pacing: selection.pacing.into(),
|
||||||
stdin_controlled: selection.stdin_controlled,
|
stdin_controlled: selection.stdin_controlled,
|
||||||
|
preparation,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,20 @@
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
|
||||||
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
|
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
|
||||||
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
pub(crate) fn drain_final_events<E>(
|
||||||
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>(
|
|
||||||
events: &mut ManagedEventSubscription,
|
events: &mut ManagedEventSubscription,
|
||||||
dropped_events: &mut u64,
|
dropped_events: &mut u64,
|
||||||
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
|
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
|
||||||
) -> Result<ManagedDrainState, E> {
|
) -> Result<(), E> {
|
||||||
for _ in 0..MANAGED_EVENT_WORK_BUDGET {
|
loop {
|
||||||
match events.try_recv() {
|
match events.try_recv() {
|
||||||
Ok(event) => process(event)?,
|
Ok(event) => process(event)?,
|
||||||
|
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||||
*dropped_events = dropped_events.saturating_add(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)
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
|
||||||
shutdown_requested.load(Ordering::Relaxed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
use std::{collections::HashSet, fmt};
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fmt, io,
|
||||||
|
net::{IpAddr, SocketAddr},
|
||||||
|
};
|
||||||
|
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use irtt_client::{
|
use irtt_client::{
|
||||||
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||||
Authentication, HmacKey,
|
AddressFamily, Authentication, HmacKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One raw positional target captured by Clap.
|
/// One raw positional target captured by Clap.
|
||||||
@@ -63,6 +67,7 @@ impl fmt::Debug for PreparedTarget {
|
|||||||
f.debug_struct("PreparedTarget")
|
f.debug_struct("PreparedTarget")
|
||||||
.field("label", &self.label)
|
.field("label", &self.label)
|
||||||
.field("server_addr", &self.managed.server_addr)
|
.field("server_addr", &self.managed.server_addr)
|
||||||
|
.field("address_family", &self.managed.address_family)
|
||||||
.field("auth", &self.managed.auth)
|
.field("auth", &self.managed.auth)
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -257,17 +262,146 @@ pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
|||||||
target_specs_with_empty(targets, false)
|
target_specs_with_empty(targets, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
/// Family selections for the current desired declarations, never resolved IPs.
|
||||||
let mut targets = Vec::with_capacity(specs.len());
|
/// Unchanged stdin declarations retain their selections; removed declarations
|
||||||
for spec in specs {
|
/// are forgotten, so adding them again performs fresh discovery.
|
||||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
#[derive(Debug, Clone)]
|
||||||
managed.auth = spec.auth;
|
pub struct TargetPreparation {
|
||||||
targets.push(PreparedTarget {
|
family: AddressFamily,
|
||||||
label: spec.label,
|
dual_stack: bool,
|
||||||
managed,
|
discovered: HashMap<String, (String, [bool; 2])>,
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Ok(targets)
|
|
||||||
|
impl TargetPreparation {
|
||||||
|
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
family,
|
||||||
|
dual_stack,
|
||||||
|
discovered: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/// Parse one complete stdin target set after its line terminator was removed.
|
||||||
@@ -277,7 +411,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
|
|||||||
pub fn parse_stdin_target_set(
|
pub fn parse_stdin_target_set(
|
||||||
record: &str,
|
record: &str,
|
||||||
maximum_targets: usize,
|
maximum_targets: usize,
|
||||||
) -> Result<Vec<PreparedTarget>, String> {
|
) -> Result<Vec<TargetSpec>, String> {
|
||||||
if record == "[]" {
|
if record == "[]" {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -288,5 +422,5 @@ pub fn parse_stdin_target_set(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
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::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
||||||
ctrlc::set_handler(move || {
|
ctrlc::set_handler(move || {
|
||||||
shutdown_requested.store(true, Ordering::Relaxed);
|
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)
|
||||||
|
}
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
#[cfg(feature = "client")]
|
|
||||||
#[path = "support/in_tree_server.rs"]
|
|
||||||
mod in_tree_server;
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
use irtt_server::ServerConfig;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
struct ClientProcess(std::process::Child);
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
impl Drop for ClientProcess {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = self.0.kill();
|
|
||||||
let _ = self.0.wait();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
impl ClientProcess {
|
|
||||||
fn wait(&mut self) -> std::process::ExitStatus {
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
if let Some(status) = self.0.try_wait().unwrap() {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
std::time::Instant::now() < deadline,
|
|
||||||
"client did not terminate within five seconds"
|
|
||||||
);
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch/help/availability belong to the application and run in every build.
|
|
||||||
#[test]
|
|
||||||
fn dispatcher_reports_and_runs_the_enabled_applets() {
|
|
||||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
|
||||||
let help = Command::new(dispatcher).arg("--help").output().unwrap();
|
|
||||||
assert!(help.status.success());
|
|
||||||
let help = String::from_utf8(help.stdout).unwrap();
|
|
||||||
for (applet, enabled) in [
|
|
||||||
("client", cfg!(feature = "client")),
|
|
||||||
("server", cfg!(feature = "server")),
|
|
||||||
("tui", cfg!(feature = "tui")),
|
|
||||||
] {
|
|
||||||
let row = help
|
|
||||||
.lines()
|
|
||||||
.find(|line| line.trim_start().starts_with(applet))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(row.contains("not available"), !enabled);
|
|
||||||
let output = Command::new(dispatcher)
|
|
||||||
.args([applet, "--help"])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(output.status.success(), enabled, "{applet}");
|
|
||||||
if !enabled {
|
|
||||||
assert!(String::from_utf8(output.stderr)
|
|
||||||
.unwrap()
|
|
||||||
.contains("not available"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let binaries = [
|
|
||||||
dispatcher,
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
env!("CARGO_BIN_EXE_irtt-client"),
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
env!("CARGO_BIN_EXE_irtt-server"),
|
|
||||||
#[cfg(feature = "tui")]
|
|
||||||
env!("CARGO_BIN_EXE_irtt-tui"),
|
|
||||||
];
|
|
||||||
for binary in binaries {
|
|
||||||
assert!(Command::new(binary)
|
|
||||||
.arg("--help")
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.status
|
|
||||||
.success());
|
|
||||||
assert!(!Command::new(binary)
|
|
||||||
.arg("--invalid-option")
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.status
|
|
||||||
.success());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The default presentation must reach a meaningful packet summary for a finite run.
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn default_client_output_emits_a_packet_summary() {
|
|
||||||
use std::{io::Read, process::Stdio, thread};
|
|
||||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
|
||||||
let mut client = ClientProcess(
|
|
||||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
|
||||||
.env_clear()
|
|
||||||
.args([
|
|
||||||
"--duration",
|
|
||||||
"100ms",
|
|
||||||
"--interval",
|
|
||||||
"20ms",
|
|
||||||
&server.addr.to_string(),
|
|
||||||
])
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let mut stdout = client.0.stdout.take().unwrap();
|
|
||||||
let stdout = thread::spawn(move || {
|
|
||||||
let mut text = String::new();
|
|
||||||
stdout.read_to_string(&mut text).unwrap();
|
|
||||||
text
|
|
||||||
});
|
|
||||||
let mut stderr = client.0.stderr.take().unwrap();
|
|
||||||
let stderr = thread::spawn(move || {
|
|
||||||
let mut text = String::new();
|
|
||||||
stderr.read_to_string(&mut text).unwrap();
|
|
||||||
text
|
|
||||||
});
|
|
||||||
let status = client.wait();
|
|
||||||
let stdout = stdout.join().unwrap();
|
|
||||||
let stderr = stderr.join().unwrap();
|
|
||||||
assert!(status.success(), "{stderr}");
|
|
||||||
assert!(stdout.contains("irtt-rs summary"), "{stdout}");
|
|
||||||
let packets = stdout
|
|
||||||
.lines()
|
|
||||||
.find(|line| line.starts_with("packets:"))
|
|
||||||
.expect("default output must include the packet summary");
|
|
||||||
for field in ["sent=", "received="] {
|
|
||||||
let count = packets
|
|
||||||
.split_whitespace()
|
|
||||||
.find_map(|word| word.strip_prefix(field))
|
|
||||||
.unwrap()
|
|
||||||
.parse::<u64>()
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
count > 0,
|
|
||||||
"summary must report {field} a positive count: {packets}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn target_authentication_inherits_overrides_or_disables_the_global_key() {
|
|
||||||
use clap::Parser;
|
|
||||||
use irtt_app::cmd::client::args::ClientArgs;
|
|
||||||
use irtt_client::{managed::TargetAuth, Authentication, HmacKey};
|
|
||||||
let args = ClientArgs::try_parse_from([
|
|
||||||
"irtt-client",
|
|
||||||
"--hmac",
|
|
||||||
"CONSPICUOUS_GLOBAL_HMAC_KEY",
|
|
||||||
"inherit=127.0.0.1:2112",
|
|
||||||
"override=127.0.0.1:2113@hmac=CONSPICUOUS_TARGET_HMAC_KEY",
|
|
||||||
"disable=127.0.0.1:2114@hmac=",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
let setup = args.prepare().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
setup.managed_config().client.auth,
|
|
||||||
Authentication::Hmac(HmacKey::new(b"CONSPICUOUS_GLOBAL_HMAC_KEY".as_slice()))
|
|
||||||
);
|
|
||||||
let targets = setup.managed_targets();
|
|
||||||
let authentication: Vec<_> = targets.iter().map(|target| target.auth.clone()).collect();
|
|
||||||
assert_eq!(
|
|
||||||
authentication,
|
|
||||||
[
|
|
||||||
TargetAuth::Inherit,
|
|
||||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(
|
|
||||||
b"CONSPICUOUS_TARGET_HMAC_KEY".as_slice()
|
|
||||||
))),
|
|
||||||
TargetAuth::Override(Authentication::Unauthenticated)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
for output in [format!("{args:?}"), format!("{setup:?}")] {
|
|
||||||
assert!(!output.contains("CONSPICUOUS_GLOBAL_HMAC_KEY"));
|
|
||||||
assert!(!output.contains("CONSPICUOUS_TARGET_HMAC_KEY"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A real authenticated server detects broken client argument-to-config mapping.
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn client_arguments_export_a_real_client_run() {
|
|
||||||
use std::{io::Read, process::Stdio, thread};
|
|
||||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
|
||||||
for option in ["--dscp", "--ttl"] {
|
|
||||||
assert!(!Command::new(dispatcher)
|
|
||||||
.args(["client", option, "256", "127.0.0.1:9"])
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
.status
|
|
||||||
.success());
|
|
||||||
}
|
|
||||||
let server =
|
|
||||||
in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test"));
|
|
||||||
let mut client = ClientProcess(
|
|
||||||
Command::new(dispatcher)
|
|
||||||
.args([
|
|
||||||
"client",
|
|
||||||
"--duration",
|
|
||||||
"100ms",
|
|
||||||
"--interval",
|
|
||||||
"20ms",
|
|
||||||
"--hmac",
|
|
||||||
"cli-test",
|
|
||||||
"--length",
|
|
||||||
"128",
|
|
||||||
"--sfill",
|
|
||||||
"pattern:ab",
|
|
||||||
"--clock",
|
|
||||||
"wall",
|
|
||||||
"--tstamp",
|
|
||||||
"both",
|
|
||||||
"--stats",
|
|
||||||
"both",
|
|
||||||
"--ttl",
|
|
||||||
"64",
|
|
||||||
"--format",
|
|
||||||
"jsonl",
|
|
||||||
"--columns",
|
|
||||||
"target,event,bytes",
|
|
||||||
&format!("edge={}", server.addr),
|
|
||||||
])
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
// Drain both pipes while waiting so output cannot block client termination.
|
|
||||||
let mut stdout = client.0.stdout.take().unwrap();
|
|
||||||
let stdout = thread::spawn(move || {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
stdout.read_to_end(&mut bytes).unwrap();
|
|
||||||
bytes
|
|
||||||
});
|
|
||||||
let mut stderr = client.0.stderr.take().unwrap();
|
|
||||||
let stderr = thread::spawn(move || {
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
stderr.read_to_end(&mut bytes).unwrap();
|
|
||||||
bytes
|
|
||||||
});
|
|
||||||
let status = client.wait();
|
|
||||||
let stdout = stdout.join().unwrap();
|
|
||||||
let stderr = stderr.join().unwrap();
|
|
||||||
assert!(status.success(), "{}", String::from_utf8_lossy(&stderr));
|
|
||||||
let stdout = String::from_utf8(stdout).unwrap();
|
|
||||||
let rows: Vec<_> = stdout.lines().collect();
|
|
||||||
assert!(!rows.is_empty());
|
|
||||||
assert!(rows.iter().all(|row| row.starts_with('{')
|
|
||||||
&& row.ends_with('}')
|
|
||||||
&& row.contains("\"target\":\"edge\"")));
|
|
||||||
assert!(rows
|
|
||||||
.iter()
|
|
||||||
.any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128")));
|
|
||||||
assert!(rows
|
|
||||||
.iter()
|
|
||||||
.any(|row| row.contains("\"event\":\"session_closed\"")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "client", feature = "tui"))]
|
|
||||||
#[test]
|
|
||||||
fn malformed_target_diagnostics_do_not_expose_authentication_keys() {
|
|
||||||
let secret = "malformed-target-secret-92817";
|
|
||||||
for applet in [
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
"client",
|
|
||||||
#[cfg(feature = "tui")]
|
|
||||||
"tui",
|
|
||||||
] {
|
|
||||||
let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
|
|
||||||
.args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")])
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(!output.status.success());
|
|
||||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
||||||
assert!(
|
|
||||||
!stderr.trim().is_empty(),
|
|
||||||
"malformed target must report a diagnostic"
|
|
||||||
);
|
|
||||||
assert!(!stderr.contains(secret), "target authentication key leaked");
|
|
||||||
assert!(!String::from_utf8_lossy(&output.stdout).contains(secret));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
#[test]
|
|
||||||
fn server_arguments_resolve_binds_and_map_policy() {
|
|
||||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
|
||||||
use irtt_app::cmd::server::ServerArgs;
|
|
||||||
use irtt_server::TimestampAllowance;
|
|
||||||
use std::time::Duration;
|
|
||||||
// Isolate zero-argument defaults from the test runner's environment without
|
|
||||||
// mutating process-global environment in this parallel test process.
|
|
||||||
let defaults = ServerArgs::from_arg_matches(
|
|
||||||
&ServerArgs::command()
|
|
||||||
.mut_args(|arg| arg.env(None::<&str>))
|
|
||||||
.try_get_matches_from(["irtt-server"])
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
defaults.resolve_binds(),
|
|
||||||
vec![
|
|
||||||
"[::]:2112".parse().unwrap(),
|
|
||||||
"0.0.0.0:2112".parse().unwrap()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
defaults.server_config(),
|
|
||||||
irtt_server::ServerConfig::default()
|
|
||||||
);
|
|
||||||
let args = ServerArgs::try_parse_from([
|
|
||||||
"irtt-server",
|
|
||||||
"--bind",
|
|
||||||
"127.0.0.1:2113",
|
|
||||||
"--bind",
|
|
||||||
"[::1]:2114",
|
|
||||||
"--hmac",
|
|
||||||
"server-cli-test",
|
|
||||||
"--max-sessions",
|
|
||||||
"3",
|
|
||||||
"--max-packet-length",
|
|
||||||
"96",
|
|
||||||
"--min-interval",
|
|
||||||
"25ms",
|
|
||||||
"--burst",
|
|
||||||
"2",
|
|
||||||
"--idle-timeout",
|
|
||||||
"8s",
|
|
||||||
"--max-duration",
|
|
||||||
"1m",
|
|
||||||
"--timestamp-allowance",
|
|
||||||
"single",
|
|
||||||
"--no-dscp",
|
|
||||||
])
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
args.resolve_binds(),
|
|
||||||
vec![
|
|
||||||
"127.0.0.1:2113".parse().unwrap(),
|
|
||||||
"[::1]:2114".parse().unwrap()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
let config = args.server_config();
|
|
||||||
assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice()));
|
|
||||||
assert_eq!(config.max_sessions(), 3);
|
|
||||||
assert_eq!(config.max_packet_length(), 96);
|
|
||||||
assert_eq!(config.min_send_interval(), Duration::from_millis(25));
|
|
||||||
assert_eq!(config.burst_allowance(), 2);
|
|
||||||
assert_eq!(config.idle_timeout(), Duration::from_secs(8));
|
|
||||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60)));
|
|
||||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
|
||||||
assert!(!config.dscp_allowed());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
#[test]
|
|
||||||
fn server_command_line_binds_replace_the_environment_bind() {
|
|
||||||
use std::net::UdpSocket;
|
|
||||||
// Occupied ports give bounded, observable startup failures, without starting
|
|
||||||
// a daemon or needing a process/signal harness merely to inspect arguments.
|
|
||||||
let environment = UdpSocket::bind("127.0.0.1:0").unwrap();
|
|
||||||
let explicit = UdpSocket::bind("127.0.0.1:0").unwrap();
|
|
||||||
let environment_addr = environment.local_addr().unwrap().to_string();
|
|
||||||
let explicit_addr = explicit.local_addr().unwrap().to_string();
|
|
||||||
for use_explicit in [false, true] {
|
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server"));
|
|
||||||
command
|
|
||||||
.env_clear()
|
|
||||||
.env("IRTT_SERVER_BIND", &environment_addr)
|
|
||||||
.env("IRTT_SERVER_MAX_SESSIONS", "invalid")
|
|
||||||
.args(["--max-sessions", "3"]);
|
|
||||||
if use_explicit {
|
|
||||||
command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]);
|
|
||||||
}
|
|
||||||
let output = command.output().unwrap();
|
|
||||||
assert!(!output.status.success());
|
|
||||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
|
||||||
let expected = if use_explicit {
|
|
||||||
&explicit_addr
|
|
||||||
} else {
|
|
||||||
&environment_addr
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
stderr.contains(expected),
|
|
||||||
"startup must attempt the selected bind: {stderr}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!stderr.contains("listening on"),
|
|
||||||
"partial startup must not announce listeners"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exercise stdin replacement and EOF through the actual application process.
|
|
||||||
#[cfg(feature = "client")]
|
|
||||||
#[test]
|
|
||||||
fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
|
|
||||||
use std::{
|
|
||||||
io::{BufRead, BufReader, Read, Write},
|
|
||||||
process::Stdio,
|
|
||||||
sync::mpsc,
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
|
||||||
let mut client = ClientProcess(
|
|
||||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
|
||||||
.env_clear()
|
|
||||||
.args([
|
|
||||||
"--duration",
|
|
||||||
"0",
|
|
||||||
"--targets-stdin",
|
|
||||||
"--interval",
|
|
||||||
"20ms",
|
|
||||||
"--format",
|
|
||||||
"jsonl",
|
|
||||||
"--columns",
|
|
||||||
"target,event",
|
|
||||||
])
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
let stdout = client.0.stdout.take().unwrap();
|
|
||||||
let (tx, rx) = mpsc::channel();
|
|
||||||
let reader = thread::spawn(move || {
|
|
||||||
for line in BufReader::new(stdout).lines() {
|
|
||||||
if tx.send(line.unwrap()).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let mut stdin = client.0.stdin.take().unwrap();
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
for target in ["first", "second"] {
|
|
||||||
writeln!(stdin, "{target}={}", server.addr).unwrap();
|
|
||||||
stdin.flush().unwrap();
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
loop {
|
|
||||||
let row = rx
|
|
||||||
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
|
||||||
.expect("stdin target set did not produce a reply");
|
|
||||||
let replied = row.contains(&format!("\"target\":\"{target}\""))
|
|
||||||
&& row.contains("\"event\":\"echo_reply\"");
|
|
||||||
rows.push(row);
|
|
||||||
if replied {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Retirement can finish after the newer target's first reply.
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
while !rows.iter().any(|row| {
|
|
||||||
row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"")
|
|
||||||
}) {
|
|
||||||
rows.push(
|
|
||||||
rx.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
|
||||||
.expect("superseded stdin target did not retire"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
drop(stdin);
|
|
||||||
let status = client.wait();
|
|
||||||
reader.join().unwrap();
|
|
||||||
rows.extend(rx.try_iter());
|
|
||||||
let mut stderr = String::new();
|
|
||||||
client
|
|
||||||
.0
|
|
||||||
.stderr
|
|
||||||
.take()
|
|
||||||
.unwrap()
|
|
||||||
.read_to_string(&mut stderr)
|
|
||||||
.unwrap();
|
|
||||||
assert!(status.success(), "{stderr}");
|
|
||||||
assert!(rows
|
|
||||||
.iter()
|
|
||||||
.any(|row| row.contains("\"target\":\"second\"")
|
|
||||||
&& row.contains("\"event\":\"session_closed\"")));
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
use std::{
|
|
||||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
|
||||||
sync::mpsc,
|
|
||||||
thread::{self, JoinHandle},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use irtt_server::{Server, ServerConfig};
|
|
||||||
use tokio::sync::oneshot;
|
|
||||||
|
|
||||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
|
|
||||||
|
|
||||||
pub struct InTreeServer {
|
|
||||||
pub addr: SocketAddr,
|
|
||||||
shutdown: Option<oneshot::Sender<()>>,
|
|
||||||
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InTreeServer {
|
|
||||||
pub fn start(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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-client"
|
name = "irtt-client"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
description = "IRTT-compatible Rust client library"
|
description = "IRTT-compatible Rust client library"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|||||||
@@ -59,9 +59,17 @@ Configuration is grouped by ownership:
|
|||||||
probe tracking policy.
|
probe tracking policy.
|
||||||
|
|
||||||
Clone one config to connect to multiple endpoints. Managed targets retain their
|
Clone one config to connect to multiple endpoints. Managed targets retain their
|
||||||
own ID, endpoint, and authentication inheritance/override; shared
|
own ID, endpoint, address-family override, and authentication inheritance/override; shared
|
||||||
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
`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`:
|
Blocking receive policy belongs to `Client`:
|
||||||
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
||||||
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
|
use crate::{AddressFamily, Authentication, ClientConfig, ClientError, ClientEvent};
|
||||||
|
|
||||||
use super::TargetId;
|
use super::TargetId;
|
||||||
|
|
||||||
@@ -48,11 +48,16 @@ impl TargetAuth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Endpoint and authentication specification for one managed target.
|
/// Endpoint, address-family and authentication specification for one managed target.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedTargetConfig {
|
pub struct ManagedTargetConfig {
|
||||||
pub id: TargetId,
|
pub id: TargetId,
|
||||||
pub server_addr: String,
|
pub server_addr: String,
|
||||||
|
/// 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,
|
pub auth: TargetAuth,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,7 @@ impl ManagedTargetConfig {
|
|||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
server_addr: server_addr.into(),
|
server_addr: server_addr.into(),
|
||||||
|
address_family: None,
|
||||||
auth: TargetAuth::Inherit,
|
auth: TargetAuth::Inherit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +92,7 @@ pub enum ManagedCompletionPolicy {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedClientConfig {
|
pub struct ManagedClientConfig {
|
||||||
/// Reusable session/socket configuration shared across target generations.
|
/// Reusable session/socket configuration shared across target generations.
|
||||||
/// Each target supplies its endpoint and resolves its authentication policy.
|
/// Each target supplies its endpoint and resolves its family and authentication policies.
|
||||||
pub client: ClientConfig,
|
pub client: ClientConfig,
|
||||||
pub pacing: ManagedPacing,
|
pub pacing: ManagedPacing,
|
||||||
pub completion: ManagedCompletionPolicy,
|
pub completion: ManagedCompletionPolicy,
|
||||||
@@ -155,6 +161,10 @@ pub struct ManagedTargetStatus {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct ManagedStatus {
|
pub struct ManagedStatus {
|
||||||
pub lifecycle: ManagedLifecycle,
|
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 stop_requested: bool,
|
||||||
pub applied_command_sequence: u64,
|
pub applied_command_sequence: u64,
|
||||||
pub desired_target_count: usize,
|
pub desired_target_count: usize,
|
||||||
@@ -171,9 +181,21 @@ pub struct ManagedStatus {
|
|||||||
pub discarded_target_outcomes: u64,
|
pub discarded_target_outcomes: u64,
|
||||||
pub targets: Arc<[ManagedTargetStatus]>,
|
pub targets: Arc<[ManagedTargetStatus]>,
|
||||||
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
|
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>>,
|
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.
|
/// Lossy presentation event emitted by the managed task.
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,32 @@ fn config() -> ManagedClientConfig {
|
|||||||
#[test]
|
#[test]
|
||||||
fn stop_resolves_every_accepted_update_and_closes_admission() {
|
fn stop_resolves_every_accepted_update_and_closes_admission() {
|
||||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||||
|
let mut subscription: ManagedStatusSubscription = handle.subscribe_status();
|
||||||
|
assert_eq!(
|
||||||
|
subscription.borrow().lifecycle,
|
||||||
|
ManagedLifecycle::NotStarted
|
||||||
|
);
|
||||||
|
assert!(!subscription.borrow().stop_requested);
|
||||||
|
assert!(subscription.borrow().final_outcome.is_none());
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let mut task = pin!(task);
|
||||||
|
{
|
||||||
|
let _runtime = runtime.enter();
|
||||||
|
assert!(task
|
||||||
|
.as_mut()
|
||||||
|
.poll(&mut Context::from_waker(Waker::noop()))
|
||||||
|
.is_pending());
|
||||||
|
}
|
||||||
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||||
|
subscription = handle.subscribe_status();
|
||||||
|
assert_eq!(subscription.borrow().lifecycle, ManagedLifecycle::Running);
|
||||||
|
assert!(pin!(subscription.changed())
|
||||||
|
.as_mut()
|
||||||
|
.poll(&mut Context::from_waker(Waker::noop()))
|
||||||
|
.is_pending());
|
||||||
let receipts: Vec<_> = (0..3)
|
let receipts: Vec<_> = (0..3)
|
||||||
.map(|index| {
|
.map(|index| {
|
||||||
handle
|
handle
|
||||||
@@ -35,15 +61,16 @@ fn stop_resolves_every_accepted_update_and_closes_admission() {
|
|||||||
Err(ManagedCommandError::QueueFull)
|
Err(ManagedCommandError::QueueFull)
|
||||||
));
|
));
|
||||||
|
|
||||||
let stop = handle.stop();
|
let mut stop = pin!(handle.stop());
|
||||||
|
assert!(stop
|
||||||
|
.as_mut()
|
||||||
|
.poll(&mut Context::from_waker(Waker::noop()))
|
||||||
|
.is_pending());
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
handle.update_targets(vec![]),
|
handle.update_targets(vec![]),
|
||||||
Err(ManagedCommandError::Stopping)
|
Err(ManagedCommandError::Stopping)
|
||||||
));
|
));
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
runtime.block_on(async {
|
runtime.block_on(async {
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
let outcome = task.await;
|
let outcome = task.await;
|
||||||
@@ -61,6 +88,16 @@ fn stop_resolves_every_accepted_update_and_closes_admission() {
|
|||||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||||
assert!(status.stop_requested);
|
assert!(status.stop_requested);
|
||||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||||
|
subscription.changed().await.unwrap();
|
||||||
|
assert!(std::sync::Arc::ptr_eq(
|
||||||
|
&subscription.borrow_and_update(),
|
||||||
|
&status
|
||||||
|
));
|
||||||
|
assert!(subscription.changed().await.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
subscription.borrow().final_outcome.as_deref(),
|
||||||
|
Some(&outcome)
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
handle.update_targets(vec![]),
|
handle.update_targets(vec![]),
|
||||||
Err(ManagedCommandError::DriverClosed)
|
Err(ManagedCommandError::DriverClosed)
|
||||||
@@ -103,12 +140,14 @@ fn failed_driver_resolves_every_accepted_update_with_its_terminal_failure() {
|
|||||||
}
|
}
|
||||||
let status = handle.status();
|
let status = handle.status();
|
||||||
assert_eq!(status.lifecycle, ManagedLifecycle::Failed);
|
assert_eq!(status.lifecycle, ManagedLifecycle::Failed);
|
||||||
|
assert!(!status.stop_requested);
|
||||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
handle.update_targets(vec![]),
|
handle.update_targets(vec![]),
|
||||||
Err(ManagedCommandError::DriverClosed)
|
Err(ManagedCommandError::DriverClosed)
|
||||||
));
|
));
|
||||||
assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready());
|
assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready());
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
|
|||||||
let status = handle.status();
|
let status = handle.status();
|
||||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||||
assert_eq!(status.final_outcome.as_ref(), Some(&outcome));
|
assert_eq!(status.final_outcome.as_ref(), Some(&outcome));
|
||||||
|
assert!(Arc::ptr_eq(
|
||||||
|
status.final_outcome.as_ref().unwrap(),
|
||||||
|
&outcome
|
||||||
|
));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -238,23 +238,50 @@ fn quiescent_rejects_empty_initial_targets() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_empty_waits_for_stop() {
|
fn explicit_empty_waits_for_stop() {
|
||||||
|
let server = start_server(ServerBehavior::Echo, None);
|
||||||
let mut config = config(ManagedPacing::Staggered);
|
let mut config = config(ManagedPacing::Staggered);
|
||||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||||
|
config.client.request.duration = None;
|
||||||
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
||||||
let outcome = runtime().block_on(async move {
|
let mut events = handle.subscribe().unwrap();
|
||||||
|
let outcome = runtime().block_on(async {
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
let mut task = Box::pin(task);
|
let mut task = Box::pin(task);
|
||||||
assert!(
|
assert!(poll_once(task.as_mut()).is_pending());
|
||||||
tokio::time::timeout(Duration::from_millis(20), task.as_mut())
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||||
let receipt = handle.stop();
|
let update = handle
|
||||||
let outcome = task.await;
|
.update_targets(vec![target("one", server.addr)])
|
||||||
receipt.await;
|
.unwrap();
|
||||||
outcome
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = task.as_mut() => panic!("explicit-stop task completed before stop"),
|
||||||
|
event = events.recv() => {
|
||||||
|
if matches!(event.unwrap(), ManagedEvent::Client {
|
||||||
|
event: irtt_client::ClientEvent::EchoReply { .. }, ..
|
||||||
|
}) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update.await.unwrap();
|
||||||
|
let mut receipt = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_pending());
|
||||||
|
assert!(poll_once(task.as_mut()).is_pending());
|
||||||
|
let status = handle.status();
|
||||||
|
assert_eq!(status.lifecycle, ManagedLifecycle::Stopping);
|
||||||
|
assert!(status.stop_requested);
|
||||||
|
assert!(status.final_outcome.is_none());
|
||||||
|
// The driver is paused during graceful cleanup: receipt readiness must
|
||||||
|
// come from durable stop observation, not eventual task completion.
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||||
|
task.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("stop observation and cleanup must complete")
|
||||||
});
|
});
|
||||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||||
|
assert!(has_close(&server.finish()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -311,6 +338,8 @@ fn pre_poll_stop() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut receipt = Box::pin(handle.stop());
|
let mut receipt = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_pending());
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
let mut task = Box::pin(task);
|
let mut task = Box::pin(task);
|
||||||
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||||
panic!("pre-stopped task did not complete")
|
panic!("pre-stopped task did not complete")
|
||||||
@@ -469,6 +498,10 @@ fn event_loss_independence() {
|
|||||||
assert_eq!(outcome.successful_target_outcomes, 1);
|
assert_eq!(outcome.successful_target_outcomes, 1);
|
||||||
let status = handle.status();
|
let status = handle.status();
|
||||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||||
|
assert!(!status.stop_requested);
|
||||||
|
let mut receipt = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||||
|
assert!(Arc::ptr_eq(&status, &handle.status()));
|
||||||
assert_eq!(status.targets.len(), 1);
|
assert_eq!(status.targets.len(), 1);
|
||||||
let target = &status.targets[0];
|
let target = &status.targets[0];
|
||||||
assert!(target.desired);
|
assert!(target.desired);
|
||||||
@@ -511,16 +544,32 @@ fn dropping_all_handles_does_not_stop() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_drop_abandonment() {
|
fn task_drop_abandonment() {
|
||||||
|
// A latched but unobserved stop must not turn abandonment into completion.
|
||||||
|
for request_stop in [false, true] {
|
||||||
let (task, handle) = ManagedClient::task(
|
let (task, handle) = ManagedClient::task(
|
||||||
config(ManagedPacing::Staggered),
|
config(ManagedPacing::Staggered),
|
||||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut events = handle.subscribe().unwrap();
|
let mut events = handle.subscribe().unwrap();
|
||||||
|
let mut receipt = request_stop.then(|| Box::pin(handle.stop()));
|
||||||
|
if let Some(receipt) = &mut receipt {
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_pending());
|
||||||
|
}
|
||||||
drop(task);
|
drop(task);
|
||||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
||||||
assert_eq!(handle.status().total_target_outcomes, 0);
|
assert_eq!(handle.status().total_target_outcomes, 0);
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
|
assert!(handle.status().final_outcome.is_none());
|
||||||
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
|
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
|
||||||
|
if let Some(receipt) = &mut receipt {
|
||||||
|
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||||
|
}
|
||||||
|
let mut late_stop = Box::pin(handle.stop());
|
||||||
|
assert!(poll_once(late_stop.as_mut()).is_ready());
|
||||||
|
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
||||||
|
assert!(!handle.status().stop_requested);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -304,172 +304,3 @@ fn read_i64(packet: &[u8], pos: &mut usize) -> i64 {
|
|||||||
*pos += TIMESTAMP_SIZE;
|
*pos += TIMESTAMP_SIZE;
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
hmac,
|
|
||||||
params::{Clock, ReceivedStats, StampAt},
|
|
||||||
write_header, FLAG_HMAC, HMAC_SIZE, RECV_COUNT_SIZE, RECV_WINDOW_SIZE,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn default_params() -> Params {
|
|
||||||
Params {
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn params_with_payload_space(payload_space: usize) -> Params {
|
|
||||||
let header_len = PacketLayout::echo(false, &Params::default()).header_len();
|
|
||||||
Params {
|
|
||||||
length: (header_len + payload_space) as i64,
|
|
||||||
..Params::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zero-fills every negotiated reply field, matching what a peer that
|
|
||||||
/// reports no optional values would put on the wire.
|
|
||||||
fn push_zeroed_layout_tail(layout: PacketLayout, out: &mut Vec<u8>) {
|
|
||||||
if layout.recv_count {
|
|
||||||
out.extend_from_slice(&[0; RECV_COUNT_SIZE]);
|
|
||||||
}
|
|
||||||
if layout.recv_window {
|
|
||||||
out.extend_from_slice(&[0; RECV_WINDOW_SIZE]);
|
|
||||||
}
|
|
||||||
for _ in 0..layout.timestamp_count() {
|
|
||||||
out.extend_from_slice(&[0; TIMESTAMP_SIZE]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_decodes_default_fields() {
|
|
||||||
let packet = [
|
|
||||||
0x14, 0xa7, 0x5b, 0x02, 0x13, 0x52, 0x71, 0x87, 0xab, 0xb6, 0x96, 0x78, 0x02, 0x00,
|
|
||||||
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0xb8, 0x1a, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xde, 0x26, 0x35, 0x95, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x80, 0x4d, 0x33, 0x0c, 0x86, 0x6d, 0xaa, 0x18, 0xb2, 0x57, 0x35, 0x95,
|
|
||||||
0x00, 0x00, 0x00, 0x00,
|
|
||||||
];
|
|
||||||
let reply = decode_echo_reply(&packet, &default_params(), None).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.sequence, 2);
|
|
||||||
assert_eq!(reply.recv_count, Some(3));
|
|
||||||
assert_eq!(reply.recv_window, Some(7));
|
|
||||||
assert!(reply.timestamps.recv_wall.is_some());
|
|
||||||
assert!(reply.timestamps.midpoint_wall.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_decodes_exact_negotiated_length_with_payload() {
|
|
||||||
let params = params_with_payload_space(4);
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
||||||
|
|
||||||
let reply = decode_echo_reply(&packet, ¶ms, None).unwrap();
|
|
||||||
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_rejects_shorter_than_negotiated_length() {
|
|
||||||
let params = params_with_payload_space(4);
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_echo_reply(&packet, ¶ms, None),
|
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
|
||||||
expected: 20,
|
|
||||||
actual: 16,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_reply_rejects_longer_than_negotiated_length() {
|
|
||||||
let params = Params::default();
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
packet.push(0);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_echo_reply(&packet, ¶ms, None),
|
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
|
||||||
expected: 16,
|
|
||||||
actual: 17,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_echo_reply_decodes_default_fields_after_hmac() {
|
|
||||||
let params = default_params();
|
|
||||||
let layout = PacketLayout::echo(true, ¶ms);
|
|
||||||
let mut packet = Vec::with_capacity(layout.header_len());
|
|
||||||
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
push_zeroed_layout_tail(layout, &mut packet);
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(packet.len(), 76);
|
|
||||||
let reply = decode_echo_reply(&packet, ¶ms, Some(b"testkey")).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.sequence, 2);
|
|
||||||
assert_eq!(reply.recv_count, Some(0));
|
|
||||||
assert_eq!(reply.recv_window, Some(0));
|
|
||||||
assert_eq!(reply.payload.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_echo_reply_decodes_exact_negotiated_length_with_payload() {
|
|
||||||
let mut params = Params {
|
|
||||||
length: 48,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
params.received_stats = ReceivedStats::Both;
|
|
||||||
let layout = PacketLayout::echo(true, ¶ms);
|
|
||||||
let mut packet = Vec::with_capacity(echo_packet_len(true, ¶ms).unwrap());
|
|
||||||
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
push_zeroed_layout_tail(layout, &mut packet);
|
|
||||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
let reply = decode_echo_reply(&packet, ¶ms, Some(b"testkey")).unwrap();
|
|
||||||
assert_eq!(reply.payload, vec![1, 2, 3, 4]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_echo_reply_rejects_length_mismatch_before_hmac_verification() {
|
|
||||||
let params = Params::default();
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
write_header(&mut packet, FLAG_REPLY | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&2_u32.to_le_bytes());
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
packet.push(0);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_echo_reply(&packet, ¶ms, Some(b"testkey")),
|
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
|
||||||
expected: 32,
|
|
||||||
actual: 33,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -136,101 +136,3 @@ fn with_hmac_flag(flags: u8, authenticated: bool) -> u8 {
|
|||||||
flags & !FLAG_HMAC
|
flags & !FLAG_HMAC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
flags::{FLAG_OPEN, FLAG_REPLY},
|
|
||||||
MAGIC,
|
|
||||||
};
|
|
||||||
|
|
||||||
const KEY: &[u8] = b"testkey";
|
|
||||||
const OPEN_REQUEST_RULES: &[FlagRule] =
|
|
||||||
&[FlagRule::Require(FLAG_OPEN), FlagRule::Reject(FLAG_REPLY)];
|
|
||||||
|
|
||||||
fn header(flags: u8) -> Vec<u8> {
|
|
||||||
let mut packet = MAGIC.to_vec();
|
|
||||||
packet.push(flags);
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_bad_magic_and_reserved_flags() {
|
|
||||||
assert_eq!(
|
|
||||||
decode(&[0, MAGIC[1], MAGIC[2], FLAG_OPEN], None, &[]),
|
|
||||||
Err(ProtoError::BadMagic)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(FLAG_OPEN | 0x10), None, &[]),
|
|
||||||
Err(ProtoError::ReservedFlags(0x10))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_enforces_required_and_rejected_flags() {
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(0), None, OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::MissingFlag(FLAG_OPEN))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(FLAG_OPEN | FLAG_REPLY), None, OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rejects_hmac_presence_mismatch() {
|
|
||||||
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
|
|
||||||
authenticated.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&authenticated, None, OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::HmacPresenceMismatch)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
decode(&header(FLAG_OPEN), Some(KEY), OPEN_REQUEST_RULES),
|
|
||||||
Err(ProtoError::HmacPresenceMismatch)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn structural_decode_reports_hmac_presence_without_a_key() {
|
|
||||||
let mut authenticated = header(FLAG_OPEN | FLAG_HMAC);
|
|
||||||
authenticated.extend_from_slice(&[0xff; HMAC_SIZE]);
|
|
||||||
|
|
||||||
let envelope = decode_structural(&authenticated).unwrap();
|
|
||||||
assert!(envelope.hmac_present);
|
|
||||||
assert_eq!(envelope.body_offset, HEADER_SIZE + HMAC_SIZE);
|
|
||||||
|
|
||||||
let envelope = decode_structural(&header(FLAG_OPEN)).unwrap();
|
|
||||||
assert!(!envelope.hmac_present);
|
|
||||||
assert_eq!(envelope.body_offset, HEADER_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_rejects_bad_hmac() {
|
|
||||||
let mut packet = begin_checked(FLAG_OPEN, Some(KEY), OPEN_REQUEST_RULES, 22).unwrap();
|
|
||||||
packet.extend_from_slice(&[1, 2]);
|
|
||||||
let mut packet = finish(packet, Some(KEY)).unwrap();
|
|
||||||
*packet.last_mut().unwrap() ^= 0x80;
|
|
||||||
|
|
||||||
decode(&packet, Some(KEY), OPEN_REQUEST_RULES).unwrap();
|
|
||||||
assert_eq!(verify(&packet, Some(KEY)), Err(ProtoError::BadHmac));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoder_key_authoritatively_normalizes_hmac_flag() {
|
|
||||||
let plain =
|
|
||||||
begin_checked(FLAG_OPEN | FLAG_HMAC, None, OPEN_REQUEST_RULES, HEADER_SIZE).unwrap();
|
|
||||||
assert_eq!(plain[3], FLAG_OPEN);
|
|
||||||
|
|
||||||
let authenticated = begin_checked(
|
|
||||||
FLAG_OPEN,
|
|
||||||
Some(KEY),
|
|
||||||
OPEN_REQUEST_RULES,
|
|
||||||
HEADER_SIZE + HMAC_SIZE,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(authenticated[3], FLAG_OPEN | FLAG_HMAC);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -96,76 +96,3 @@ pub fn verify_packet_hmac(key: &[u8], packet: &[u8]) -> Result<()> {
|
|||||||
pub(crate) fn hmac_offset() -> usize {
|
pub(crate) fn hmac_offset() -> usize {
|
||||||
HEADER_SIZE
|
HEADER_SIZE
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
flags::{FLAG_HMAC, FLAG_OPEN},
|
|
||||||
MAGIC,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn reference_copy_zero_hmac(
|
|
||||||
key: &[u8],
|
|
||||||
packet: &[u8],
|
|
||||||
hmac_offset: usize,
|
|
||||||
) -> Result<[u8; HMAC_SIZE]> {
|
|
||||||
if packet.len().saturating_sub(hmac_offset) < HMAC_SIZE {
|
|
||||||
return Err(ProtoError::InvalidHmacOffset);
|
|
||||||
}
|
|
||||||
let mut copy = packet.to_vec();
|
|
||||||
copy[hmac_offset..hmac_offset + HMAC_SIZE].fill(0);
|
|
||||||
let mut mac = HmacMd5::new_from_slice(key).expect("HMAC accepts keys of any size");
|
|
||||||
mac.update(©);
|
|
||||||
let bytes = mac.finalize().into_bytes();
|
|
||||||
let mut out = [0u8; HMAC_SIZE];
|
|
||||||
out.copy_from_slice(&bytes);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn packet_with_hmac_field(hmac_bytes: [u8; HMAC_SIZE]) -> Vec<u8> {
|
|
||||||
let mut packet = Vec::new();
|
|
||||||
packet.extend_from_slice(&MAGIC);
|
|
||||||
packet.push(FLAG_OPEN | FLAG_HMAC);
|
|
||||||
packet.extend_from_slice(&hmac_bytes);
|
|
||||||
packet.extend_from_slice(&[1, 2, 3, 4]);
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_and_verify_hmac() {
|
|
||||||
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
|
|
||||||
|
|
||||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
|
||||||
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
verify_hmac(b"wrong", &packet, hmac_offset()),
|
|
||||||
Err(ProtoError::BadHmac)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_hmac_matches_copy_and_zero_semantics() {
|
|
||||||
let mut packet = packet_with_hmac_field([0xa5; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&[5, 6, 7, 8, 9]);
|
|
||||||
|
|
||||||
let digest = compute_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
let reference = reference_copy_zero_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(digest, reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verify_hmac_rejects_modified_authenticated_byte() {
|
|
||||||
let mut packet = packet_with_hmac_field([0; HMAC_SIZE]);
|
|
||||||
compute_hmac_in_place(b"testkey", &mut packet, hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
verify_hmac(b"testkey", &packet, hmac_offset()).unwrap();
|
|
||||||
packet[hmac_offset() + HMAC_SIZE + 1] ^= 0x01;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
verify_hmac(b"testkey", &packet, hmac_offset()),
|
|
||||||
Err(ProtoError::BadHmac)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -162,194 +162,3 @@ pub fn echo_packet_len(hmac: bool, params: &Params) -> Result<usize> {
|
|||||||
};
|
};
|
||||||
Ok(header_len.max(requested))
|
Ok(header_len.max(requested))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::params::{Clock, ReceivedStats, StampAt};
|
|
||||||
|
|
||||||
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
|
|
||||||
Params {
|
|
||||||
received_stats: stats,
|
|
||||||
stamp_at,
|
|
||||||
clock,
|
|
||||||
..Params::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
|
|
||||||
let stats_len = match stats {
|
|
||||||
ReceivedStats::None => 0,
|
|
||||||
ReceivedStats::Count => RECV_COUNT_SIZE,
|
|
||||||
ReceivedStats::Window => RECV_WINDOW_SIZE,
|
|
||||||
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
|
|
||||||
};
|
|
||||||
let clock_count = match clock {
|
|
||||||
Clock::Unspecified => 0,
|
|
||||||
Clock::Wall | Clock::Monotonic => 1,
|
|
||||||
Clock::Both => 2,
|
|
||||||
};
|
|
||||||
let timestamp_groups = match stamp_at {
|
|
||||||
StampAt::None => 0,
|
|
||||||
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
|
|
||||||
StampAt::Both => 2,
|
|
||||||
};
|
|
||||||
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verified_layout_lengths() {
|
|
||||||
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
|
|
||||||
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
|
|
||||||
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
|
|
||||||
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
|
|
||||||
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
|
|
||||||
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::None, StampAt::None, Clock::Both)
|
|
||||||
),
|
|
||||||
16
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Count, StampAt::Send, Clock::Wall)
|
|
||||||
),
|
|
||||||
28
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Window, StampAt::Receive, Clock::Monotonic)
|
|
||||||
),
|
|
||||||
32
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Both, StampAt::Midpoint, Clock::Both)
|
|
||||||
),
|
|
||||||
44
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
false,
|
|
||||||
¶ms(ReceivedStats::Both, StampAt::Both, Clock::Both)
|
|
||||||
),
|
|
||||||
60
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(
|
|
||||||
true,
|
|
||||||
¶ms(ReceivedStats::Both, StampAt::Both, Clock::Both)
|
|
||||||
),
|
|
||||||
76
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
|
|
||||||
for stats in [
|
|
||||||
ReceivedStats::None,
|
|
||||||
ReceivedStats::Count,
|
|
||||||
ReceivedStats::Window,
|
|
||||||
ReceivedStats::Both,
|
|
||||||
] {
|
|
||||||
for stamp_at in [
|
|
||||||
StampAt::None,
|
|
||||||
StampAt::Send,
|
|
||||||
StampAt::Receive,
|
|
||||||
StampAt::Both,
|
|
||||||
StampAt::Midpoint,
|
|
||||||
] {
|
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
|
||||||
for hmac in [false, true] {
|
|
||||||
let params = params(stats, stamp_at, clock);
|
|
||||||
let layout = PacketLayout::echo(hmac, ¶ms);
|
|
||||||
let expected_len = HEADER_SIZE
|
|
||||||
+ TOKEN_SIZE
|
|
||||||
+ SEQ_SIZE
|
|
||||||
+ if hmac { HMAC_SIZE } else { 0 }
|
|
||||||
+ expected_optional_len(stats, stamp_at, clock);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
layout.header_len(),
|
|
||||||
expected_len,
|
|
||||||
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
|
|
||||||
);
|
|
||||||
assert_eq!(layout.recv_count, stats.has_count());
|
|
||||||
assert_eq!(layout.recv_window, stats.has_window());
|
|
||||||
assert_eq!(
|
|
||||||
layout.recv_wall,
|
|
||||||
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
|
||||||
&& clock.has_wall()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.recv_mono,
|
|
||||||
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
|
||||||
&& clock.has_mono()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.midpoint_wall,
|
|
||||||
matches!(stamp_at, StampAt::Midpoint) && clock.has_wall()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.midpoint_mono,
|
|
||||||
matches!(stamp_at, StampAt::Midpoint) && clock.has_mono()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.send_wall,
|
|
||||||
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_wall()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
layout.send_mono,
|
|
||||||
matches!(stamp_at, StampAt::Send | StampAt::Both) && clock.has_mono()
|
|
||||||
);
|
|
||||||
|
|
||||||
if hmac {
|
|
||||||
assert_eq!(
|
|
||||||
echo_header_len(true, ¶ms) - echo_header_len(false, ¶ms),
|
|
||||||
HMAC_SIZE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn negotiated_length_is_floored_at_the_required_field_block() {
|
|
||||||
// The 60-byte header of this layout is the floor. A negative negotiated
|
|
||||||
// length is not an error: it asks for nothing beyond the mandatory
|
|
||||||
// fields, exactly as zero does.
|
|
||||||
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
|
||||||
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
|
|
||||||
p.length = length;
|
|
||||||
assert_eq!(
|
|
||||||
echo_packet_len(false, &p),
|
|
||||||
Ok(expected),
|
|
||||||
"unexpected packet length for negotiated length {length}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A positive length wider than `usize` must stay an error rather than
|
|
||||||
/// becoming a saturated buffer size. Only reachable where `usize` is
|
|
||||||
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
|
|
||||||
#[cfg(target_pointer_width = "32")]
|
|
||||||
#[test]
|
|
||||||
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
|
|
||||||
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
|
||||||
p.length = 5_000_000_000;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
echo_packet_len(false, &p),
|
|
||||||
Err(ProtoError::PacketLengthUnrepresentable {
|
|
||||||
length: 5_000_000_000
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -98,53 +98,3 @@ pub fn decode_open_reply(packet: &[u8], hmac_key: Option<&[u8]>) -> Result<OpenR
|
|||||||
params: Params::decode(&packet[pos..])?,
|
params: Params::decode(&packet[pos..])?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
hmac,
|
|
||||||
params::{Clock, ReceivedStats, StampAt},
|
|
||||||
FLAG_HMAC, HMAC_SIZE,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn default_params() -> Params {
|
|
||||||
Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_reply_decodes_token() {
|
|
||||||
let mut packet = vec![0x14, 0xa7, 0x5b, 0x03];
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&default_params().encode());
|
|
||||||
|
|
||||||
let reply = decode_open_reply(&packet, None).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.params, default_params());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hmac_open_reply_decodes_token_after_hmac_field() {
|
|
||||||
let mut packet = vec![0x14, 0xa7, 0x5b, FLAG_OPEN | FLAG_REPLY | FLAG_HMAC];
|
|
||||||
packet.extend_from_slice(&[0; HMAC_SIZE]);
|
|
||||||
packet.extend_from_slice(&0x7896_b6ab_8771_5213u64.to_le_bytes());
|
|
||||||
packet.extend_from_slice(&default_params().encode());
|
|
||||||
hmac::compute_hmac_in_place(b"testkey", &mut packet, hmac::hmac_offset()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
packet.len(),
|
|
||||||
4 + HMAC_SIZE + TOKEN_SIZE + default_params().encode().len()
|
|
||||||
);
|
|
||||||
let reply = decode_open_reply(&packet, Some(b"testkey")).unwrap();
|
|
||||||
assert_eq!(reply.token, 0x7896_b6ab_8771_5213);
|
|
||||||
assert_eq!(reply.params, default_params());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -368,773 +368,3 @@ fn read_int(input: &[u8], pos: &mut usize) -> Result<i64> {
|
|||||||
*pos += used;
|
*pos += used;
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn assert_round_trip(params: Params) {
|
|
||||||
assert_eq!(Params::decode(¶ms.encode()), Ok(params));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_server_fill_value(value: &[u8]) -> Vec<u8> {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(9, &mut encoded);
|
|
||||||
varint::encode_uvarint(value.len() as u64, &mut encoded);
|
|
||||||
encoded.extend_from_slice(value);
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_int(tag: u64, value: i64) -> Vec<u8> {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(tag, &mut encoded);
|
|
||||||
varint::encode_varint(value, &mut encoded);
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 3_000_000_000,
|
|
||||||
interval_ns: 1_000_000_000,
|
|
||||||
length: 1472,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
dscp: 184,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "pattern:abc".to_owned(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
assert_round_trip(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip_negotiated_option_modes() {
|
|
||||||
for received_stats in [
|
|
||||||
ReceivedStats::None,
|
|
||||||
ReceivedStats::Count,
|
|
||||||
ReceivedStats::Window,
|
|
||||||
ReceivedStats::Both,
|
|
||||||
] {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
received_stats,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for stamp_at in [
|
|
||||||
StampAt::None,
|
|
||||||
StampAt::Send,
|
|
||||||
StampAt::Receive,
|
|
||||||
StampAt::Both,
|
|
||||||
StampAt::Midpoint,
|
|
||||||
] {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
stamp_at,
|
|
||||||
clock: Clock::Both,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn params_round_trip_dscp_values_are_encoded_without_shifting() {
|
|
||||||
// `Params::dscp` is a raw wire byte; encode/decode carry whatever
|
|
||||||
// value is set without interpreting or shifting it, regardless of
|
|
||||||
// whether it happens to look like a codepoint (46) or a raw TOS byte
|
|
||||||
// (184).
|
|
||||||
for dscp in [0, 46, 63, 64, 184, -1] {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
dscp,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
assert_round_trip(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
dscp: 46,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let encoded = params.encode();
|
|
||||||
assert!(
|
|
||||||
encoded.windows(2).any(|bytes| bytes == [8, 92]),
|
|
||||||
"dscp value 46 must be encoded as param value 46"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!encoded.windows(3).any(|bytes| bytes == [8, 0xf0, 0x02]),
|
|
||||||
"Params::encode must not shift dscp 46 to 184; any codepoint shift is a caller concern"
|
|
||||||
);
|
|
||||||
assert_eq!(Params::decode(&encoded).unwrap().dscp, 46);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_absent_short_and_max_length_round_trip() {
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: None,
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "rand".to_owned(),
|
|
||||||
}),
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
assert_round_trip(Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "0123456789abcdef0123456789abcdef".to_owned(),
|
|
||||||
}),
|
|
||||||
..Params::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_decode_accepts_max_length() {
|
|
||||||
let value = b"0123456789abcdef0123456789abcdef";
|
|
||||||
let params = Params::decode(&encode_server_fill_value(value)).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
params.server_fill,
|
|
||||||
Some(ServerFill {
|
|
||||||
value: "0123456789abcdef0123456789abcdef".to_owned(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_decode_rejects_oversized_length() {
|
|
||||||
let value = b"0123456789abcdef0123456789abcdefx";
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encode_server_fill_value(value)),
|
|
||||||
Err(ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn server_fill_tag_and_length_are_encoded_before_utf8_bytes() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "rand".to_owned(),
|
|
||||||
}),
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let encoded = params.encode();
|
|
||||||
assert!(encoded
|
|
||||||
.windows(6)
|
|
||||||
.any(|bytes| bytes == [9, 4, b'r', b'a', b'n', b'd']));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_tags_are_ignored() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(99, &mut encoded);
|
|
||||||
varint::encode_varint(123, &mut encoded);
|
|
||||||
varint::encode_uvarint(1, &mut encoded);
|
|
||||||
varint::encode_varint(1, &mut encoded);
|
|
||||||
|
|
||||||
let params = Params::decode(&encoded).unwrap();
|
|
||||||
assert_eq!(params.protocol_version, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_received_stats_value_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(5, &mut encoded);
|
|
||||||
varint::encode_varint(4, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "ReceivedStats",
|
|
||||||
value: 4,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_timestamp_value_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(6, &mut encoded);
|
|
||||||
varint::encode_varint(5, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "StampAt",
|
|
||||||
value: 5,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn explicit_clock_zero_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(7, &mut encoded);
|
|
||||||
varint::encode_varint(0, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "Clock",
|
|
||||||
value: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode_with_presence(&encoded),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "Clock",
|
|
||||||
value: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn explicit_clock_values_one_to_three_are_accepted() {
|
|
||||||
for (value, expected) in [(1, Clock::Wall), (2, Clock::Monotonic), (3, Clock::Both)] {
|
|
||||||
let decoded = Params::decode_with_presence(&encode_int(7, value)).unwrap();
|
|
||||||
assert_eq!(decoded.params.clock, expected);
|
|
||||||
assert!(decoded.presence.clock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_payload_decodes_to_wire_defaults_with_nothing_present() {
|
|
||||||
let decoded = Params::decode_with_presence(&[]).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(decoded.params, Params::default());
|
|
||||||
assert_eq!(decoded.params.protocol_version, 0);
|
|
||||||
assert_eq!(decoded.params.duration_ns, 0);
|
|
||||||
assert_eq!(decoded.params.interval_ns, 0);
|
|
||||||
assert_eq!(decoded.params.length, 0);
|
|
||||||
assert_eq!(decoded.params.dscp, 0);
|
|
||||||
assert_eq!(decoded.params.received_stats, ReceivedStats::None);
|
|
||||||
assert_eq!(decoded.params.stamp_at, StampAt::None);
|
|
||||||
// The wire default clock is zero, not `Both`.
|
|
||||||
assert_eq!(decoded.params.clock, Clock::Unspecified);
|
|
||||||
assert!(!decoded.params.clock.has_wall());
|
|
||||||
assert!(!decoded.params.clock.has_mono());
|
|
||||||
assert_eq!(decoded.params.server_fill, None);
|
|
||||||
|
|
||||||
assert_eq!(decoded.presence, ParamPresence::default());
|
|
||||||
assert_eq!(
|
|
||||||
decoded.presence,
|
|
||||||
ParamPresence {
|
|
||||||
protocol_version: false,
|
|
||||||
duration_ns: false,
|
|
||||||
interval_ns: false,
|
|
||||||
length: false,
|
|
||||||
received_stats: false,
|
|
||||||
stamp_at: false,
|
|
||||||
clock: false,
|
|
||||||
dscp: false,
|
|
||||||
server_fill: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn absent_and_explicitly_zero_parameters_share_a_value_but_not_a_presence() {
|
|
||||||
// Duration and Interval are the pair a server must tell apart: absent is
|
|
||||||
// accepted, explicitly zero is not.
|
|
||||||
let absent = Params::decode_with_presence(&encode_int(1, 1)).unwrap();
|
|
||||||
assert_eq!(absent.params.duration_ns, 0);
|
|
||||||
assert!(!absent.presence.duration_ns);
|
|
||||||
assert_eq!(absent.params.interval_ns, 0);
|
|
||||||
assert!(!absent.presence.interval_ns);
|
|
||||||
assert_eq!(absent.params.clock, Clock::Unspecified);
|
|
||||||
assert!(!absent.presence.clock);
|
|
||||||
|
|
||||||
let explicit_duration = Params::decode_with_presence(&encode_int(2, 0)).unwrap();
|
|
||||||
assert_eq!(explicit_duration.params.duration_ns, 0);
|
|
||||||
assert!(explicit_duration.presence.duration_ns);
|
|
||||||
|
|
||||||
let explicit_interval = Params::decode_with_presence(&encode_int(3, 0)).unwrap();
|
|
||||||
assert_eq!(explicit_interval.params.interval_ns, 0);
|
|
||||||
assert!(explicit_interval.presence.interval_ns);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_known_tag_reports_its_own_presence() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
for (tag, value) in [
|
|
||||||
(1, 1),
|
|
||||||
(2, 5),
|
|
||||||
(3, 6),
|
|
||||||
(4, 7),
|
|
||||||
(5, 3),
|
|
||||||
(6, 4),
|
|
||||||
(7, 3),
|
|
||||||
(8, 184),
|
|
||||||
] {
|
|
||||||
encoded.extend_from_slice(&encode_int(tag, value));
|
|
||||||
}
|
|
||||||
encoded.extend_from_slice(&encode_server_fill_value(b"rand"));
|
|
||||||
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
decoded.presence,
|
|
||||||
ParamPresence {
|
|
||||||
protocol_version: true,
|
|
||||||
duration_ns: true,
|
|
||||||
interval_ns: true,
|
|
||||||
length: true,
|
|
||||||
received_stats: true,
|
|
||||||
stamp_at: true,
|
|
||||||
clock: true,
|
|
||||||
dscp: true,
|
|
||||||
server_fill: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unspecified_clock_is_encoded_by_omission() {
|
|
||||||
let params = Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
clock: Clock::Unspecified,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let encoded = params.encode();
|
|
||||||
|
|
||||||
assert_eq!(encoded, encode_int(1, 1));
|
|
||||||
assert!(
|
|
||||||
!encoded.windows(2).any(|bytes| bytes[0] == 7),
|
|
||||||
"an unspecified clock must omit the tag rather than encode an explicit zero"
|
|
||||||
);
|
|
||||||
assert_round_trip(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_and_decode_with_presence_agree() {
|
|
||||||
let mut payloads = vec![
|
|
||||||
Vec::new(),
|
|
||||||
encode_int(1, 1),
|
|
||||||
encode_int(2, 0),
|
|
||||||
encode_server_fill_value(b"rand"),
|
|
||||||
];
|
|
||||||
payloads.push(
|
|
||||||
Params {
|
|
||||||
protocol_version: 1,
|
|
||||||
duration_ns: 3_000_000_000,
|
|
||||||
interval_ns: 1_000_000_000,
|
|
||||||
length: 1472,
|
|
||||||
received_stats: ReceivedStats::Both,
|
|
||||||
stamp_at: StampAt::Both,
|
|
||||||
clock: Clock::Both,
|
|
||||||
dscp: 184,
|
|
||||||
server_fill: Some(ServerFill {
|
|
||||||
value: "rand".to_owned(),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
.encode(),
|
|
||||||
);
|
|
||||||
// Malformed payloads must fail identically through both entry points.
|
|
||||||
payloads.push(vec![1, 0x80]);
|
|
||||||
payloads.push(encode_int(7, 0));
|
|
||||||
|
|
||||||
for payload in payloads {
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&payload),
|
|
||||||
Params::decode_with_presence(&payload).map(|decoded| decoded.params),
|
|
||||||
"decode and decode_with_presence disagreed on {payload:02x?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn malformed_server_fill_is_rejected() {
|
|
||||||
let encoded = [9, 4, b'a', b'b'];
|
|
||||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::MalformedParams));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_utf8_server_fill_is_rejected() {
|
|
||||||
let encoded = [9, 1, 0xff];
|
|
||||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::InvalidUtf8));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_pointer_width = "32")]
|
|
||||||
#[test]
|
|
||||||
fn server_fill_length_too_large_for_usize_is_rejected() {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
varint::encode_uvarint(9, &mut encoded);
|
|
||||||
varint::encode_uvarint(u64::from(u32::MAX) + 1, &mut encoded);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode(&encoded),
|
|
||||||
Err(ProtoError::ParameterLengthTooLarge {
|
|
||||||
tag: 9,
|
|
||||||
length: u64::from(u32::MAX) + 1,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncated_varint_parameter_is_rejected() {
|
|
||||||
let encoded = [1, 0x80];
|
|
||||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn truncated_unknown_parameter_value_is_rejected() {
|
|
||||||
let encoded = [99];
|
|
||||||
assert_eq!(Params::decode(&encoded), Err(ProtoError::TruncatedVarint));
|
|
||||||
}
|
|
||||||
|
|
||||||
mod properties {
|
|
||||||
use super::*;
|
|
||||||
use proptest::prelude::*;
|
|
||||||
|
|
||||||
fn received_stats_strategy() -> impl Strategy<Value = ReceivedStats> {
|
|
||||||
prop_oneof![
|
|
||||||
Just(ReceivedStats::None),
|
|
||||||
Just(ReceivedStats::Count),
|
|
||||||
Just(ReceivedStats::Window),
|
|
||||||
Just(ReceivedStats::Both),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stamp_at_strategy() -> impl Strategy<Value = StampAt> {
|
|
||||||
prop_oneof![
|
|
||||||
Just(StampAt::None),
|
|
||||||
Just(StampAt::Send),
|
|
||||||
Just(StampAt::Receive),
|
|
||||||
Just(StampAt::Both),
|
|
||||||
Just(StampAt::Midpoint),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Clock` valid for direct construction, including `Unspecified`
|
|
||||||
/// (wire omission). Never produces an explicit zero on the wire; see
|
|
||||||
/// `explicit_clock_strategy` for that.
|
|
||||||
fn clock_strategy() -> impl Strategy<Value = Clock> {
|
|
||||||
prop_oneof![
|
|
||||||
Just(Clock::Unspecified),
|
|
||||||
Just(Clock::Wall),
|
|
||||||
Just(Clock::Monotonic),
|
|
||||||
Just(Clock::Both),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `Clock` value valid to encode *explicitly* on the wire (tag 7).
|
|
||||||
/// Excludes `Unspecified`/zero, which is only ever reached by omission.
|
|
||||||
fn explicit_clock_value_strategy() -> impl Strategy<Value = i64> {
|
|
||||||
1_i64..=3
|
|
||||||
}
|
|
||||||
|
|
||||||
fn server_fill_strategy() -> impl Strategy<Value = Option<ServerFill>> {
|
|
||||||
prop_oneof![
|
|
||||||
Just(None),
|
|
||||||
proptest::collection::vec(proptest::char::any(), 0..=8).prop_map(|chars| Some(
|
|
||||||
ServerFill {
|
|
||||||
value: chars.into_iter().collect(),
|
|
||||||
}
|
|
||||||
)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn params_strategy() -> impl Strategy<Value = Params> {
|
|
||||||
(
|
|
||||||
any::<i64>(),
|
|
||||||
any::<i64>(),
|
|
||||||
any::<i64>(),
|
|
||||||
any::<i64>(),
|
|
||||||
any::<i64>(),
|
|
||||||
received_stats_strategy(),
|
|
||||||
stamp_at_strategy(),
|
|
||||||
clock_strategy(),
|
|
||||||
server_fill_strategy(),
|
|
||||||
)
|
|
||||||
.prop_map(
|
|
||||||
|(
|
|
||||||
protocol_version,
|
|
||||||
duration_ns,
|
|
||||||
interval_ns,
|
|
||||||
length,
|
|
||||||
dscp,
|
|
||||||
received_stats,
|
|
||||||
stamp_at,
|
|
||||||
clock,
|
|
||||||
server_fill,
|
|
||||||
)| Params {
|
|
||||||
protocol_version,
|
|
||||||
duration_ns,
|
|
||||||
interval_ns,
|
|
||||||
length,
|
|
||||||
received_stats,
|
|
||||||
stamp_at,
|
|
||||||
clock,
|
|
||||||
dscp,
|
|
||||||
server_fill,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_tagged_int(tag: u64, value: i64, out: &mut Vec<u8>) {
|
|
||||||
varint::encode_uvarint(tag, out);
|
|
||||||
varint::encode_varint(value, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_tagged_server_fill(value: &str, out: &mut Vec<u8>) {
|
|
||||||
varint::encode_uvarint(9, out);
|
|
||||||
varint::encode_uvarint(value.len() as u64, out);
|
|
||||||
out.extend_from_slice(value.as_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
proptest! {
|
|
||||||
#[test]
|
|
||||||
fn valid_params_round_trip(params in params_strategy()) {
|
|
||||||
let encoded = params.encode();
|
|
||||||
let decoded = Params::decode(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_param_presence_matches_omission_rules(params in params_strategy()) {
|
|
||||||
let encoded = params.encode();
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(&decoded.params, ¶ms);
|
|
||||||
|
|
||||||
prop_assert_eq!(decoded.presence.protocol_version, params.protocol_version != 0);
|
|
||||||
prop_assert_eq!(decoded.presence.duration_ns, params.duration_ns != 0);
|
|
||||||
prop_assert_eq!(decoded.presence.interval_ns, params.interval_ns != 0);
|
|
||||||
prop_assert_eq!(decoded.presence.length, params.length != 0);
|
|
||||||
prop_assert_eq!(decoded.presence.dscp, params.dscp != 0);
|
|
||||||
prop_assert_eq!(
|
|
||||||
decoded.presence.received_stats,
|
|
||||||
params.received_stats != ReceivedStats::None
|
|
||||||
);
|
|
||||||
prop_assert_eq!(decoded.presence.stamp_at, params.stamp_at != StampAt::None);
|
|
||||||
prop_assert_eq!(decoded.presence.clock, params.clock != Clock::Unspecified);
|
|
||||||
prop_assert_eq!(decoded.presence.server_fill, params.server_fill.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_protocol_version_is_last_wins(first: i64, second: i64) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(1, first, &mut encoded);
|
|
||||||
encode_tagged_int(1, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.protocol_version, second);
|
|
||||||
prop_assert!(decoded.presence.protocol_version);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_duration_ns_is_last_wins(first: i64, second: i64) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(2, first, &mut encoded);
|
|
||||||
encode_tagged_int(2, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.duration_ns, second);
|
|
||||||
prop_assert!(decoded.presence.duration_ns);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_interval_ns_is_last_wins(first: i64, second: i64) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(3, first, &mut encoded);
|
|
||||||
encode_tagged_int(3, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.interval_ns, second);
|
|
||||||
prop_assert!(decoded.presence.interval_ns);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_length_is_last_wins(first: i64, second: i64) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(4, first, &mut encoded);
|
|
||||||
encode_tagged_int(4, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.length, second);
|
|
||||||
prop_assert!(decoded.presence.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_received_stats_is_last_wins(first in 0_i64..=3, second in 0_i64..=3) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(5, first, &mut encoded);
|
|
||||||
encode_tagged_int(5, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.received_stats, ReceivedStats::try_from(second).unwrap());
|
|
||||||
prop_assert!(decoded.presence.received_stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_stamp_at_is_last_wins(first in 0_i64..=4, second in 0_i64..=4) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(6, first, &mut encoded);
|
|
||||||
encode_tagged_int(6, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.stamp_at, StampAt::try_from(second).unwrap());
|
|
||||||
prop_assert!(decoded.presence.stamp_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_clock_is_last_wins(
|
|
||||||
first in explicit_clock_value_strategy(),
|
|
||||||
second in explicit_clock_value_strategy(),
|
|
||||||
) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(7, first, &mut encoded);
|
|
||||||
encode_tagged_int(7, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.clock, Clock::try_from(second).unwrap());
|
|
||||||
prop_assert!(decoded.presence.clock);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_dscp_is_last_wins(first: i64, second: i64) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_int(8, first, &mut encoded);
|
|
||||||
encode_tagged_int(8, second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded.params.dscp, second);
|
|
||||||
prop_assert!(decoded.presence.dscp);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repeated_server_fill_is_last_wins(
|
|
||||||
first in proptest::collection::vec(proptest::char::any(), 0..=8)
|
|
||||||
.prop_map(|chars| chars.into_iter().collect::<String>()),
|
|
||||||
second in proptest::collection::vec(proptest::char::any(), 0..=8)
|
|
||||||
.prop_map(|chars| chars.into_iter().collect::<String>()),
|
|
||||||
) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_tagged_server_fill(&first, &mut encoded);
|
|
||||||
encode_tagged_server_fill(&second, &mut encoded);
|
|
||||||
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(
|
|
||||||
decoded.params.server_fill,
|
|
||||||
Some(ServerFill { value: second })
|
|
||||||
);
|
|
||||||
prop_assert!(decoded.presence.server_fill);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_scalar_tag_prefix_does_not_disturb_known_params(
|
|
||||||
params in params_strategy(),
|
|
||||||
unknown_tag in 10_u64..=u16::MAX as u64,
|
|
||||||
unknown_value: u64,
|
|
||||||
) {
|
|
||||||
let mut prefixed = Vec::new();
|
|
||||||
varint::encode_uvarint(unknown_tag, &mut prefixed);
|
|
||||||
varint::encode_uvarint(unknown_value, &mut prefixed);
|
|
||||||
prefixed.extend_from_slice(¶ms.encode());
|
|
||||||
|
|
||||||
let decoded = Params::decode(&prefixed).unwrap();
|
|
||||||
prop_assert_eq!(decoded, params.clone());
|
|
||||||
|
|
||||||
let decoded_with_presence = Params::decode_with_presence(&prefixed).unwrap();
|
|
||||||
let expected_presence = Params::decode_with_presence(¶ms.encode()).unwrap().presence;
|
|
||||||
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_scalar_tag_suffix_does_not_disturb_known_params(
|
|
||||||
params in params_strategy(),
|
|
||||||
unknown_tag in 10_u64..=u16::MAX as u64,
|
|
||||||
unknown_value: u64,
|
|
||||||
) {
|
|
||||||
let mut suffixed = params.encode();
|
|
||||||
varint::encode_uvarint(unknown_tag, &mut suffixed);
|
|
||||||
varint::encode_uvarint(unknown_value, &mut suffixed);
|
|
||||||
|
|
||||||
let decoded = Params::decode(&suffixed).unwrap();
|
|
||||||
prop_assert_eq!(decoded, params.clone());
|
|
||||||
|
|
||||||
let decoded_with_presence = Params::decode_with_presence(&suffixed).unwrap();
|
|
||||||
let expected_presence = Params::decode_with_presence(¶ms.encode()).unwrap().presence;
|
|
||||||
prop_assert_eq!(decoded_with_presence.presence, expected_presence);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Omitted-vs-explicit-zero presence, for the fields where explicit
|
|
||||||
/// zero is valid on the wire. `Clock` is deliberately excluded: an
|
|
||||||
/// explicit zero there is rejected outright, not merely "present with
|
|
||||||
/// the default value" (see `explicit_clock_zero_is_rejected`).
|
|
||||||
#[test]
|
|
||||||
fn omitted_vs_explicit_zero_presence_for_zero_valid_fields() {
|
|
||||||
let scalar_tags = [1_u64, 2, 3, 4, 8];
|
|
||||||
for tag in scalar_tags {
|
|
||||||
let omitted = Params::decode_with_presence(&[]).unwrap();
|
|
||||||
let mut explicit_zero = Vec::new();
|
|
||||||
encode_tagged_int(tag, 0, &mut explicit_zero);
|
|
||||||
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(omitted.params, explicit.params);
|
|
||||||
assert_eq!(explicit.params, Params::default());
|
|
||||||
|
|
||||||
let (omitted_presence, explicit_presence) = match tag {
|
|
||||||
1 => (
|
|
||||||
omitted.presence.protocol_version,
|
|
||||||
explicit.presence.protocol_version,
|
|
||||||
),
|
|
||||||
2 => (omitted.presence.duration_ns, explicit.presence.duration_ns),
|
|
||||||
3 => (omitted.presence.interval_ns, explicit.presence.interval_ns),
|
|
||||||
4 => (omitted.presence.length, explicit.presence.length),
|
|
||||||
8 => (omitted.presence.dscp, explicit.presence.dscp),
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
assert!(!omitted_presence);
|
|
||||||
assert!(explicit_presence);
|
|
||||||
}
|
|
||||||
|
|
||||||
let enum_tags = [5_u64, 6];
|
|
||||||
for tag in enum_tags {
|
|
||||||
let omitted = Params::decode_with_presence(&[]).unwrap();
|
|
||||||
let mut explicit_zero = Vec::new();
|
|
||||||
encode_tagged_int(tag, 0, &mut explicit_zero);
|
|
||||||
let explicit = Params::decode_with_presence(&explicit_zero).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(omitted.params, explicit.params);
|
|
||||||
|
|
||||||
let (omitted_presence, explicit_presence) = match tag {
|
|
||||||
5 => (
|
|
||||||
omitted.presence.received_stats,
|
|
||||||
explicit.presence.received_stats,
|
|
||||||
),
|
|
||||||
6 => (omitted.presence.stamp_at, explicit.presence.stamp_at),
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
assert!(!omitted_presence);
|
|
||||||
assert!(explicit_presence);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `Clock` deliberately breaks the pattern above: explicit zero is
|
|
||||||
// rejected rather than accepted-but-present.
|
|
||||||
let omitted = Params::decode_with_presence(&[]).unwrap();
|
|
||||||
assert_eq!(omitted.params.clock, Clock::Unspecified);
|
|
||||||
assert!(!omitted.presence.clock);
|
|
||||||
let mut explicit_clock_zero = Vec::new();
|
|
||||||
encode_tagged_int(7, 0, &mut explicit_clock_zero);
|
|
||||||
assert_eq!(
|
|
||||||
Params::decode_with_presence(&explicit_clock_zero),
|
|
||||||
Err(ProtoError::InvalidEnum {
|
|
||||||
name: "Clock",
|
|
||||||
value: 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,112 +59,3 @@ pub fn decode_varint(input: &[u8]) -> Result<(i64, usize)> {
|
|||||||
let (value, used) = decode_uvarint(input)?;
|
let (value, used) = decode_uvarint(input)?;
|
||||||
Ok((zigzag_decode(value), used))
|
Ok((zigzag_decode(value), used))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uvarint_round_trips() {
|
|
||||||
for value in [
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
127,
|
|
||||||
128,
|
|
||||||
255,
|
|
||||||
16_384,
|
|
||||||
u32::MAX as u64,
|
|
||||||
i64::MAX as u64,
|
|
||||||
u64::MAX,
|
|
||||||
] {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_uvarint(value, &mut encoded);
|
|
||||||
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn signed_varint_round_trips() {
|
|
||||||
for value in [
|
|
||||||
i64::MIN,
|
|
||||||
-9_223_372_036,
|
|
||||||
-2,
|
|
||||||
-1,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
1_000_000_000,
|
|
||||||
3_000_000_000,
|
|
||||||
i64::MAX,
|
|
||||||
] {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_varint(value, &mut encoded);
|
|
||||||
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn verified_byte_examples() {
|
|
||||||
let cases: &[(i64, &[u8])] = &[
|
|
||||||
(1, &[0x02]),
|
|
||||||
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
|
|
||||||
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
|
|
||||||
(1472, &[0x80, 0x17]),
|
|
||||||
(3, &[0x06]),
|
|
||||||
(184, &[0xf0, 0x02]),
|
|
||||||
];
|
|
||||||
for (value, expected) in cases {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_varint(*value, &mut encoded);
|
|
||||||
assert_eq!(&encoded, expected);
|
|
||||||
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tag = Vec::new();
|
|
||||||
encode_uvarint(9, &mut tag);
|
|
||||||
assert_eq!(tag, [0x09]);
|
|
||||||
tag.clear();
|
|
||||||
encode_uvarint(24, &mut tag);
|
|
||||||
assert_eq!(tag, [0x18]);
|
|
||||||
}
|
|
||||||
|
|
||||||
mod properties {
|
|
||||||
use super::*;
|
|
||||||
use proptest::prelude::*;
|
|
||||||
|
|
||||||
proptest! {
|
|
||||||
#[test]
|
|
||||||
fn uvarint_round_trips(value: u64) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_uvarint(value, &mut encoded);
|
|
||||||
prop_assert!(!encoded.is_empty());
|
|
||||||
prop_assert!(encoded.len() <= 10);
|
|
||||||
let (decoded, used) = decode_uvarint(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded, value);
|
|
||||||
prop_assert_eq!(used, encoded.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn signed_varint_round_trips(value: i64) {
|
|
||||||
let mut encoded = Vec::new();
|
|
||||||
encode_varint(value, &mut encoded);
|
|
||||||
prop_assert!(!encoded.is_empty());
|
|
||||||
prop_assert!(encoded.len() <= 10);
|
|
||||||
let (decoded, used) = decode_varint(&encoded).unwrap();
|
|
||||||
prop_assert_eq!(decoded, value);
|
|
||||||
prop_assert_eq!(used, encoded.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zigzag_round_trips_from_signed(value: i64) {
|
|
||||||
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zigzag_round_trips_from_unsigned(value: u64) {
|
|
||||||
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
use irtt_proto::{compute_hmac, compute_hmac_in_place, verify_hmac, ProtoError};
|
||||||
|
|
||||||
|
fn captured_open() -> Vec<u8> {
|
||||||
|
// Captured vector 6; provenance and independently checked digest are in
|
||||||
|
// docs/protocol/test-vectors/README.md. This is an oracle for the primitive.
|
||||||
|
"14 a7 5b 09 ff 90 16 a7 aa 53 78 16 9e c3 a2 d5 54 dc 30 36
|
||||||
|
01 02 02 80 d0 ac f3 0e 03 80 a8 d6 b9 07 05 06 06 06 07 06"
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn computing_hmac_ignores_existing_digest_and_only_replaces_its_field() {
|
||||||
|
let expected = captured_open();
|
||||||
|
let mut packet = expected.clone();
|
||||||
|
packet[4..20].fill(0xa5);
|
||||||
|
let before = packet.clone();
|
||||||
|
assert_eq!(
|
||||||
|
compute_hmac(b"testkey", &packet, 4).unwrap().as_slice(),
|
||||||
|
&expected[4..20]
|
||||||
|
);
|
||||||
|
assert_eq!(packet, before);
|
||||||
|
compute_hmac_in_place(b"testkey", &mut packet, 4).unwrap();
|
||||||
|
assert_eq!(packet, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verification_rejects_wrong_key_and_modified_authenticated_bytes() {
|
||||||
|
let packet = captured_open();
|
||||||
|
verify_hmac(b"testkey", &packet, 4).unwrap();
|
||||||
|
assert_eq!(verify_hmac(b"wrong", &packet, 4), Err(ProtoError::BadHmac));
|
||||||
|
for offset in [0, 4, 21, packet.len() - 1] {
|
||||||
|
let mut modified = packet.clone();
|
||||||
|
modified[offset] ^= 1;
|
||||||
|
assert_eq!(
|
||||||
|
verify_hmac(b"testkey", &modified, 4),
|
||||||
|
Err(ProtoError::BadHmac)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incomplete_or_out_of_bounds_digest_fields_are_rejected_without_mutation() {
|
||||||
|
for offset in [5, 20, 21, usize::MAX] {
|
||||||
|
let mut packet = vec![0xa5; 20];
|
||||||
|
assert_eq!(
|
||||||
|
compute_hmac(b"key", &packet, offset),
|
||||||
|
Err(ProtoError::InvalidHmacOffset)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verify_hmac(b"key", &packet, offset),
|
||||||
|
Err(ProtoError::InvalidHmacOffset)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compute_hmac_in_place(b"key", &mut packet, offset),
|
||||||
|
Err(ProtoError::InvalidHmacOffset)
|
||||||
|
);
|
||||||
|
assert_eq!(packet, [0xa5; 20]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use irtt_proto::params::{Clock, ReceivedStats, StampAt};
|
||||||
|
use irtt_proto::{
|
||||||
|
echo_header_len, echo_packet_len, PacketLayout, Params, HMAC_SIZE, RECV_COUNT_SIZE,
|
||||||
|
RECV_WINDOW_SIZE, SEQ_SIZE, TIMESTAMP_SIZE, TOKEN_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEADER_SIZE: usize = 4;
|
||||||
|
|
||||||
|
fn params(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> Params {
|
||||||
|
Params {
|
||||||
|
received_stats: stats,
|
||||||
|
stamp_at,
|
||||||
|
clock,
|
||||||
|
..Params::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_optional_len(stats: ReceivedStats, stamp_at: StampAt, clock: Clock) -> usize {
|
||||||
|
let stats_len = match stats {
|
||||||
|
ReceivedStats::None => 0,
|
||||||
|
ReceivedStats::Count => RECV_COUNT_SIZE,
|
||||||
|
ReceivedStats::Window => RECV_WINDOW_SIZE,
|
||||||
|
ReceivedStats::Both => RECV_COUNT_SIZE + RECV_WINDOW_SIZE,
|
||||||
|
};
|
||||||
|
let clock_count = match clock {
|
||||||
|
Clock::Unspecified => 0,
|
||||||
|
Clock::Wall | Clock::Monotonic => 1,
|
||||||
|
Clock::Both => 2,
|
||||||
|
};
|
||||||
|
let timestamp_groups = match stamp_at {
|
||||||
|
StampAt::None => 0,
|
||||||
|
StampAt::Send | StampAt::Receive | StampAt::Midpoint => 1,
|
||||||
|
StampAt::Both => 2,
|
||||||
|
};
|
||||||
|
stats_len + timestamp_groups * clock_count * TIMESTAMP_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_and_close_layout_lengths() {
|
||||||
|
assert_eq!(PacketLayout::open_request(false).header_len(), 4);
|
||||||
|
assert_eq!(PacketLayout::open_request(true).header_len(), 20);
|
||||||
|
assert_eq!(PacketLayout::open_reply(false).header_len(), 12);
|
||||||
|
assert_eq!(PacketLayout::open_reply(true).header_len(), 28);
|
||||||
|
assert_eq!(PacketLayout::close_request(false).header_len(), 12);
|
||||||
|
assert_eq!(PacketLayout::close_request(true).header_len(), 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layout_matrix_matches_stats_timestamps_clock_and_hmac_rules() {
|
||||||
|
for stats in [
|
||||||
|
ReceivedStats::None,
|
||||||
|
ReceivedStats::Count,
|
||||||
|
ReceivedStats::Window,
|
||||||
|
ReceivedStats::Both,
|
||||||
|
] {
|
||||||
|
for stamp_at in [
|
||||||
|
StampAt::None,
|
||||||
|
StampAt::Send,
|
||||||
|
StampAt::Receive,
|
||||||
|
StampAt::Both,
|
||||||
|
StampAt::Midpoint,
|
||||||
|
] {
|
||||||
|
for clock in [
|
||||||
|
Clock::Unspecified,
|
||||||
|
Clock::Wall,
|
||||||
|
Clock::Monotonic,
|
||||||
|
Clock::Both,
|
||||||
|
] {
|
||||||
|
for hmac in [false, true] {
|
||||||
|
let params = params(stats, stamp_at, clock);
|
||||||
|
let layout = PacketLayout::echo(hmac, ¶ms);
|
||||||
|
let expected_len = HEADER_SIZE
|
||||||
|
+ TOKEN_SIZE
|
||||||
|
+ SEQ_SIZE
|
||||||
|
+ if hmac { HMAC_SIZE } else { 0 }
|
||||||
|
+ expected_optional_len(stats, stamp_at, clock);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
layout.header_len(),
|
||||||
|
expected_len,
|
||||||
|
"unexpected length for stats={stats:?} stamp_at={stamp_at:?} clock={clock:?} hmac={hmac}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_count,
|
||||||
|
matches!(stats, ReceivedStats::Count | ReceivedStats::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_window,
|
||||||
|
matches!(stats, ReceivedStats::Window | ReceivedStats::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_wall,
|
||||||
|
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Wall | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.recv_mono,
|
||||||
|
matches!(stamp_at, StampAt::Receive | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Monotonic | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.midpoint_wall,
|
||||||
|
matches!(stamp_at, StampAt::Midpoint)
|
||||||
|
&& matches!(clock, Clock::Wall | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.midpoint_mono,
|
||||||
|
matches!(stamp_at, StampAt::Midpoint)
|
||||||
|
&& matches!(clock, Clock::Monotonic | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.send_wall,
|
||||||
|
matches!(stamp_at, StampAt::Send | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Wall | Clock::Both)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
layout.send_mono,
|
||||||
|
matches!(stamp_at, StampAt::Send | StampAt::Both)
|
||||||
|
&& matches!(clock, Clock::Monotonic | Clock::Both)
|
||||||
|
);
|
||||||
|
|
||||||
|
if hmac {
|
||||||
|
assert_eq!(
|
||||||
|
echo_header_len(true, ¶ms) - echo_header_len(false, ¶ms),
|
||||||
|
HMAC_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negotiated_length_is_floored_at_the_required_field_block() {
|
||||||
|
// The 60-byte header of this layout is the floor. A negative negotiated
|
||||||
|
// length is not an error: it asks for nothing beyond the mandatory
|
||||||
|
// fields, exactly as zero does.
|
||||||
|
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
||||||
|
for (length, expected) in [(-4096, 60), (-1, 60), (0, 60), (20, 60), (92, 92)] {
|
||||||
|
p.length = length;
|
||||||
|
assert_eq!(
|
||||||
|
echo_packet_len(false, &p),
|
||||||
|
Ok(expected),
|
||||||
|
"unexpected packet length for negotiated length {length}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A positive length wider than `usize` must stay an error rather than
|
||||||
|
/// becoming a saturated buffer size. Only reachable where `usize` is
|
||||||
|
/// narrower than `i64`; on a 64-bit target every positive `i64` converts.
|
||||||
|
#[cfg(target_pointer_width = "32")]
|
||||||
|
#[test]
|
||||||
|
fn a_length_wider_than_usize_is_rejected_rather_than_saturated() {
|
||||||
|
let mut p = params(ReceivedStats::Both, StampAt::Both, Clock::Both);
|
||||||
|
p.length = 5_000_000_000;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
echo_packet_len(false, &p),
|
||||||
|
Err(irtt_proto::ProtoError::PacketLengthUnrepresentable {
|
||||||
|
length: 5_000_000_000
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
use irtt_proto::{
|
||||||
|
varint, Clock, ParamPresence, Params, ProtoError, ReceivedStats, ServerFill, StampAt,
|
||||||
|
};
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
fn int(tag: u64, value: i64) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
varint::encode_uvarint(tag, &mut out);
|
||||||
|
varint::encode_varint(value, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill(value: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = vec![9];
|
||||||
|
varint::encode_uvarint(value.len() as u64, &mut out);
|
||||||
|
out.extend_from_slice(value);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn presence(p: ParamPresence) -> [bool; 9] {
|
||||||
|
[
|
||||||
|
p.protocol_version,
|
||||||
|
p.duration_ns,
|
||||||
|
p.interval_ns,
|
||||||
|
p.length,
|
||||||
|
p.received_stats,
|
||||||
|
p.stamp_at,
|
||||||
|
p.clock,
|
||||||
|
p.dscp,
|
||||||
|
p.server_fill,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_error(bytes: &[u8], expected: ProtoError) {
|
||||||
|
assert_eq!(Params::decode(bytes).unwrap_err(), expected, "{bytes:02x?}");
|
||||||
|
assert_eq!(
|
||||||
|
Params::decode_with_presence(bytes).unwrap_err(),
|
||||||
|
expected,
|
||||||
|
"{bytes:02x?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_encodings_preserve_raw_dscp_and_utf8_byte_lengths() {
|
||||||
|
for (params, bytes) in [
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
dscp: 46,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![8, 92],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
dscp: 184,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![8, 0xf0, 2],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
server_fill: Some(ServerFill {
|
||||||
|
value: "rand".into(),
|
||||||
|
}),
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![9, 4, b'r', b'a', b'n', b'd'],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Params {
|
||||||
|
server_fill: Some(ServerFill { value: "é".into() }),
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
vec![9, 2, 0xc3, 0xa9],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(params.encode(), bytes);
|
||||||
|
assert_eq!(Params::decode(&bytes).unwrap(), params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wire_defaults_and_explicit_zero_presence() {
|
||||||
|
let defaults = Params {
|
||||||
|
protocol_version: 0,
|
||||||
|
duration_ns: 0,
|
||||||
|
interval_ns: 0,
|
||||||
|
length: 0,
|
||||||
|
received_stats: ReceivedStats::None,
|
||||||
|
stamp_at: StampAt::None,
|
||||||
|
clock: Clock::Unspecified,
|
||||||
|
dscp: 0,
|
||||||
|
server_fill: None,
|
||||||
|
};
|
||||||
|
let absent = Params::decode_with_presence(&[]).unwrap();
|
||||||
|
assert_eq!(absent.params, defaults);
|
||||||
|
assert_eq!(presence(absent.presence), [false; 9]);
|
||||||
|
assert_eq!(defaults.encode(), Vec::<u8>::new());
|
||||||
|
assert!(!defaults.clock.has_wall());
|
||||||
|
assert!(!defaults.clock.has_mono());
|
||||||
|
|
||||||
|
// Clock is the sole integer tag for which explicit zero is invalid.
|
||||||
|
for tag in [1, 2, 3, 4, 5, 6, 8] {
|
||||||
|
let bytes = int(tag, 0);
|
||||||
|
let decoded = Params::decode_with_presence(&bytes).unwrap();
|
||||||
|
assert_eq!(decoded.params, defaults);
|
||||||
|
let mut expected = [false; 9];
|
||||||
|
expected[tag as usize - 1] = true;
|
||||||
|
assert_eq!(presence(decoded.presence), expected, "tag {tag}");
|
||||||
|
assert_eq!(Params::decode(&bytes).unwrap(), decoded.params);
|
||||||
|
}
|
||||||
|
let empty_fill = Params::decode_with_presence(&[9, 0]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
empty_fill.params.server_fill,
|
||||||
|
Some(ServerFill {
|
||||||
|
value: String::new()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
presence(empty_fill.presence),
|
||||||
|
[false, false, false, false, false, false, false, false, true]
|
||||||
|
);
|
||||||
|
assert_eq!(empty_fill.params.encode(), [9, 0]);
|
||||||
|
assert_eq!(
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..defaults
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
[1, 2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_tags_map_to_their_fields_and_report_isolated_presence() {
|
||||||
|
for (tag, value, expected) in [
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
Params {
|
||||||
|
protocol_version: 1,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
Params {
|
||||||
|
duration_ns: 5,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
3,
|
||||||
|
6,
|
||||||
|
Params {
|
||||||
|
interval_ns: 6,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
7,
|
||||||
|
Params {
|
||||||
|
length: 7,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
8,
|
||||||
|
184,
|
||||||
|
Params {
|
||||||
|
dscp: 184,
|
||||||
|
..Params::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
|
||||||
|
assert_eq!(decoded.params, expected);
|
||||||
|
let mut expected_presence = [false; 9];
|
||||||
|
expected_presence[tag as usize - 1] = true;
|
||||||
|
assert_eq!(presence(decoded.presence), expected_presence);
|
||||||
|
}
|
||||||
|
for (tag, modes) in [
|
||||||
|
(
|
||||||
|
5,
|
||||||
|
vec![
|
||||||
|
ReceivedStats::None as i64,
|
||||||
|
ReceivedStats::Count as i64,
|
||||||
|
ReceivedStats::Window as i64,
|
||||||
|
ReceivedStats::Both as i64,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
6,
|
||||||
|
vec![
|
||||||
|
StampAt::None as i64,
|
||||||
|
StampAt::Send as i64,
|
||||||
|
StampAt::Receive as i64,
|
||||||
|
StampAt::Both as i64,
|
||||||
|
StampAt::Midpoint as i64,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
7,
|
||||||
|
vec![
|
||||||
|
Clock::Wall as i64,
|
||||||
|
Clock::Monotonic as i64,
|
||||||
|
Clock::Both as i64,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
for (index, mode) in modes.into_iter().enumerate() {
|
||||||
|
let value = index as i64 + if tag == 7 { 1 } else { 0 };
|
||||||
|
let decoded = Params::decode_with_presence(&int(tag, value)).unwrap();
|
||||||
|
let actual = match tag {
|
||||||
|
5 => decoded.params.received_stats as i64,
|
||||||
|
6 => decoded.params.stamp_at as i64,
|
||||||
|
7 => decoded.params.clock as i64,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
assert_eq!(actual, mode);
|
||||||
|
let mut expected_presence = [false; 9];
|
||||||
|
expected_presence[tag as usize - 1] = true;
|
||||||
|
assert_eq!(presence(decoded.presence), expected_presence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_values_and_invalid_enums_are_rejected_by_both_decoders() {
|
||||||
|
for (tag, value, name) in [
|
||||||
|
(5, 4, "ReceivedStats"),
|
||||||
|
(6, 5, "StampAt"),
|
||||||
|
(7, 0, "Clock"),
|
||||||
|
(7, 4, "Clock"),
|
||||||
|
(5, -1, "ReceivedStats"),
|
||||||
|
(6, -1, "StampAt"),
|
||||||
|
(7, -1, "Clock"),
|
||||||
|
] {
|
||||||
|
assert_error(&int(tag, value), ProtoError::InvalidEnum { name, value });
|
||||||
|
}
|
||||||
|
for bytes in [&[1, 0x80][..], &[99], &[0x80], &[9, 0x80]] {
|
||||||
|
assert_error(bytes, ProtoError::TruncatedVarint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_fill_accepts_valid_bytes_and_enforces_length_and_utf8_boundaries() {
|
||||||
|
for value in [
|
||||||
|
"",
|
||||||
|
"rand",
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
"éééééééééééééééé",
|
||||||
|
] {
|
||||||
|
let expected = Params {
|
||||||
|
server_fill: Some(ServerFill {
|
||||||
|
value: value.into(),
|
||||||
|
}),
|
||||||
|
..Params::default()
|
||||||
|
};
|
||||||
|
let bytes = fill(value.as_bytes());
|
||||||
|
assert_eq!(expected.encode(), bytes);
|
||||||
|
let decoded = Params::decode_with_presence(&bytes).unwrap();
|
||||||
|
assert_eq!(decoded.params, expected);
|
||||||
|
assert!(decoded.presence.server_fill);
|
||||||
|
assert_eq!(Params::decode(&bytes).unwrap(), expected);
|
||||||
|
}
|
||||||
|
assert_error(
|
||||||
|
&fill(b"0123456789abcdef0123456789abcdefx"),
|
||||||
|
ProtoError::ParameterLengthTooLarge { tag: 9, length: 33 },
|
||||||
|
);
|
||||||
|
assert_error(&[9, 4, b'a', b'b'], ProtoError::MalformedParams);
|
||||||
|
assert_error(&[9, 1, 0xff], ProtoError::InvalidUtf8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_pointer_width = "32")]
|
||||||
|
#[test]
|
||||||
|
fn server_fill_length_too_large_for_usize_is_rejected() {
|
||||||
|
let mut bytes = vec![9];
|
||||||
|
let length = u64::from(u32::MAX) + 1;
|
||||||
|
varint::encode_uvarint(length, &mut bytes);
|
||||||
|
assert_error(
|
||||||
|
&bytes,
|
||||||
|
ProtoError::ParameterLengthTooLarge { tag: 9, length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_strategy() -> impl Strategy<Value = String> {
|
||||||
|
// Eight arbitrary Unicode scalars occupy at most the allowed 32 bytes.
|
||||||
|
proptest::collection::vec(any::<char>(), 0..=8).prop_map(|chars| chars.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params_strategy() -> impl Strategy<Value = Params> {
|
||||||
|
(
|
||||||
|
any::<[i64; 5]>(),
|
||||||
|
prop::sample::select(vec![
|
||||||
|
ReceivedStats::None,
|
||||||
|
ReceivedStats::Count,
|
||||||
|
ReceivedStats::Window,
|
||||||
|
ReceivedStats::Both,
|
||||||
|
]),
|
||||||
|
prop::sample::select(vec![
|
||||||
|
StampAt::None,
|
||||||
|
StampAt::Send,
|
||||||
|
StampAt::Receive,
|
||||||
|
StampAt::Both,
|
||||||
|
StampAt::Midpoint,
|
||||||
|
]),
|
||||||
|
prop::sample::select(vec![
|
||||||
|
Clock::Unspecified,
|
||||||
|
Clock::Wall,
|
||||||
|
Clock::Monotonic,
|
||||||
|
Clock::Both,
|
||||||
|
]),
|
||||||
|
prop::option::of(string_strategy()),
|
||||||
|
)
|
||||||
|
.prop_map(
|
||||||
|
|(scalar, received_stats, stamp_at, clock, server_fill)| Params {
|
||||||
|
protocol_version: scalar[0],
|
||||||
|
duration_ns: scalar[1],
|
||||||
|
interval_ns: scalar[2],
|
||||||
|
length: scalar[3],
|
||||||
|
dscp: scalar[4],
|
||||||
|
received_stats,
|
||||||
|
stamp_at,
|
||||||
|
clock,
|
||||||
|
server_fill: server_fill.map(|value| ServerFill { value }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_tags(params: &Params) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// Deliberately bypass encode's omission: duplicate zero values must be present.
|
||||||
|
for (tag, value) in [
|
||||||
|
(1, params.protocol_version),
|
||||||
|
(2, params.duration_ns),
|
||||||
|
(3, params.interval_ns),
|
||||||
|
(4, params.length),
|
||||||
|
(5, params.received_stats as i64),
|
||||||
|
(6, params.stamp_at as i64),
|
||||||
|
(7, params.clock as i64),
|
||||||
|
(8, params.dscp),
|
||||||
|
] {
|
||||||
|
out.extend_from_slice(&int(tag, value));
|
||||||
|
}
|
||||||
|
out.extend_from_slice(&fill(params.server_fill.as_ref().unwrap().value.as_bytes()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn valid_params_round_trip_with_omission_presence(params in params_strategy()) {
|
||||||
|
let encoded = params.encode();
|
||||||
|
let decoded = Params::decode_with_presence(&encoded).unwrap();
|
||||||
|
prop_assert_eq!(&decoded.params, ¶ms);
|
||||||
|
prop_assert_eq!(Params::decode(&encoded).unwrap(), params.clone());
|
||||||
|
prop_assert_eq!(presence(decoded.presence), [
|
||||||
|
params.protocol_version != 0, params.duration_ns != 0,
|
||||||
|
params.interval_ns != 0, params.length != 0,
|
||||||
|
params.received_stats != ReceivedStats::None, params.stamp_at != StampAt::None,
|
||||||
|
params.clock != Clock::Unspecified, params.dscp != 0, params.server_fill.is_some(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_known_tags_are_last_wins(
|
||||||
|
mut first in params_strategy(), mut second in params_strategy(),
|
||||||
|
first_clock in 1_i64..=3, second_clock in 1_i64..=3,
|
||||||
|
first_fill in string_strategy(), second_fill in string_strategy(),
|
||||||
|
) {
|
||||||
|
first.clock = Clock::try_from(first_clock).unwrap();
|
||||||
|
second.clock = Clock::try_from(second_clock).unwrap();
|
||||||
|
first.server_fill = Some(ServerFill { value: first_fill });
|
||||||
|
second.server_fill = Some(ServerFill { value: second_fill });
|
||||||
|
let mut bytes = all_tags(&first);
|
||||||
|
bytes.extend_from_slice(&all_tags(&second));
|
||||||
|
let decoded = Params::decode_with_presence(&bytes).unwrap();
|
||||||
|
prop_assert_eq!(&decoded.params, &second);
|
||||||
|
prop_assert_eq!(Params::decode(&bytes).unwrap(), second);
|
||||||
|
prop_assert_eq!(presence(decoded.presence), [true; 9]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_scalar_tags_do_not_disturb_known_params(
|
||||||
|
params in params_strategy(), tag in 10_u64..=u16::MAX as u64, value: u64,
|
||||||
|
) {
|
||||||
|
let encoded = params.encode();
|
||||||
|
let expected = Params::decode_with_presence(&encoded).unwrap();
|
||||||
|
let mut unknown = Vec::new();
|
||||||
|
varint::encode_uvarint(tag, &mut unknown);
|
||||||
|
varint::encode_uvarint(value, &mut unknown);
|
||||||
|
for bytes in [[unknown.as_slice(), &encoded].concat(), [&encoded, unknown.as_slice()].concat()] {
|
||||||
|
prop_assert_eq!(Params::decode_with_presence(&bytes).unwrap(), expected.clone());
|
||||||
|
prop_assert_eq!(Params::decode(&bytes).unwrap(), params.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
-127
@@ -54,34 +54,42 @@ fn echo_reply(params: &Params, flags: u8) -> EchoReply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn authenticated_reply_encoders_set_hmac_from_key() {
|
fn reply_encoders_normalize_hmac_flags_from_the_key() {
|
||||||
let params = params();
|
let params = params();
|
||||||
let open_reply = OpenReply {
|
for key in [None, Some(KEY)] {
|
||||||
flags: FLAG_OPEN | FLAG_REPLY,
|
for supplied_hmac in [0, FLAG_HMAC] {
|
||||||
|
let auth = if key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
|
let open = OpenReply {
|
||||||
|
flags: FLAG_OPEN | FLAG_REPLY | supplied_hmac,
|
||||||
token: TOKEN,
|
token: TOKEN,
|
||||||
params: params.clone(),
|
params: params.clone(),
|
||||||
};
|
};
|
||||||
let packet = encode_open_reply(&open_reply, Some(KEY)).unwrap();
|
let packet = encode_open_reply(&open, key).unwrap();
|
||||||
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | FLAG_HMAC);
|
assert_eq!(packet[3], FLAG_OPEN | FLAG_REPLY | auth);
|
||||||
|
let offset = if key.is_some() { 20 } else { 4 };
|
||||||
|
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_open_reply(&packet, Some(KEY)),
|
decode_open_reply(&packet, key).unwrap(),
|
||||||
Ok(OpenReply {
|
OpenReply {
|
||||||
flags: FLAG_OPEN | FLAG_REPLY | FLAG_HMAC,
|
flags: packet[3],
|
||||||
..open_reply
|
..open
|
||||||
})
|
}
|
||||||
);
|
);
|
||||||
|
let echo = echo_reply(¶ms, FLAG_REPLY | supplied_hmac);
|
||||||
let echo_reply = echo_reply(¶ms, FLAG_REPLY);
|
let packet = encode_echo_reply(&echo, ¶ms, key).unwrap();
|
||||||
let packet = encode_echo_reply(&echo_reply, ¶ms, Some(KEY)).unwrap();
|
assert_eq!(packet[3], FLAG_REPLY | auth);
|
||||||
assert_eq!(packet[3], FLAG_REPLY | FLAG_HMAC);
|
assert_eq!(&packet[offset..offset + 8], &TOKEN.to_le_bytes());
|
||||||
|
assert_eq!(&packet[offset + 8..offset + 12], &u32::MAX.to_le_bytes());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_echo_reply(&packet, ¶ms, Some(KEY)),
|
decode_echo_reply(&packet, ¶ms, key).unwrap(),
|
||||||
Ok(EchoReply {
|
EchoReply {
|
||||||
flags: FLAG_REPLY | FLAG_HMAC,
|
flags: packet[3],
|
||||||
..echo_reply
|
..echo
|
||||||
})
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejected_open_reply_round_trips_and_zero_token_requires_close() {
|
fn rejected_open_reply_round_trips_and_zero_token_requires_close() {
|
||||||
@@ -95,6 +103,12 @@ fn rejected_open_reply_round_trips_and_zero_token_requires_close() {
|
|||||||
};
|
};
|
||||||
let packet = encode_open_reply(&rejected, None).unwrap();
|
let packet = encode_open_reply(&rejected, None).unwrap();
|
||||||
assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone()));
|
assert_eq!(decode_open_reply(&packet, None), Ok(rejected.clone()));
|
||||||
|
let mut zero_without_close = packet.clone();
|
||||||
|
zero_without_close[3] &= !FLAG_CLOSE;
|
||||||
|
assert_eq!(
|
||||||
|
decode_open_reply(&zero_without_close, None),
|
||||||
|
Err(ProtoError::ZeroToken)
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode_open_reply(
|
encode_open_reply(
|
||||||
@@ -119,63 +133,51 @@ fn peer_close_echo_reply_round_trips_without_a_close_reply_codec() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reply_encoders_supply_their_packet_type_rules() {
|
fn reply_codecs_enforce_packet_type_and_reserved_flag_rules() {
|
||||||
let params = Params::default();
|
let params = Params::default();
|
||||||
assert_eq!(
|
for (flags, expected) in [
|
||||||
encode_open_reply(
|
(FLAG_REPLY, ProtoError::MissingFlag(FLAG_OPEN)),
|
||||||
&OpenReply {
|
(FLAG_OPEN, ProtoError::MissingFlag(FLAG_REPLY)),
|
||||||
flags: FLAG_REPLY,
|
(
|
||||||
|
FLAG_OPEN | FLAG_REPLY | 0x10,
|
||||||
|
ProtoError::ReservedFlags(0x10),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let open = OpenReply {
|
||||||
|
flags,
|
||||||
token: TOKEN,
|
token: TOKEN,
|
||||||
params: params.clone(),
|
params: params.clone(),
|
||||||
},
|
};
|
||||||
None,
|
assert_eq!(encode_open_reply(&open, None).unwrap_err(), expected);
|
||||||
),
|
let mut packet = encode_open_reply(
|
||||||
Err(ProtoError::MissingFlag(FLAG_OPEN))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
encode_echo_reply(
|
|
||||||
&EchoReply {
|
|
||||||
flags: 0,
|
|
||||||
..echo_reply(¶ms, FLAG_REPLY)
|
|
||||||
},
|
|
||||||
¶ms,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
Err(ProtoError::MissingFlag(FLAG_REPLY))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every packet the reply encoders produce must be rejected by the inbound
|
|
||||||
/// request decoder: a server never answers a datagram carrying `FLAG_REPLY`.
|
|
||||||
#[test]
|
|
||||||
fn encoded_replies_are_never_admitted_as_inbound_requests() {
|
|
||||||
let params = Params::default();
|
|
||||||
|
|
||||||
for reply_flags in [FLAG_OPEN | FLAG_REPLY, FLAG_OPEN | FLAG_REPLY | FLAG_CLOSE] {
|
|
||||||
let packet = encode_open_reply(
|
|
||||||
&OpenReply {
|
&OpenReply {
|
||||||
flags: reply_flags,
|
flags: FLAG_OPEN | FLAG_REPLY,
|
||||||
token: if reply_flags & FLAG_CLOSE == 0 {
|
..open
|
||||||
TOKEN
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
},
|
|
||||||
params: params.clone(),
|
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
packet[3] = flags;
|
||||||
decode_request(&packet),
|
assert_eq!(decode_open_reply(&packet, None).unwrap_err(), expected);
|
||||||
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
for (flags, expected) in [
|
||||||
for reply_flags in [FLAG_REPLY, FLAG_REPLY | FLAG_CLOSE] {
|
(0, ProtoError::MissingFlag(FLAG_REPLY)),
|
||||||
let packet = encode_echo_reply(&echo_reply(¶ms, reply_flags), ¶ms, None).unwrap();
|
(
|
||||||
|
FLAG_OPEN | FLAG_REPLY,
|
||||||
|
ProtoError::UnexpectedFlag(FLAG_OPEN),
|
||||||
|
),
|
||||||
|
(FLAG_REPLY | 0x10, ProtoError::ReservedFlags(0x10)),
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_request(&packet),
|
encode_echo_reply(&echo_reply(¶ms, flags), ¶ms, None).unwrap_err(),
|
||||||
Err(ProtoError::UnexpectedFlag(FLAG_REPLY))
|
expected
|
||||||
|
);
|
||||||
|
let mut packet =
|
||||||
|
encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None).unwrap();
|
||||||
|
packet[3] = flags;
|
||||||
|
assert_eq!(
|
||||||
|
decode_echo_reply(&packet, ¶ms, None).unwrap_err(),
|
||||||
|
expected
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +209,12 @@ fn echo_layout_combinations_round_trip() {
|
|||||||
StampAt::Both,
|
StampAt::Both,
|
||||||
StampAt::Midpoint,
|
StampAt::Midpoint,
|
||||||
] {
|
] {
|
||||||
for clock in [Clock::Wall, Clock::Monotonic, Clock::Both] {
|
for clock in [
|
||||||
|
Clock::Unspecified,
|
||||||
|
Clock::Wall,
|
||||||
|
Clock::Monotonic,
|
||||||
|
Clock::Both,
|
||||||
|
] {
|
||||||
for key in [None, Some(KEY)] {
|
for key in [None, Some(KEY)] {
|
||||||
let params = Params {
|
let params = Params {
|
||||||
protocol_version: 1,
|
protocol_version: 1,
|
||||||
@@ -265,45 +272,49 @@ fn echo_reply_requires_exact_negotiated_optional_fields() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() {
|
fn echo_payload_is_copied_zero_filled_and_bounded_in_both_directions() {
|
||||||
let params = Params {
|
for stats in [ReceivedStats::None, ReceivedStats::Both] {
|
||||||
length: 20,
|
for key in [None, Some(KEY)] {
|
||||||
|
let mut params = Params {
|
||||||
|
received_stats: stats,
|
||||||
..Params::default()
|
..Params::default()
|
||||||
};
|
};
|
||||||
|
let offset = PacketLayout::echo(key.is_some(), ¶ms).header_len();
|
||||||
let packet = encode_request(echo_request(¶ms, &[1, 2]), None).unwrap();
|
params.length = (offset + 4) as i64;
|
||||||
assert_eq!(&packet[16..], &[1, 2, 0, 0]);
|
let packet = encode_request(echo_request(¶ms, &[1, 2]), key).unwrap();
|
||||||
|
assert_eq!(&packet[offset..], &[1, 2, 0, 0]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode_request(echo_request(¶ms, &[0; 5]), None),
|
encode_request(echo_request(¶ms, &[0; 5]), key),
|
||||||
Err(ProtoError::PayloadTooLarge {
|
Err(ProtoError::PayloadTooLarge {
|
||||||
available: 4,
|
available: 4,
|
||||||
provided: 5,
|
provided: 5
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
let reply = EchoReply {
|
let reply = EchoReply {
|
||||||
payload: vec![3, 4],
|
payload: vec![3, 4],
|
||||||
..echo_reply(¶ms, FLAG_REPLY)
|
..echo_reply(¶ms, FLAG_REPLY)
|
||||||
};
|
};
|
||||||
let packet = encode_echo_reply(&reply, ¶ms, None).unwrap();
|
let packet = encode_echo_reply(&reply, ¶ms, key).unwrap();
|
||||||
assert_eq!(&packet[16..], &[3, 4, 0, 0]);
|
assert_eq!(&packet[offset..], &[3, 4, 0, 0]);
|
||||||
let decoded = decode_echo_reply(&packet, ¶ms, None).unwrap();
|
let decoded = decode_echo_reply(&packet, ¶ms, key).unwrap();
|
||||||
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
|
assert_eq!(decoded.payload, vec![3, 4, 0, 0]);
|
||||||
assert_eq!(encode_echo_reply(&decoded, ¶ms, None).unwrap(), packet);
|
assert_eq!(encode_echo_reply(&decoded, ¶ms, key).unwrap(), packet);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode_echo_reply(
|
encode_echo_reply(
|
||||||
&EchoReply {
|
&EchoReply {
|
||||||
payload: vec![0; 5],
|
payload: vec![0; 5],
|
||||||
..echo_reply(¶ms, FLAG_REPLY)
|
..reply
|
||||||
},
|
},
|
||||||
¶ms,
|
¶ms,
|
||||||
None,
|
key
|
||||||
),
|
),
|
||||||
Err(ProtoError::PayloadTooLarge {
|
Err(ProtoError::PayloadTooLarge {
|
||||||
available: 4,
|
available: 4,
|
||||||
provided: 5,
|
provided: 5
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_negative_negotiated_length_still_encodes_in_both_directions() {
|
fn a_negative_negotiated_length_still_encodes_in_both_directions() {
|
||||||
@@ -321,6 +332,14 @@ fn a_negative_negotiated_length_still_encodes_in_both_directions() {
|
|||||||
let request = encode_request(echo_request(¶ms, &[]), None)
|
let request = encode_request(echo_request(¶ms, &[]), None)
|
||||||
.expect("a negative negotiated length must still encode a request");
|
.expect("a negative negotiated length must still encode a request");
|
||||||
assert_eq!(request.len(), 16);
|
assert_eq!(request.len(), 16);
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&request).unwrap().kind,
|
||||||
|
DecodedRequestKind::Echo {
|
||||||
|
token: TOKEN,
|
||||||
|
sequence: 17,
|
||||||
|
tail: &[]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let reply = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None)
|
let reply = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None)
|
||||||
.expect("a negative negotiated length must still encode a reply");
|
.expect("a negative negotiated length must still encode a reply");
|
||||||
@@ -505,13 +524,15 @@ fn midpoint_dual_field_reply_authenticates_over_full_packet() {
|
|||||||
let reply = decode_echo_reply(&packet, ¶ms, Some(KEY)).unwrap();
|
let reply = decode_echo_reply(&packet, ¶ms, Some(KEY)).unwrap();
|
||||||
assert_eq!(reply.timestamps.midpoint_wall, Some(555));
|
assert_eq!(reply.timestamps.midpoint_wall, Some(555));
|
||||||
|
|
||||||
let mut corrupted = packet;
|
for offset in [HMAC_OFFSET, PacketLayout::echo(true, ¶ms).header_len()] {
|
||||||
corrupted[HMAC_OFFSET] ^= 0xFF;
|
let mut corrupted = packet.clone();
|
||||||
|
corrupted[offset] ^= 0xFF;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_echo_reply(&corrupted, ¶ms, Some(KEY)),
|
decode_echo_reply(&corrupted, ¶ms, Some(KEY)),
|
||||||
Err(ProtoError::BadHmac)
|
Err(ProtoError::BadHmac)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_exact_compat_length_is_accepted_as_an_extension() {
|
fn only_the_exact_compat_length_is_accepted_as_an_extension() {
|
||||||
@@ -539,61 +560,92 @@ fn only_the_exact_compat_length_is_accepted_as_an_extension() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn midpoint_both_clock_reply_has_no_additional_extension() {
|
fn other_layouts_have_no_longer_form_exception() {
|
||||||
let params = midpoint_params(Clock::Both, 0);
|
for (stamp_at, clock) in [
|
||||||
let reply = echo_reply(¶ms, FLAG_REPLY);
|
(StampAt::Midpoint, Clock::Both),
|
||||||
let packet = encode_echo_reply(&reply, ¶ms, None).unwrap();
|
(StampAt::Receive, Clock::Wall),
|
||||||
|
] {
|
||||||
let mut too_long = packet.clone();
|
|
||||||
too_long.extend_from_slice(&[0; 8]);
|
|
||||||
assert_eq!(
|
|
||||||
decode_echo_reply(&too_long, ¶ms, None),
|
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
|
||||||
expected: packet.len(),
|
|
||||||
actual: too_long.len(),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_midpoint_stamp_at_has_no_longer_form_exception() {
|
|
||||||
let params = Params {
|
let params = Params {
|
||||||
received_stats: ReceivedStats::None,
|
stamp_at,
|
||||||
stamp_at: StampAt::Receive,
|
clock,
|
||||||
clock: Clock::Wall,
|
|
||||||
..Params::default()
|
..Params::default()
|
||||||
};
|
};
|
||||||
let reply = echo_reply(¶ms, FLAG_REPLY);
|
let packet = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, None).unwrap();
|
||||||
let packet = encode_echo_reply(&reply, ¶ms, None).unwrap();
|
|
||||||
|
|
||||||
let mut too_long = packet.clone();
|
let mut too_long = packet.clone();
|
||||||
too_long.extend_from_slice(&[0; 8]);
|
too_long.extend_from_slice(&[0; 8]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_echo_reply(&too_long, ¶ms, None),
|
decode_echo_reply(&too_long, ¶ms, None),
|
||||||
Err(ProtoError::PacketLengthMismatch {
|
Err(ProtoError::PacketLengthMismatch {
|
||||||
expected: packet.len(),
|
expected: packet.len(),
|
||||||
actual: too_long.len(),
|
actual: too_long.len()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The midpoint compatibility length is a *reply* concept. An inbound ECHO
|
|
||||||
/// request of that length is just a longer request, which a receiver accepts
|
|
||||||
/// without applying any negotiated length rule.
|
|
||||||
#[test]
|
|
||||||
fn echo_request_decoding_has_no_compat_length_rule() {
|
|
||||||
let params = midpoint_params(Clock::Wall, 0);
|
|
||||||
let packet = encode_request(echo_request(¶ms, &[]), None).unwrap();
|
|
||||||
|
|
||||||
let mut too_long = packet.clone();
|
|
||||||
too_long.extend_from_slice(&[0; 8]);
|
|
||||||
let request = decode_request(&too_long).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
request.kind,
|
|
||||||
DecodedRequestKind::Echo {
|
|
||||||
token: TOKEN,
|
|
||||||
sequence: 17,
|
|
||||||
tail: &too_long[16..],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reply_decoders_enforce_hmac_presence_and_authenticate_the_body() {
|
||||||
|
let params = params();
|
||||||
|
for key in [None, Some(KEY)] {
|
||||||
|
let open = encode_open_reply(
|
||||||
|
&OpenReply {
|
||||||
|
flags: FLAG_OPEN | FLAG_REPLY,
|
||||||
|
token: TOKEN,
|
||||||
|
params: params.clone(),
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let echo = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, key).unwrap();
|
||||||
|
let mismatched_key = if key.is_some() { None } else { Some(KEY) };
|
||||||
|
assert_eq!(
|
||||||
|
decode_open_reply(&open, mismatched_key),
|
||||||
|
Err(ProtoError::HmacPresenceMismatch)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_echo_reply(&echo, ¶ms, mismatched_key),
|
||||||
|
Err(ProtoError::HmacPresenceMismatch)
|
||||||
|
);
|
||||||
|
if let Some(key) = key {
|
||||||
|
let mut bad_open = open;
|
||||||
|
let mut bad_echo = echo;
|
||||||
|
*bad_open.last_mut().unwrap() ^= 1;
|
||||||
|
*bad_echo.last_mut().unwrap() ^= 1;
|
||||||
|
assert_eq!(
|
||||||
|
decode_open_reply(&bad_open, Some(key)),
|
||||||
|
Err(ProtoError::BadHmac)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode_echo_reply(&bad_echo, ¶ms, Some(key)),
|
||||||
|
Err(ProtoError::BadHmac)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn echo_reply_length_errors_precede_hmac_verification() {
|
||||||
|
for key in [None, Some(KEY)] {
|
||||||
|
let mut params = Params::default();
|
||||||
|
let header = PacketLayout::echo(key.is_some(), ¶ms).header_len();
|
||||||
|
params.length = (header + 4) as i64;
|
||||||
|
let packet = encode_echo_reply(&echo_reply(¶ms, FLAG_REPLY), ¶ms, key).unwrap();
|
||||||
|
for actual in [header - 1, header, packet.len() - 1, packet.len() + 1] {
|
||||||
|
let mut malformed = packet.clone();
|
||||||
|
malformed.resize(actual, 0);
|
||||||
|
// Truncating or extending the signed packet also invalidates its MAC.
|
||||||
|
let expected = if actual < header {
|
||||||
|
ProtoError::PacketTooShort {
|
||||||
|
needed: header,
|
||||||
|
actual,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProtoError::PacketLengthMismatch {
|
||||||
|
expected: packet.len(),
|
||||||
|
actual,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(decode_echo_reply(&malformed, ¶ms, key), Err(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,8 +47,6 @@ fn token_and_sequence() -> Vec<u8> {
|
|||||||
body
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- A. Classification ----------
|
|
||||||
|
|
||||||
fn classify(kind: &DecodedRequestKind<'_>) -> &'static str {
|
fn classify(kind: &DecodedRequestKind<'_>) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
DecodedRequestKind::Open { no_test: false, .. } => "open",
|
DecodedRequestKind::Open { no_test: false, .. } => "open",
|
||||||
@@ -71,6 +69,34 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
|
|||||||
(FLAG_OPEN | FLAG_CLOSE | FLAG_HMAC, true, "no-test open"),
|
(FLAG_OPEN | FLAG_CLOSE | FLAG_HMAC, true, "no-test open"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
for no_test in [false, true] {
|
||||||
|
for key in [None, Some(KEY)] {
|
||||||
|
let params = Params::default();
|
||||||
|
let packet = encode_request(
|
||||||
|
RequestToEncode::Open {
|
||||||
|
params: ¶ms,
|
||||||
|
no_test,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let expected_flags = FLAG_OPEN
|
||||||
|
| if no_test { FLAG_CLOSE } else { 0 }
|
||||||
|
| if key.is_some() { FLAG_HMAC } else { 0 };
|
||||||
|
assert_eq!(packet[3], expected_flags);
|
||||||
|
assert_eq!(
|
||||||
|
decode_request(&packet).unwrap().kind,
|
||||||
|
DecodedRequestKind::Open {
|
||||||
|
no_test,
|
||||||
|
params: &[]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if let Some(key) = key {
|
||||||
|
verify_packet_hmac(key, &packet).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (flags, hmac_present, expected) in cases {
|
for (flags, hmac_present, expected) in cases {
|
||||||
// Long enough for any kind, so only the flags decide classification.
|
// Long enough for any kind, so only the flags decide classification.
|
||||||
let packet = raw(flags, &token_and_sequence(), 64);
|
let packet = raw(flags, &token_and_sequence(), 64);
|
||||||
@@ -82,6 +108,10 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
|
|||||||
request.hmac_present, hmac_present,
|
request.hmac_present, hmac_present,
|
||||||
"flags 0x{flags:02x} HMAC presence"
|
"flags 0x{flags:02x} HMAC presence"
|
||||||
);
|
);
|
||||||
|
if let DecodedRequestKind::Open { params, .. } = request.kind {
|
||||||
|
let offset = if hmac_present { 20 } else { 4 };
|
||||||
|
assert_eq!(params, &packet[offset..]);
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(&request.kind),
|
classify(&request.kind),
|
||||||
expected,
|
expected,
|
||||||
@@ -90,8 +120,6 @@ fn flag_combinations_classify_requests_with_orthogonal_hmac_presence() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- B. Invalid header / flags ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structurally_invalid_headers_are_rejected() {
|
fn structurally_invalid_headers_are_rejected() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -135,8 +163,6 @@ fn any_reply_flagged_datagram_is_rejected_as_a_request() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- C. Structural minimum lengths ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structural_minimum_lengths_are_enforced_per_kind() {
|
fn structural_minimum_lengths_are_enforced_per_kind() {
|
||||||
// (flags, minimum accepted length)
|
// (flags, minimum accepted length)
|
||||||
@@ -172,8 +198,6 @@ fn structural_minimum_lengths_are_enforced_per_kind() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- D. Field offsets ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
|
fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
|
||||||
for hmac in [false, true] {
|
for hmac in [false, true] {
|
||||||
@@ -211,69 +235,25 @@ fn token_and_sequence_are_read_from_the_hmac_dependent_offsets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- E. Open parameter slice ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn open_params_are_borrowed_after_the_header_and_decoded_later() {
|
fn open_params_are_borrowed_after_the_header_and_decoded_later() {
|
||||||
let encoded = params().encode();
|
// Structural parsing accepts even malformed Params; semantic errors belong
|
||||||
|
// to Params::decode and are covered in params.rs.
|
||||||
|
for body in [params().encode(), Vec::new(), vec![1, 0x80]] {
|
||||||
for hmac in [false, true] {
|
for hmac in [false, true] {
|
||||||
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
|
let flags = FLAG_OPEN | if hmac { FLAG_HMAC } else { 0 };
|
||||||
let expected_offset = if hmac { 4 + HMAC_SIZE } else { 4 };
|
let offset = if hmac { 20 } else { 4 };
|
||||||
let packet = raw(flags, &encoded, expected_offset + encoded.len());
|
let packet = raw(flags, &body, offset + body.len());
|
||||||
|
|
||||||
match decode_request(&packet).unwrap().kind {
|
match decode_request(&packet).unwrap().kind {
|
||||||
DecodedRequestKind::Open { params, .. } => {
|
DecodedRequestKind::Open { params, .. } => {
|
||||||
assert_eq!(params, &packet[expected_offset..]);
|
assert_eq!(params, &packet[offset..]);
|
||||||
assert_eq!(Params::decode(params).unwrap(), self::params());
|
|
||||||
}
|
}
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
other => panic!("expected an open request, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_and_malformed_open_params_decode_structurally() {
|
|
||||||
let empty = header(FLAG_OPEN);
|
|
||||||
match decode_request(&empty).unwrap().kind {
|
|
||||||
DecodedRequestKind::Open { params, .. } => assert!(params.is_empty()),
|
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A truncated varint: structurally fine, semantically not. A receiver must
|
|
||||||
// be able to authenticate before spending effort on this.
|
|
||||||
let malformed = raw(FLAG_OPEN, &[1, 0x80], 6);
|
|
||||||
match decode_request(&malformed).unwrap().kind {
|
|
||||||
DecodedRequestKind::Open { params, .. } => {
|
|
||||||
assert_eq!(params, &[1, 0x80]);
|
|
||||||
assert_eq!(Params::decode(params), Err(ProtoError::TruncatedVarint));
|
|
||||||
}
|
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- F. Open precedence ----------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_precedence_beats_close_and_an_echo_shaped_body() {
|
|
||||||
// An OPEN|CLOSE datagram whose body looks exactly like a token and
|
|
||||||
// sequence number is still a no-test open, and those bytes are parameters.
|
|
||||||
let packet = raw(FLAG_OPEN | FLAG_CLOSE, &token_and_sequence(), 16);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
decode_request(&packet).unwrap(),
|
|
||||||
DecodedRequest {
|
|
||||||
hmac_present: false,
|
|
||||||
kind: DecodedRequestKind::Open {
|
|
||||||
no_test: true,
|
|
||||||
params: &packet[4..],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- G. Trailing data ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
|
fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
|
||||||
let mut close = raw(FLAG_CLOSE, &TOKEN.to_le_bytes(), 12);
|
let mut close = raw(FLAG_CLOSE, &TOKEN.to_le_bytes(), 12);
|
||||||
@@ -303,8 +283,6 @@ fn trailing_bytes_are_tolerated_and_no_length_ceiling_applies() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- H. Zero token ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_zero_token_is_structurally_valid() {
|
fn a_zero_token_is_structurally_valid() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -323,8 +301,6 @@ fn a_zero_token_is_structurally_valid() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- HMAC presence is not authentication ----------
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hmac_presence_is_reported_independently_of_mac_validity() {
|
fn hmac_presence_is_reported_independently_of_mac_validity() {
|
||||||
// A structurally complete authenticated echo whose MAC is garbage.
|
// A structurally complete authenticated echo whose MAC is garbage.
|
||||||
@@ -378,106 +354,3 @@ fn packet_verification_requires_a_present_and_complete_hmac_field() {
|
|||||||
Err(ProtoError::BadMagic)
|
Err(ProtoError::BadMagic)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Encode -> decode cross-checks ----------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encoded_requests_decode_back_to_their_sender_side_identity() {
|
|
||||||
for key in [None, Some(KEY)] {
|
|
||||||
for no_test in [false, true] {
|
|
||||||
let packet = encode_request(
|
|
||||||
RequestToEncode::Open {
|
|
||||||
params: ¶ms(),
|
|
||||||
no_test,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(request.hmac_present, key.is_some());
|
|
||||||
match request.kind {
|
|
||||||
DecodedRequestKind::Open {
|
|
||||||
no_test: decoded,
|
|
||||||
params: encoded,
|
|
||||||
} => {
|
|
||||||
assert_eq!(decoded, no_test);
|
|
||||||
assert_eq!(Params::decode(encoded).unwrap(), params());
|
|
||||||
}
|
|
||||||
other => panic!("expected an open request, got {other:?}"),
|
|
||||||
}
|
|
||||||
if let Some(key) = key {
|
|
||||||
verify_packet_hmac(key, &packet).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let packet = encode_request(RequestToEncode::Close { token: TOKEN }, key).unwrap();
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(request.hmac_present, key.is_some());
|
|
||||||
assert_eq!(request.kind, DecodedRequestKind::Close { token: TOKEN });
|
|
||||||
|
|
||||||
// The ECHO tail is deliberately not compared against the logical
|
|
||||||
// payload: the sender placed it at a negotiated offset the receiver
|
|
||||||
// cannot recover without `Params`.
|
|
||||||
let echo_params = Params {
|
|
||||||
length: 96,
|
|
||||||
..params()
|
|
||||||
};
|
|
||||||
let packet = encode_request(
|
|
||||||
RequestToEncode::Echo {
|
|
||||||
token: TOKEN,
|
|
||||||
sequence: SEQUENCE,
|
|
||||||
params: &echo_params,
|
|
||||||
payload: &[1, 2, 3, 4],
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(request.hmac_present, key.is_some());
|
|
||||||
match request.kind {
|
|
||||||
DecodedRequestKind::Echo {
|
|
||||||
token, sequence, ..
|
|
||||||
} => {
|
|
||||||
assert_eq!(token, TOKEN);
|
|
||||||
assert_eq!(sequence, SEQUENCE);
|
|
||||||
}
|
|
||||||
other => panic!("expected an echo request, got {other:?}"),
|
|
||||||
}
|
|
||||||
if let Some(key) = key {
|
|
||||||
verify_packet_hmac(key, &packet).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn echo_encoding_floors_a_negative_negotiated_length_at_the_field_block() {
|
|
||||||
// A negative negotiated length is accepted during open and echoed back
|
|
||||||
// unchanged, so a real session can carry one. It must therefore stay
|
|
||||||
// encodable: it asks for no space beyond the mandatory fields.
|
|
||||||
let params = Params {
|
|
||||||
length: -1,
|
|
||||||
..Params::default()
|
|
||||||
};
|
|
||||||
let packet = encode_request(
|
|
||||||
RequestToEncode::Echo {
|
|
||||||
token: TOKEN,
|
|
||||||
sequence: SEQUENCE,
|
|
||||||
params: ¶ms,
|
|
||||||
payload: &[],
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("a negative negotiated length must still encode");
|
|
||||||
|
|
||||||
// header (4) + token (8) + sequence (4).
|
|
||||||
assert_eq!(packet.len(), 16);
|
|
||||||
let request = decode_request(&packet).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
request.kind,
|
|
||||||
DecodedRequestKind::Echo {
|
|
||||||
token: TOKEN,
|
|
||||||
sequence: SEQUENCE,
|
|
||||||
tail: &[],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use irtt_proto::{varint::*, ProtoError};
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_values_round_trip() {
|
||||||
|
for value in [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
127,
|
||||||
|
128,
|
||||||
|
255,
|
||||||
|
16_384,
|
||||||
|
u32::MAX as u64,
|
||||||
|
i64::MAX as u64,
|
||||||
|
u64::MAX,
|
||||||
|
] {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_uvarint(value, &mut encoded);
|
||||||
|
assert_eq!(decode_uvarint(&encoded), Ok((value, encoded.len())));
|
||||||
|
}
|
||||||
|
for value in [
|
||||||
|
i64::MIN,
|
||||||
|
-9_223_372_036,
|
||||||
|
-2,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1_000_000_000,
|
||||||
|
3_000_000_000,
|
||||||
|
i64::MAX,
|
||||||
|
] {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_varint(value, &mut encoded);
|
||||||
|
assert_eq!(decode_varint(&encoded), Ok((value, encoded.len())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_byte_examples() {
|
||||||
|
let cases: &[(i64, &[u8])] = &[
|
||||||
|
(1, &[0x02]),
|
||||||
|
(3_000_000_000, &[0x80, 0xf8, 0x82, 0xad, 0x16]),
|
||||||
|
(1_000_000_000, &[0x80, 0xa8, 0xd6, 0xb9, 0x07]),
|
||||||
|
(1472, &[0x80, 0x17]),
|
||||||
|
(3, &[0x06]),
|
||||||
|
(184, &[0xf0, 0x02]),
|
||||||
|
];
|
||||||
|
for (value, expected) in cases {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_varint(*value, &mut encoded);
|
||||||
|
assert_eq!(&encoded, expected);
|
||||||
|
assert_eq!(decode_varint(expected), Ok((*value, expected.len())));
|
||||||
|
}
|
||||||
|
for tag in [9, 24] {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_uvarint(tag, &mut encoded);
|
||||||
|
assert_eq!(encoded, [tag as u8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_and_overflowing_varints_are_rejected() {
|
||||||
|
// Incomplete input differs from a tenth byte whose bits cannot fit u64,
|
||||||
|
// or an eleventh byte after ten continuation bytes.
|
||||||
|
for (bytes, expected) in [
|
||||||
|
(vec![], ProtoError::TruncatedVarint),
|
||||||
|
(vec![0x80], ProtoError::TruncatedVarint),
|
||||||
|
(vec![0x80; 10], ProtoError::TruncatedVarint),
|
||||||
|
(
|
||||||
|
[vec![0x80; 9], vec![2]].concat(),
|
||||||
|
ProtoError::VarintOverflow,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[vec![0x80; 10], vec![0]].concat(),
|
||||||
|
ProtoError::VarintOverflow,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(decode_uvarint(&bytes), Err(expected), "{bytes:02x?}");
|
||||||
|
assert_eq!(
|
||||||
|
decode_varint(&bytes),
|
||||||
|
decode_uvarint(&bytes).map(|(v, n)| (zigzag_decode(v), n))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#[test]
|
||||||
|
fn uvarint_round_trips(value: u64) {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_uvarint(value, &mut encoded);
|
||||||
|
prop_assert!(!encoded.is_empty());
|
||||||
|
prop_assert!(encoded.len() <= 10);
|
||||||
|
prop_assert_eq!(decode_uvarint(&encoded).unwrap(), (value, encoded.len()));
|
||||||
|
prop_assert_eq!(zigzag_encode(zigzag_decode(value)), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_varint_round_trips(value: i64) {
|
||||||
|
let mut encoded = Vec::new();
|
||||||
|
encode_varint(value, &mut encoded);
|
||||||
|
prop_assert!(!encoded.is_empty());
|
||||||
|
prop_assert!(encoded.len() <= 10);
|
||||||
|
prop_assert_eq!(decode_varint(&encoded).unwrap(), (value, encoded.len()));
|
||||||
|
prop_assert_eq!(zigzag_decode(zigzag_encode(value)), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "irtt-stats"
|
name = "irtt-stats"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
description = "Statistics aggregation for irtt-client events"
|
description = "Statistics aggregation for irtt-client events"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|||||||
+44
-1
@@ -58,6 +58,41 @@ once per interval instead. Final per-target summaries print in the order
|
|||||||
targets were supplied on the command line, not the order in which they
|
targets were supplied on the command line, not the order in which they
|
||||||
finish or their labels sort alphabetically.
|
finish or their labels sort alphabetically.
|
||||||
|
|
||||||
|
## ADDRESS FAMILY
|
||||||
|
|
||||||
|
By default, each target uses the first address returned by the system resolver,
|
||||||
|
whether IPv4 or IPv6. Use `-4` / `--ipv4` to select IPv4 only, or
|
||||||
|
`-6` / `--ipv6` to select IPv6 only. Hostnames are resolved when each session
|
||||||
|
connects; these flags do not perform an extra discovery lookup. An explicit
|
||||||
|
IP literal from the other family is rejected during preparation.
|
||||||
|
|
||||||
|
Use `--dual-stack` to compare IPv4 and IPv6 paths as separate measurements:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-client -6 host.example
|
||||||
|
irtt-client --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||||
|
```
|
||||||
|
|
||||||
|
For each hostname, the client discovers which address families the system
|
||||||
|
resolver currently returns. IPv4 results create `cov/v4`, IPv6 results create
|
||||||
|
`cov/v6`, and both create both targets, in that order. Without an alias,
|
||||||
|
`host.example` becomes `host.example/v4` and/or `host.example/v6`. Multiple
|
||||||
|
addresses within one family still produce only one target. Explicit IP literals
|
||||||
|
keep their original label and remain single targets.
|
||||||
|
|
||||||
|
Each target has its own session, statistics, and failures. Its original hostname
|
||||||
|
is retained: every new session resolves it again and selects the first address
|
||||||
|
matching its family. Families are discovered only when a declaration is added,
|
||||||
|
so a newly appearing family does not create another target automatically.
|
||||||
|
Discovery failure or a successful lookup returning no addresses rejects the
|
||||||
|
whole target set. A missing family is normal and simply creates no target for
|
||||||
|
that family. Final labels must be unique, including generated `/v4` and `/v6`
|
||||||
|
labels; choose another alias if one collides with an explicit label.
|
||||||
|
|
||||||
|
`-4`, `-6`, and `--dual-stack` are mutually exclusive. Expanded targets use
|
||||||
|
ordinary multi-target pacing and each sends its own probes, so dual-stack can
|
||||||
|
double the number of probes per interval compared with one target per hostname.
|
||||||
|
|
||||||
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
## DYNAMIC TARGET SETS FROM STANDARD INPUT
|
||||||
|
|
||||||
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
||||||
@@ -75,6 +110,13 @@ are retired, unchanged target configurations retain their generation, and a
|
|||||||
changed address or HMAC setting creates a fresh generation. Under transient
|
changed address or HMAC setting creates a fresh generation. Under transient
|
||||||
live-generation backpressure, only the latest unapplied desired set is kept.
|
live-generation backpressure, only the latest unapplied desired set is kept.
|
||||||
|
|
||||||
|
The same address-family preparation applies to initial and stdin targets.
|
||||||
|
With `--dual-stack`, unchanged labels and endpoints retain their discovered
|
||||||
|
families across records, including HMAC-only changes. Removing a declaration
|
||||||
|
and later adding it again performs fresh discovery. New declarations are
|
||||||
|
prepared asynchronously while existing measurements continue. A preparation
|
||||||
|
error stops the stream gracefully without applying a partial set.
|
||||||
|
|
||||||
Commas frame targets in stdin records; escape a literal comma within one
|
Commas frame targets in stdin records; escape a literal comma within one
|
||||||
target as `\,`. Target parsing inside each element is otherwise the same as
|
target as `\,`. Target parsing inside each element is otherwise the same as
|
||||||
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
for positional arguments, including the other escapes above, `@hmac=KEY`, and
|
||||||
@@ -107,7 +149,8 @@ revision to become applicable.
|
|||||||
|
|
||||||
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
The maximum stdin record payload is 64 KiB. Stdin-controlled mode bounds each
|
||||||
desired set to 128 targets and retains at most 256 live target generations
|
desired set to 128 targets and retains at most 256 live target generations
|
||||||
while replacements drain.
|
while replacements drain. The 128-target limit applies after dual-stack
|
||||||
|
expansion: a hostname producing both families consumes two target slots.
|
||||||
|
|
||||||
## FINITE VERSUS CONTINUOUS OPERATION
|
## FINITE VERSUS CONTINUOUS OPERATION
|
||||||
|
|
||||||
|
|||||||
+106
-16
@@ -21,6 +21,21 @@ Target syntax, multiple targets, and pacing are identical to
|
|||||||
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
||||||
well; consult that manual for their meaning.
|
well; consult that manual for their meaning.
|
||||||
|
|
||||||
|
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
|
||||||
|
`-6` / `--ipv6` uses IPv6 only, and `--dual-stack` gives each available family
|
||||||
|
of a hostname its own measurement row and graph series. These switches are
|
||||||
|
mutually exclusive. Explicit IP literals remain single targets; a literal
|
||||||
|
conflicting with `-4` or `-6` is rejected before the dashboard starts.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
irtt-tui -4 uk-cov1.irtt.lochnair.net
|
||||||
|
irtt-tui --dual-stack cov=uk-cov1.irtt.lochnair.net
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command creates `cov/v4` and/or `cov/v6` according to the system
|
||||||
|
resolver's initial results, letting you compare the two paths directly.
|
||||||
|
See the client manual's address-family section for discovery errors and labels.
|
||||||
|
|
||||||
## CONTINUOUS DEFAULT
|
## CONTINUOUS DEFAULT
|
||||||
|
|
||||||
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
||||||
@@ -31,32 +46,102 @@ a finite run:
|
|||||||
irtt-tui <server> --duration 30s
|
irtt-tui <server> --duration 30s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Continuous runs reconnect after target-local failures, peer closure, or a
|
||||||
|
session completing because the server restricted its duration. After a
|
||||||
|
1.5-second delay, one coalesced retry reopens completed targets while healthy
|
||||||
|
sessions continue undisturbed. Even if every target fails, the TUI stays alive
|
||||||
|
until you quit. Explicit stops, removed or replaced targets, and no-test
|
||||||
|
completion do not trigger retries. Quitting cancels pending retry work.
|
||||||
|
|
||||||
|
With `--dual-stack`, a failed family reconnects independently while its healthy
|
||||||
|
sibling continues. Reconnect resolves the original hostname again within the
|
||||||
|
target's family. It does not discover additional families or pin the address
|
||||||
|
returned by initial discovery.
|
||||||
|
|
||||||
|
Each reconnect keeps the same logical target row. Statistics reset when a new
|
||||||
|
session generation is accepted, including attempts that fail before opening.
|
||||||
|
Retained graph history and the age of the last actual reply remain; the graph
|
||||||
|
leaves a gap between generations instead of connecting their samples.
|
||||||
|
|
||||||
|
## DASHBOARD
|
||||||
|
|
||||||
|
One dashboard is used for every target count and terminal size. It shows:
|
||||||
|
|
||||||
|
- Run status, elapsed time, and finite duration or continuous mode.
|
||||||
|
- The selected target's label and remote address, plus latest signed
|
||||||
|
client-to-server and server-to-client delays (`-` means unavailable).
|
||||||
|
- Target rows with latest effective RTT, cumulative loss percentage, jitter
|
||||||
|
(standard deviation of round-trip IPDV), and age of the last primary reply.
|
||||||
|
- A graph overlaying all targets, with matching target colors and a visible
|
||||||
|
live/history indicator and window size.
|
||||||
|
|
||||||
|
An active session with no primary replies shows `waiting`. Reply age makes a
|
||||||
|
stale RTT distinguishable from a fresh measurement. Loss follows the shared
|
||||||
|
statistics collector: outstanding sends can temporarily contribute to the
|
||||||
|
cumulative loss estimate. Jitter is `-` until an IPDV pair is available.
|
||||||
|
|
||||||
|
`Tab` / `Shift-Tab` selects a target, bringing its row into view when necessary.
|
||||||
|
Up to six target rows are shown; smaller terminals show fewer. Selecting a target
|
||||||
|
changes the identification, one-way summary and details; the graph continues to
|
||||||
|
compare every target.
|
||||||
|
|
||||||
|
`d` opens a details sheet in the graph's space. It contains the selected target's
|
||||||
|
session and negotiation, packet counters, progress, latest timing sample, full
|
||||||
|
timing statistics, warnings, and recent events from all targets. Scroll it with
|
||||||
|
the arrows or page keys. Long lines and timing columns can be read with horizontal
|
||||||
|
scrolling, including full labels and remote addresses that do not fit in the
|
||||||
|
header. `d` or `Esc` returns to the graph, preserving its metric and viewport.
|
||||||
|
The former large/compact Dashboard screens have been replaced by this sheet;
|
||||||
|
`g` remains an alias for `d`.
|
||||||
|
|
||||||
|
The minimum terminal size is 56 columns by 18 rows. Below that size the UI asks
|
||||||
|
for a resize and continues acquiring measurements. Resize and important status
|
||||||
|
redraws still work while the display is paused.
|
||||||
|
|
||||||
## CONTROLS
|
## CONTROLS
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `q`, `Ctrl-C` | Quit |
|
| `q`, `Ctrl-C` | Quit |
|
||||||
| `p` | Toggle display pause (probing continues; only redraws stop) |
|
| `p` | Toggle display pause (probing and acquisition continue) |
|
||||||
| `g` | Toggle Graph / Dashboard view |
|
| `Tab` / `Shift-Tab` | Select next / previous target |
|
||||||
| `m` | Cycle graph metric |
|
| `d`, `g` | Toggle target details |
|
||||||
| `r` | Clear visible graph history |
|
| `Esc` | Close target details |
|
||||||
| `←` / `→` | Pan graph |
|
| `r` | Clear graph history for all targets; keep latest samples and statistics |
|
||||||
| `PageUp` / `PageDown` | Page-pan graph |
|
| `m` | Cycle graph metric (graph visible) |
|
||||||
| `Home` / `End` | Jump to oldest / jump to live |
|
| `←` / `→` | Pan graph; scroll columns in details |
|
||||||
| `+` / `=` | Zoom graph in |
|
| `↑` / `↓` | Scroll details |
|
||||||
| `-` | Zoom graph out |
|
| `PageUp` / `PageDown` | Page-pan graph; page-scroll details |
|
||||||
| `0` | Reset graph window and zoom |
|
| `Home` / `End` | Oldest / live graph; top / bottom of details |
|
||||||
|
| `+` / `=` | Zoom graph in (graph visible) |
|
||||||
|
| `-` | Zoom graph out (graph visible) |
|
||||||
|
| `0` | Reset graph window and zoom, keeping live/history position (graph visible) |
|
||||||
|
|
||||||
|
The six graph metrics are effective RTT, raw RTT, adjusted RTT,
|
||||||
|
client-to-server delay, server-to-client delay, and server processing time.
|
||||||
|
Signed effective/adjusted RTT and one-way values are preserved. Graph windows
|
||||||
|
range from 5 seconds to 24 hours and default to one minute. The footer shows
|
||||||
|
controls appropriate to the currently open graph or details sheet.
|
||||||
|
|
||||||
## RETAINED HISTORY
|
## RETAINED HISTORY
|
||||||
|
|
||||||
The TUI keeps its own bounded presentation state on top of the statistics
|
The TUI keeps its own bounded presentation state on top of the statistics
|
||||||
retention described in `irtt-client(1)`:
|
retention described in `irtt-client(1)`:
|
||||||
|
|
||||||
- Up to 100,000 graph samples per target.
|
- Up to 500,000 graph samples per target.
|
||||||
- Up to 80 recent status/log messages.
|
- Up to 80 recent status/log messages.
|
||||||
|
|
||||||
`r` clears the currently visible graph history and returns the viewport to
|
At 500,000 replies per target, retention is approximately 13h53m20s at
|
||||||
live; it does not change the 100,000-sample cap itself. These are
|
100ms intervals, 27h46m40s at 200ms, or 5d18h53m20s at 1s. Retained graph
|
||||||
|
samples store both clocks, six graph-ready values, and a session-boundary
|
||||||
|
flag; latest details keep the full typed timing values and sequence number.
|
||||||
|
On 64-bit macOS, an 88-byte retained sample gives about 42 MiB per target
|
||||||
|
at the cap, excluding statistics and temporary rendering buffers. Histories
|
||||||
|
grow on demand and reserve exactly to the cap on their final growth step;
|
||||||
|
they do not preallocate the full history for every target at startup.
|
||||||
|
|
||||||
|
`r` clears graph history for all targets and returns the viewport to
|
||||||
|
live; it does not change the 500,000-sample cap itself. These are
|
||||||
presentation bounds on top of the client's own statistics retention (see
|
presentation bounds on top of the client's own statistics retention (see
|
||||||
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
|
`irtt-client(1)` MEASUREMENTS AND MEMORY) — a long continuous TUI session's
|
||||||
total memory is dominated by whichever of the two is larger for your target
|
total memory is dominated by whichever of the two is larger for your target
|
||||||
@@ -72,9 +157,14 @@ corrupt the display. Size a long finite TUI run from the figures in
|
|||||||
|
|
||||||
## EXIT BEHAVIOR
|
## EXIT BEHAVIOR
|
||||||
|
|
||||||
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. Otherwise,
|
Quitting with `q` or `Ctrl-C` is an interrupted, successful exit. In continuous
|
||||||
exit status follows the same peer-close and driver-failure rules as
|
mode, peer closure and target failures trigger reconnect rather than ending the
|
||||||
`irtt-client(1)`.
|
run. A managed driver failure or an unexpected reconnect-update failure still
|
||||||
|
ends the TUI with an error.
|
||||||
|
|
||||||
|
Finite runs do not reconnect. They complete after their target sessions end;
|
||||||
|
a peer closure is a normal session ending, while a run whose targets all fail
|
||||||
|
exits with an error.
|
||||||
|
|
||||||
## EXAMPLES
|
## EXAMPLES
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user