Compare commits

..
Author SHA1 Message Date
Nils Andreas Svee 7da8d2141c chore(release): irtt-rs 0.9.0
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 12s
Rust CI / cargo clippy (push) Failing after 35s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 38s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 7s
Netem Interop / netem interop (push) Failing after 14s
Supply chain / cargo-deny advisories (push) Failing after 4s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny bans licenses sources (push) Failing after 5s
Supply chain / actionlint (push) Successful in 5s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m22s
Semver / cargo-semver-checks (push) Successful in 55s
Publish irtt-server image / publish (push) Successful in 1m3s
2026-10-21 01:31:32 +07:00
renovate Bot bdb8c13d7d chore(deps): update github actions 2026-10-21 01:24:56 +07:00
renovate Bot a04ea5727b chore(deps): maintain cargo lockfile 2026-10-21 01:24:56 +07:00
renovate Bot ab5f1ac807 fix(deps): update rust crate arbitrary to 1.5.0 2026-10-21 01:24:01 +07:00
renovate Bot 45b0c8549d chore(deps): update rust crate proptest to 1.12.0 2026-10-21 01:24:00 +07:00
Nils Andreas Svee 18a8e307fb feat(app): allow hexadecimal HMAC key specification and redact keys from debug output 2026-10-21 01:19:59 +07:00
Nils Andreas Svee ab241c4a2b feat(app/client): expose socket bind options 2026-10-21 00:33:46 +07:00
Nils Andreas Svee 38e912632d chore(release): irtt-client 0.6.0, irtt-stats 0.5.2, irtt-rs 0.8.0
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo doc (push) Successful in 27s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m11s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 1m42s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 1m44s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / cargo-deny advisories (push) Successful in 8s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 33s
Netem Interop / netem interop (push) Successful in 1m8s
Supply chain / actionlint (push) Successful in 3s
Publish irtt-server image / publish (push) Successful in 1m8s
Semver / cargo-semver-checks (push) Successful in 54s
2026-10-07 15:35:32 +07:00
Nils Andreas Svee 39cbf7b6b5 Merge branch 'feat/dual-stack'
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 35s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 1m17s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 30s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / cargo-deny advisories (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m7s
2026-10-07 14:26:50 +07:00
Nils Andreas Svee eaa37d7568 Merge branch 'feat/refactor-tui-history' 2026-10-07 14:25:49 +07:00
Nils Andreas Svee da7472e10a feat(app): add address-family selection and dual-stack targets 2026-10-07 14:17:11 +07:00
Nils Andreas Svee 9e6c10c083 refactor(tui): compact graph history and retain 500k samples 2026-10-07 12:41:49 +07:00
33 changed files with 946 additions and 298 deletions

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
# It remains here while portable actionlint runs with Gitea CI, but scans # It remains here while portable actionlint runs with Gitea CI, but scans
# both workflow directories through its GitHub Actions rule set. # both workflow directories through its GitHub Actions rule set.
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1
with: with:
persona: regular persona: regular
inputs: | inputs: |
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
# the old --skip-git flag is no longer supported. # the old --skip-git flag is no longer supported.
scan-scheduled: scan-scheduled:
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} 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@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
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@b1d267aea5211fa648d9d442fa64b90dbdcbdd36" # v2.7.0
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
exit 0 exit 0
- name: Upload Clippy SARIF - name: Upload Clippy SARIF
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4 uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
if: always() if: always()
with: with:
sarif_file: rust-clippy-results.sarif sarif_file: rust-clippy-results.sarif
+110
View File
@@ -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,62 @@ release. The format is loosely based on
## irtt-rs ## irtt-rs
### 0.9.0
#### Added
- Client and TUI `--bind-addr IP[:PORT]` selects a local source address and optional UDP port. Omitted ports and port `0` use an ephemeral port; IPv6 addresses with an explicit port use `[ADDR]:PORT`. The bind address must match the target's address family, and fixed ports can conflict across multiple target sockets.
- Client and TUI `--bind-device DEVICE` binds sockets to a network interface on Linux, Android, and Fuchsia.
- Client and TUI `--mark MARK` sets a socket firewall mark for policy routing or firewall rules on Linux, Android, and Fuchsia. It accepts decimal and `0x`-prefixed hexadecimal unsigned 32-bit values.
- Client and TUI `--fib FIB` selects a routing table on FreeBSD, accepting a decimal unsigned 32-bit value.
#### Fixed
- Restored CLI compatibility with upstream IRTT for hexadecimal HMAC keys: client, TUI, and server `--hmac`, server `IRTT_SERVER_HMAC`, and client/TUI per-target `@hmac=KEY` accept a lowercase `0x` prefix followed by hexadecimal bytes, with either case allowed for digits. Literal UTF-8 keys remain supported; the HMAC-MD5 algorithm is unchanged.
- Server argument debug output now redacts HMAC keys, matching the client and TUI. Server help hides the `IRTT_SERVER_HMAC` value, and invalid global HMAC keys are rejected during configuration preparation without echoing key material.
#### Compatibility
- Keys beginning with literal `0x` are now interpreted as hexadecimal. Empty keys, empty hexadecimal keys, odd-length hexadecimal strings, and invalid hexadecimal digits are rejected. Per-target `@hmac=` continues to disable authentication explicitly.
### 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
+235 -146
View File
@@ -123,11 +123,11 @@ dependencies = [
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
dependencies = [ dependencies = [
"bit-vec 0.8.0", "bit-vec 0.10.1",
] ]
[[package]] [[package]]
@@ -138,9 +138,13 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]] [[package]]
name = "bit-vec" name = "bit-vec"
version = "0.8.0" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
dependencies = [
"borsh",
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
@@ -181,6 +185,30 @@ dependencies = [
"objc2", "objc2",
] ]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -199,6 +227,12 @@ version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "castaway" name = "castaway"
version = "0.2.4" version = "0.2.4"
@@ -210,9 +244,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.6.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -230,6 +264,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 = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -251,6 +296,15 @@ dependencies = [
"clap_derive", "clap_derive",
] ]
[[package]]
name = "clap-num"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "822c4000301ac390e65995c62207501e3ef800a1fc441df913a5e8e4dc374816"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.7" version = "4.6.7"
@@ -272,7 +326,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -328,6 +382,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -337,6 +397,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "critical-section" name = "critical-section"
version = "1.2.0" version = "1.2.0"
@@ -413,9 +482,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",
] ]
@@ -440,7 +509,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -451,7 +520,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -462,9 +531,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]] [[package]]
name = "derive_more" name = "derive_more"
@@ -533,15 +602,15 @@ 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"
version = "1.0.2" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]] [[package]]
name = "errno" name = "errno"
@@ -591,9 +660,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]] [[package]]
name = "finl_unicode" name = "finl_unicode"
@@ -674,17 +743,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
] "rand_core 0.10.1",
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
] ]
[[package]] [[package]]
@@ -759,10 +818,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "indoc" name = "indexmap"
version = "2.0.7" version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "indoc"
version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
dependencies = [ dependencies = [
"rustversion", "rustversion",
] ]
@@ -777,12 +846,12 @@ dependencies = [
"indoc", "indoc",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[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",
@@ -815,13 +884,15 @@ dependencies = [
[[package]] [[package]]
name = "irtt-rs" name = "irtt-rs"
version = "0.7.1" version = "0.9.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
"clap-num",
"crossterm", "crossterm",
"ctrlc", "ctrlc",
"futures-core", "futures-core",
"hex",
"irtt-client", "irtt-client",
"irtt-proto", "irtt-proto",
"irtt-server", "irtt-server",
@@ -845,7 +916,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",
@@ -886,11 +957,11 @@ dependencies = [
[[package]] [[package]]
name = "kasuari" name = "kasuari"
version = "0.4.12" version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown",
"portable-atomic", "portable-atomic",
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
@@ -909,9 +980,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.190" version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]] [[package]]
name = "libm" name = "libm"
@@ -936,9 +1007,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "litrs" name = "litrs"
version = "1.0.0" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
@@ -961,16 +1032,16 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [ dependencies = [
"hashbrown 0.17.1", "hashbrown",
] ]
[[package]] [[package]]
name = "mac_address" name = "mac_address"
version = "1.2.0" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a" checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
dependencies = [ dependencies = [
"nix 0.30.1", "nix 0.31.3",
"windows-sys", "windows-sys",
] ]
@@ -1041,19 +1112,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.31.3" version = "0.31.3"
@@ -1105,18 +1163,18 @@ dependencies = [
[[package]] [[package]]
name = "num_threads" name = "num_threads"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
dependencies = [ dependencies = [
"libc", "libc",
] ]
[[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",
] ]
@@ -1206,19 +1264,21 @@ dependencies = [
[[package]] [[package]]
name = "pest" name = "pest"
version = "2.9.2" version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23"
dependencies = [ dependencies = [
"memchr", "memchr",
"psm",
"stacker",
"ucd-trie", "ucd-trie",
] ]
[[package]] [[package]]
name = "pest_derive" name = "pest_derive"
version = "2.9.2" version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44"
dependencies = [ dependencies = [
"pest", "pest",
"pest_generator", "pest_generator",
@@ -1226,22 +1286,22 @@ dependencies = [
[[package]] [[package]]
name = "pest_generator" name = "pest_generator"
version = "2.9.2" version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.9",
] ]
[[package]] [[package]]
name = "pest_meta" name = "pest_meta"
version = "2.9.2" version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710"
dependencies = [ dependencies = [
"pest", "pest",
] ]
@@ -1312,17 +1372,17 @@ 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 = "proc-macro-crate"
version = "0.2.21" version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"zerocopy", "toml_edit",
] ]
[[package]] [[package]]
@@ -1336,16 +1396,17 @@ dependencies = [
[[package]] [[package]]
name = "proptest" name = "proptest"
version = "1.11.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [ dependencies = [
"bit-set 0.8.0", "bit-set 0.11.1",
"bit-vec 0.8.0", "bit-vec 0.10.1",
"bitflags 2.13.2", "bitflags 2.13.2",
"chacha20",
"core_detect",
"num-traits", "num-traits",
"rand 0.9.5", "rand 0.10.3",
"rand_chacha",
"rand_xorshift", "rand_xorshift",
"regex-syntax", "regex-syntax",
"rusty-fork", "rusty-fork",
@@ -1353,6 +1414,15 @@ dependencies = [
"unarray", "unarray",
] ]
[[package]]
name = "psm"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "1.2.3" version = "1.2.3"
@@ -1391,22 +1461,12 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.5" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [ dependencies = [
"rand_chacha", "getrandom 0.4.3",
"rand_core 0.9.5", "rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@@ -1417,20 +1477,17 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.9.5" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_xorshift" name = "rand_xorshift"
version = "0.4.0" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [ dependencies = [
"rand_core 0.9.5", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1458,7 +1515,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"compact_str", "compact_str",
"critical-section", "critical-section",
"hashbrown 0.17.1", "hashbrown",
"itertools", "itertools",
"kasuari", "kasuari",
"lru", "lru",
@@ -1521,7 +1578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"hashbrown 0.17.1", "hashbrown",
"indoc", "indoc",
"instability", "instability",
"itertools", "itertools",
@@ -1657,7 +1714,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -1667,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest 0.10.7", "digest 0.10.7",
] ]
@@ -1722,9 +1779,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.2" version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]] [[package]]
name = "socket2" name = "socket2"
@@ -1736,6 +1793,19 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -1799,9 +1869,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.6" version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1934,7 +2004,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
] ]
[[package]] [[package]]
@@ -1960,9 +2030,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.2" version = "1.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
dependencies = [ dependencies = [
"libc", "libc",
"mio", "mio",
@@ -1980,7 +2050,37 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.17+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow",
] ]
[[package]] [[package]]
@@ -2003,9 +2103,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.26" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
@@ -2038,9 +2138,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.27.0" version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
dependencies = [ dependencies = [
"atomic", "atomic",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -2119,7 +2219,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.6", "syn 3.0.9",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -2294,28 +2394,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
+2 -2
View File
@@ -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"
+4 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "irtt-rs" name = "irtt-rs"
version = "0.7.1" version = "0.9.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
@@ -49,15 +49,17 @@ tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary
[dependencies] [dependencies]
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true } 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"] }
clap-num = "=1.2.0"
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true } crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
futures-core = { version = "0.3.34", default-features = false, optional = true } futures-core = { version = "0.3.34", default-features = false, optional = true }
hex = "0.4.3"
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", "sync", "macros"], 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
+3 -2
View File
@@ -16,7 +16,7 @@ pub struct ClientArgs {
value_name = "TARGET", value_name = "TARGET",
num_args = 0.., num_args = 0..,
value_parser = parse_target, value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target." long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
)] )]
pub targets: Vec<TargetArg>, pub targets: Vec<TargetArg>,
@@ -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 {
+46 -12
View File
@@ -8,7 +8,7 @@ use std::{
use irtt_client::{ use irtt_client::{
managed::{ managed::{
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus, ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
}, },
ClientEvent, ClientEvent,
}; };
@@ -22,7 +22,7 @@ use crate::shared::client::{
request_managed_stop_once, should_print_final_summary, request_managed_stop_once, should_print_final_summary,
}, },
worker::ManagedWorker, worker::ManagedWorker,
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT, ManagedRunSetup, TargetSpec, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
}; };
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig}; use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
@@ -46,9 +46,10 @@ pub async fn run_stream(
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,
@@ -191,7 +192,7 @@ pub async fn run_stream(
#[derive(Clone)] #[derive(Clone)]
enum StdinUpdate { enum StdinUpdate {
Targets(Vec<ManagedTargetConfig>), Targets(Vec<TargetSpec>),
Stop(StdinStop), Stop(StdinStop),
} }
@@ -286,9 +287,7 @@ fn read_stdin_target_sets<R: BufRead>(
} }
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) { match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
Ok(targets) => { Ok(targets) => {
updates.send_replace(StdinUpdate::Targets( updates.send_replace(StdinUpdate::Targets(targets));
targets.into_iter().map(|target| target.managed).collect(),
));
} }
Err(error) => { Err(error) => {
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!( updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
@@ -449,6 +448,9 @@ async 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 stdin_changed = false;
let mut preparation = setup.preparation.clone();
let mut desired_specs = None;
let mut preparing = None;
let mut desired = None; let mut desired = None;
let mut pending = None; let mut pending = None;
let mut submitted = Vec::new(); let mut submitted = Vec::new();
@@ -465,13 +467,25 @@ async fn run_stdin_stream(
} else if stdin_changed || stdin.has_changed().unwrap_or(true) { } else if stdin_changed || stdin.has_changed().unwrap_or(true) {
stdin_changed = false; stdin_changed = false;
match stdin.borrow_and_update().clone() { match stdin.borrow_and_update().clone() {
StdinUpdate::Targets(targets) => { StdinUpdate::Targets(specs) => {
desired = Some(targets); desired_specs = Some(specs);
desired = None;
retry_after = None; retry_after = None;
} }
StdinUpdate::Stop(reason) => stop = Some(reason), StdinUpdate::Stop(reason) => stop = Some(reason),
} }
} }
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() if stop.is_none()
&& pending.is_none() && pending.is_none()
&& !retry_after && !retry_after
@@ -494,6 +508,7 @@ async fn run_stdin_stream(
} }
} }
if stop.is_some() { if stop.is_some() {
preparing = None;
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot)); 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());
@@ -508,6 +523,25 @@ async fn run_stdin_stream(
_ = shutdown.changed(), if stop.is_none() => {} _ = shutdown.changed(), if stop.is_none() => {}
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; } _ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
changed = status.changed() => { if changed.is_err() { break; } } 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() => { result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
pending = None; pending = None;
match result { match result {
@@ -515,7 +549,7 @@ async fn run_stdin_stream(
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => { Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
// A newer desired set supersedes this rejected set. Otherwise, // A newer desired set supersedes this rejected set. Otherwise,
// retry only after the driver's status advances from submission. // retry only after the driver's status advances from submission.
if desired.is_none() { if desired.is_none() && desired_specs.is_none() && preparing.is_none() {
desired = Some(std::mem::take(&mut submitted)); desired = Some(std::mem::take(&mut submitted));
} else { } else {
retry_after = None; retry_after = None;
+24 -16
View File
@@ -12,7 +12,7 @@ const DEFAULT_PORT: u16 = 2112;
// Every policy option is optional. An omitted option leaves the corresponding // Every policy option is optional. An omitted option leaves the corresponding
// `ServerConfig` default in place rather than restating it here, so library and // `ServerConfig` default in place rather than restating it here, so library and
// CLI defaults cannot drift apart. // CLI defaults cannot drift apart.
#[derive(Debug, Clone, Parser)] #[derive(Clone, Parser)]
#[command( #[command(
name = "irtt-server", name = "irtt-server",
about = "Minimal IRTT-compatible UDP server", about = "Minimal IRTT-compatible UDP server",
@@ -28,13 +28,14 @@ pub struct ServerArgs {
)] )]
pub bind: Vec<SocketAddr>, pub bind: Vec<SocketAddr>,
// Decode during configuration preparation so Clap cannot echo rejected keys.
/// HMAC key; every request must then carry a valid MAC. /// HMAC key; every request must then carry a valid MAC.
#[arg( #[arg(
long, long,
value_name = "KEY", value_name = "KEY",
value_parser = parse_hmac_key,
env = "IRTT_SERVER_HMAC", env = "IRTT_SERVER_HMAC",
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments, or in the environment as IRTT_SERVER_HMAC." hide_env_values = true,
long_help = "HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes (digits may use either case). Empty keys and invalid hex are rejected.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments, or in the environment as IRTT_SERVER_HMAC."
)] )]
pub hmac: Option<String>, pub hmac: Option<String>,
@@ -126,6 +127,23 @@ pub struct ServerArgs {
pub no_dscp: bool, pub no_dscp: bool,
} }
impl std::fmt::Debug for ServerArgs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerArgs")
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
.field("bind", &self.bind)
.field("max_sessions", &self.max_sessions)
.field("max_packet_length", &self.max_packet_length)
.field("min_interval", &self.min_interval)
.field("burst", &self.burst)
.field("idle_timeout", &self.idle_timeout)
.field("max_duration", &self.max_duration)
.field("timestamp_allowance", &self.timestamp_allowance)
.field("no_dscp", &self.no_dscp)
.finish()
}
}
/// The command-line spelling of [`TimestampAllowance`]. /// The command-line spelling of [`TimestampAllowance`].
/// ///
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server` /// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
@@ -166,11 +184,10 @@ impl ServerArgs {
} }
/// Builds the server configuration this invocation asks for. /// Builds the server configuration this invocation asks for.
#[must_use] pub fn server_config(&self) -> Result<ServerConfig, String> {
pub fn server_config(&self) -> ServerConfig {
let mut config = ServerConfig::default(); let mut config = ServerConfig::default();
if let Some(key) = self.hmac.as_ref() { if let Some(key) = self.hmac.as_ref() {
config = config.with_hmac_key(key.as_bytes()); config = config.with_hmac_key(crate::shared::decode_hmac_key(key)?);
} }
if let Some(value) = self.max_sessions { if let Some(value) = self.max_sessions {
config = config.with_max_sessions(value); config = config.with_max_sessions(value);
@@ -198,19 +215,10 @@ impl ServerArgs {
if self.no_dscp { if self.no_dscp {
config = config.with_dscp_allowed(false); config = config.with_dscp_allowed(false);
} }
config Ok(config)
} }
} }
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
/// enable authentication with predictable, empty key material.
fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
/// Parses a duration whose zero is meaningful server policy. /// Parses a duration whose zero is meaningful server policy.
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> { fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
if input == "0" { if input == "0" {
+1 -1
View File
@@ -36,7 +36,7 @@ async fn serve(
args: ServerArgs, args: ServerArgs,
shutdown_requested: &AtomicBool, shutdown_requested: &AtomicBool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = args.server_config(); let config = args.server_config()?;
let binds = args.resolve_binds(); let binds = args.resolve_binds();
let is_implicit_default = args.bind.is_empty(); let is_implicit_default = args.bind.is_empty();
let set = match ServerSet::bind(binds.clone(), config.clone()).await { let set = match ServerSet::bind(binds.clone(), config.clone()).await {
+3 -2
View File
@@ -18,7 +18,7 @@ pub struct TuiArgs {
num_args = 1.., num_args = 1..,
required = true, required = true,
value_parser = parse_target, value_parser = parse_target,
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example" long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode. Keys use literal UTF-8 or lowercase 0x followed by hexadecimal bytes.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
)] )]
pub targets: Vec<TargetArg>, pub targets: Vec<TargetArg>,
@@ -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 {
+4 -3
View File
@@ -34,9 +34,10 @@ pub async fn run_tui(
args: TuiArgs, args: TuiArgs,
mut shutdown: watch::Receiver<bool>, 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() { if *shutdown.borrow() {
return Ok(()); return Ok(());
+88 -42
View File
@@ -43,7 +43,7 @@ use crate::{
shared::client::{expected_probe_count, GroupPacingArg, ManagedRunSetup}, shared::client::{expected_probe_count, GroupPacingArg, ManagedRunSetup},
}; };
const HISTORY_LIMIT: usize = 100_000; const HISTORY_LIMIT: usize = 500_000;
const RECENT_EVENT_LIMIT: usize = 80; const RECENT_EVENT_LIMIT: usize = 80;
const MIN_WIDTH: u16 = 56; const MIN_WIDTH: u16 = 56;
const MIN_HEIGHT: u16 = 18; const MIN_HEIGHT: u16 = 18;
@@ -405,7 +405,7 @@ impl TuiState {
server_timing, server_timing,
.. ..
} => { } => {
target.push_graph_sample(GraphSample { target.push_graph_sample(LatestSample {
generation: target.generation.expect("client event has a generation"), generation: target.generation.expect("client event has a generation"),
timestamp: *received_at, timestamp: *received_at,
seq: *seq, seq: *seq,
@@ -615,7 +615,7 @@ struct TuiTargetState {
status: TargetStatus, status: TargetStatus,
negotiated: Option<NegotiationResult>, negotiated: Option<NegotiationResult>,
graph_history: VecDeque<GraphSample>, graph_history: VecDeque<GraphSample>,
last_sample: Option<GraphSample>, last_sample: Option<LatestSample>,
last_warning: Option<String>, last_warning: Option<String>,
stats: StatsCollector, stats: StatsCollector,
} }
@@ -656,9 +656,20 @@ impl TuiTargetState {
} }
} }
fn push_graph_sample(&mut self, sample: GraphSample) { fn push_graph_sample(&mut self, sample: LatestSample) {
let history = GraphSample {
timestamp: sample.timestamp,
values_ms: GraphMetric::ALL.map(|metric| {
metric
.value_ns(&sample)
.map_or(f64::NAN, |ns| ns as f64 / 1_000_000.0)
}),
break_before: self
.last_sample
.is_none_or(|last| last.generation != sample.generation),
};
self.last_sample = Some(sample); self.last_sample = Some(sample);
push_bounded(&mut self.graph_history, sample, HISTORY_LIMIT); push_bounded(&mut self.graph_history, history, HISTORY_LIMIT);
} }
} }
@@ -926,7 +937,8 @@ enum GraphMetric {
} }
impl GraphMetric { impl GraphMetric {
const ALL: &[Self] = &[ // Enum order also indexes the retained graph values.
const ALL: [Self; 6] = [
Self::EffectiveRtt, Self::EffectiveRtt,
Self::RawRtt, Self::RawRtt,
Self::AdjustedRtt, Self::AdjustedRtt,
@@ -966,7 +978,7 @@ impl GraphMetric {
} }
} }
fn value_ns(self, sample: &GraphSample) -> Option<i128> { fn value_ns(self, sample: &LatestSample) -> Option<i128> {
match self { match self {
Self::EffectiveRtt => Some(sample.rtt.effective.as_nanos()), Self::EffectiveRtt => Some(sample.rtt.effective.as_nanos()),
Self::RawRtt => Some(SignedDuration::from(sample.rtt.raw).as_nanos()), Self::RawRtt => Some(SignedDuration::from(sample.rtt.raw).as_nanos()),
@@ -996,8 +1008,25 @@ impl GraphMetric {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] // Retained values use the same nanoseconds-to-f64 milliseconds conversion as
// the chart. NaN means absent and never reaches the canvas as a finite point.
// Keep both clocks: geometry is monotonic, while labels use local wall time.
#[derive(Debug, Clone, Copy)]
struct GraphSample { struct GraphSample {
timestamp: ClientTimestamp,
values_ms: [f64; 6],
break_before: bool,
}
impl GraphSample {
fn value_ms(&self, metric: GraphMetric) -> Option<f64> {
let value = self.values_ms[metric as usize];
value.is_finite().then_some(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LatestSample {
generation: u64, generation: u64,
timestamp: ClientTimestamp, timestamp: ClientTimestamp,
seq: u32, seq: u32,
@@ -1426,40 +1455,31 @@ fn target_metric_series(
let first = target let first = target
.graph_history .graph_history
.partition_point(|sample| sample.timestamp.mono < viewport.start); .partition_point(|sample| sample.timestamp.mono < viewport.start);
let predecessor_generation = target let mut previous = None;
.graph_history for sample in target.graph_history.range(..first).rev() {
.get(first.saturating_sub(1)) if let Some(value) = sample.value_ms(metric) {
.map(|sample| sample.generation); previous = Some((sample.timestamp.mono, value));
let mut previous = target break;
.graph_history }
.range(..first) if sample.break_before {
.rev() break;
.take_while(|sample| Some(sample.generation) == predecessor_generation) }
.find_map(|sample| { }
metric.value_ns(sample).map(|ns| {
(
sample.generation,
(sample.timestamp.mono, ns as f64 / 1_000_000.0),
)
})
});
let points = target.graph_history.range(first..).filter_map(|sample| {
metric.value_ns(sample).map(|ns| {
(
sample.generation,
(sample.timestamp.mono, ns as f64 / 1_000_000.0),
)
})
});
let mut data = Vec::new(); let mut data = Vec::new();
for (generation, point) in points { for sample in target.graph_history.range(first..) {
if previous.is_some_and(|(before_generation, _)| before_generation != generation) { // Consume boundaries even when this metric is absent in the first
if !data.is_empty() { // replies of a new session; filtering them out would bridge an outage.
if sample.break_before {
if !data.is_empty() && data.last().is_some_and(|(x, _): &(f64, f64)| x.is_finite()) {
data.push((f64::NAN, f64::NAN)); data.push((f64::NAN, f64::NAN));
} }
previous = None; previous = None;
} }
if let Some((_, before)) = previous { let Some(value) = sample.value_ms(metric) else {
continue;
};
let point = (sample.timestamp.mono, value);
if let Some(before) = previous {
// Only genuine crossing segments contribute boundary points. In // Only genuine crossing segments contribute boundary points. In
// particular, never extend the latest sample to the live edge. // particular, never extend the latest sample to the live edge.
if before.0 < viewport.start && point.0 > viewport.start { if before.0 < viewport.start && point.0 > viewport.start {
@@ -1481,7 +1501,7 @@ fn target_metric_series(
point.1, point.1,
)); ));
} }
previous = Some((generation, point)); previous = Some(point);
} }
// A boundary beyond the viewport needs no trailing break. // A boundary beyond the viewport needs no trailing break.
if data.last().is_some_and(|(x, _)| !x.is_finite()) { if data.last().is_some_and(|(x, _)| !x.is_finite()) {
@@ -2032,7 +2052,7 @@ mod tests {
let mut target = TuiTargetState::new("monitor".into(), stats_config(true)); let mut target = TuiTargetState::new("monitor".into(), stats_config(true));
for (generation, seconds) in [(0, 0), (0, 2), (1, 6), (1, 8)] { for (generation, seconds) in [(0, 0), (0, 2), (1, 6), (1, 8)] {
let raw = Duration::from_millis(seconds + 1); let raw = Duration::from_millis(seconds + 1);
target.push_graph_sample(GraphSample { target.push_graph_sample(LatestSample {
generation, generation,
timestamp: ClientTimestamp { timestamp: ClientTimestamp {
mono: start + Duration::from_secs(seconds), mono: start + Duration::from_secs(seconds),
@@ -2041,7 +2061,8 @@ mod tests {
seq: 0, seq: 0,
rtt: RttSample { rtt: RttSample {
raw, raw,
adjusted: None, adjusted: (seconds != 6)
.then_some(SignedDuration::from_nanos(-(raw.as_nanos() as i128))),
effective: SignedDuration::from_nanos(raw.as_nanos() as i128), effective: SignedDuration::from_nanos(raw.as_nanos() as i128),
}, },
one_way: None, one_way: None,
@@ -2110,10 +2131,35 @@ mod tests {
); );
assert!(series(3, 5).is_none(), "no interpolation across sessions"); assert!(series(3, 5).is_none(), "no interpolation across sessions");
assert!(series(9, 10).is_none(), "no extrapolation to the live edge"); assert!(series(9, 10).is_none(), "no extrapolation to the live edge");
let template = target.graph_history[0]; let adjusted = |left, right| {
target_metric_series(
&target,
0,
GraphViewportRange {
start: start + Duration::from_secs(left),
end: start + Duration::from_secs(right),
window: Duration::from_secs(right - left),
},
GraphMetric::AdjustedRtt,
)
};
let missing = adjusted(1, 9).unwrap();
assert_eq!(&missing.data[..2], [(0.0, -2.0), (1.0, -3.0)]);
assert!(missing.data[2].0.is_nan());
assert_eq!(&missing.data[3..], [(7.0, -9.0)]);
assert!(
adjusted(3, 7).is_none(),
"absent metric cannot bridge sessions"
);
assert_eq!(
adjusted(7, 9).unwrap().data,
[(1.0, -9.0)],
"predecessor search stops at an absent boundary metric"
);
let template = target.last_sample.unwrap();
target.graph_history.clear(); target.graph_history.clear();
for generation in 0..10_000 { for generation in 0..10_000 {
target.push_graph_sample(GraphSample { target.push_graph_sample(LatestSample {
generation, generation,
timestamp: ClientTimestamp { timestamp: ClientTimestamp {
mono: start + Duration::from_secs(generation), mono: start + Duration::from_secs(generation),
+68 -13
View File
@@ -1,4 +1,4 @@
use std::{fmt, time::Duration}; use std::{fmt, net::IpAddr, net::SocketAddr, time::Duration};
use clap::ValueEnum; use clap::ValueEnum;
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH}; use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
@@ -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,
@@ -14,8 +26,9 @@ pub struct CommonClientArgs {
#[arg(long, default_value_t = 0, value_parser = parse_length)] #[arg(long, default_value_t = 0, value_parser = parse_length)]
pub length: u32, pub length: u32,
/// HMAC key. // Decode during configuration preparation so Clap cannot echo rejected keys.
#[arg(long, value_parser = parse_hmac_key)] /// HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes.
#[arg(long)]
pub hmac: Option<String>, pub hmac: Option<String>,
/// Clock mode to request. /// Clock mode to request.
@@ -58,6 +71,38 @@ pub struct CommonClientArgs {
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)] #[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
pub ttl: Option<u32>, pub ttl: Option<u32>,
#[arg(
long = "bind-addr",
value_name = "IP[:PORT]",
value_parser = parse_bind_addr,
long_help = "Local IP address and optional UDP port to bind. \
If no port is specified, the operating system selects \
an ephemeral port. IPv6 addresses with an explicit \
port must use [ADDR]:PORT syntax."
)]
pub bind_addr: Option<SocketAddr>,
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
#[arg(
long = "bind-device",
value_name = "DEVICE",
help = "Bind the socket to a network device"
)]
pub bind_to_device: Option<String>,
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
#[arg(
long,
value_name = "MARK",
value_parser = clap_num::maybe_hex::<u32>,
help = "Set the outgoing socket firewall mark"
)]
pub mark: Option<u32>,
#[cfg(target_os = "freebsd")]
#[arg(long, value_name = "FIB", help = "Set the socket routing table (FIB)")]
pub fib: Option<u32>,
/// Accept safe server restrictions during negotiation. /// Accept safe server restrictions during negotiation.
#[arg(long)] #[arg(long)]
pub loose: bool, pub loose: bool,
@@ -65,7 +110,11 @@ 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") let mut debug = f.debug_struct("CommonClientArgs");
debug
.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]"))
@@ -75,8 +124,14 @@ impl fmt::Debug for CommonClientArgs {
.field("server_fill", &self.server_fill) .field("server_fill", &self.server_fill)
.field("dscp", &self.dscp) .field("dscp", &self.dscp)
.field("ttl", &self.ttl) .field("ttl", &self.ttl)
.field("loose", &self.loose) .field("bind_addr", &self.bind_addr);
.finish() #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
debug
.field("bind_to_device", &self.bind_to_device)
.field("mark", &self.mark);
#[cfg(target_os = "freebsd")]
debug.field("fib", &self.fib);
debug.field("loose", &self.loose).finish()
} }
} }
@@ -137,6 +192,13 @@ impl From<ReceivedStatsArg> for ReceivedStats {
} }
} }
fn parse_bind_addr(input: &str) -> Result<SocketAddr, String> {
input
.parse::<SocketAddr>()
.or_else(|_| input.parse::<IpAddr>().map(|ip| SocketAddr::new(ip, 0)))
.map_err(|_| "expected an IP address, optionally with a port".to_owned())
}
pub fn parse_duration(input: &str) -> Result<Duration, String> { pub fn parse_duration(input: &str) -> Result<Duration, String> {
let (number, unit) = split_duration(input)?; let (number, unit) = split_duration(input)?;
let value: u64 = number let value: u64 = number
@@ -206,13 +268,6 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
Ok(length) Ok(length)
} }
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
if input.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(input.to_owned())
}
pub fn parse_server_fill(input: &str) -> Result<String, String> { pub fn parse_server_fill(input: &str) -> Result<String, String> {
if input.is_empty() { if input.is_empty() {
return Err("server fill must not be empty".to_owned()); return Err("server fill must not be empty".to_owned());
+27 -7
View File
@@ -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) -> Result<ClientConfig, String> {
ClientConfig { Ok(ClientConfig {
address_family: self.address_family(),
open: OpenPolicy { open: OpenPolicy {
negotiation: if self.loose { negotiation: if self.loose {
NegotiationPolicy::Loose NegotiationPolicy::Loose
@@ -34,16 +45,25 @@ impl CommonClientArgs {
}, },
auth: self auth: self
.hmac .hmac
.as_ref() .as_deref()
.map(crate::shared::decode_hmac_key)
.transpose()?
.map_or(Authentication::Unauthenticated, |key| { .map_or(Authentication::Unauthenticated, |key| {
Authentication::Hmac(HmacKey::new(key.as_bytes())) Authentication::Hmac(HmacKey::new(key))
}), }),
socket: SocketConfig { socket: SocketConfig {
bind_addr: self.bind_addr,
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
bind_to_device: self.bind_to_device.clone(),
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
mark: self.mark,
#[cfg(target_os = "freebsd")]
fib: self.fib,
ttl: self.ttl, ttl: self.ttl,
..SocketConfig::default() ..SocketConfig::default()
}, },
..ClientConfig::default() ..ClientConfig::default()
} })
} }
} }
+2 -2
View File
@@ -15,6 +15,6 @@ pub use prepare::{
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 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,
}; };
+14 -4
View File
@@ -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,
}) })
} }
+155 -16
View File
@@ -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()
} }
@@ -83,12 +88,13 @@ impl From<GroupPacingArg> for ManagedPacing {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum TargetParseError { enum TargetParseError {
EmptyLabel, EmptyLabel,
EmptyAddress, EmptyAddress,
InvalidEscape, InvalidEscape,
EmptyTarget, EmptyTarget,
InvalidHmac(String),
} }
impl fmt::Display for TargetParseError { impl fmt::Display for TargetParseError {
@@ -98,6 +104,7 @@ impl fmt::Display for TargetParseError {
Self::EmptyAddress => "target address must not be empty", Self::EmptyAddress => "target address must not be empty",
Self::InvalidEscape => "invalid target escape sequence", Self::InvalidEscape => "invalid target escape sequence",
Self::EmptyTarget => "target must not be empty", Self::EmptyTarget => "target must not be empty",
Self::InvalidHmac(message) => message,
}; };
f.write_str(message) f.write_str(message)
} }
@@ -192,7 +199,10 @@ fn parse_target_syntax(
let auth = if value.is_empty() { let auth = if value.is_empty() {
TargetAuth::Override(Authentication::Unauthenticated) TargetAuth::Override(Authentication::Unauthenticated)
} else { } else {
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes()))) TargetAuth::Override(Authentication::Hmac(HmacKey::new(
crate::shared::decode_hmac_key(&value)
.map_err(TargetParseError::InvalidHmac)?,
)))
}; };
(&input[..index], auth) (&input[..index], auth)
} }
@@ -257,17 +267,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])>,
}); }
impl TargetPreparation {
pub fn new(family: AddressFamily, dual_stack: bool) -> Self {
Self {
family,
dual_stack,
discovered: HashMap::new(),
} }
Ok(targets) }
pub async fn prepare(
mut self,
specs: Vec<TargetSpec>,
maximum_targets: Option<usize>,
) -> Result<(Vec<PreparedTarget>, Self), String> {
let mut targets = Vec::with_capacity(specs.len());
let mut discovered = HashMap::new();
let mut labels = HashSet::new();
for (index, spec) in specs.into_iter().enumerate() {
let families = if let Some(ip) = literal_ip(&spec.addr) {
match (self.family, ip) {
(AddressFamily::Ipv4, IpAddr::V6(_)) => {
return Err(format!(
"target {}: IPv6 literal conflicts with --ipv4",
index + 1
))
}
(AddressFamily::Ipv6, IpAddr::V4(_)) => {
return Err(format!(
"target {}: IPv4 literal conflicts with --ipv6",
index + 1
))
}
_ => None,
}
} else if self.dual_stack {
let families = match self.discovered.get(&spec.label) {
Some((addr, families)) if *addr == spec.addr => *families,
_ => {
let endpoint = discovery_endpoint(&spec.addr)
.map_err(|error| format!("target {}: {error}", index + 1))?;
discovery_families(tokio::net::lookup_host(endpoint).await)
.map_err(|error| format!("target {}: {error}", index + 1))?
}
};
discovered.insert(spec.label.clone(), (spec.addr.clone(), families));
Some(families)
} else {
None
};
for target in expand_target(spec, families) {
if !labels.insert(target.label.clone()) {
return Err("duplicate target label after address-family expansion".to_owned());
}
targets.push(target);
}
if let Some(maximum) = maximum_targets.filter(|maximum| targets.len() > *maximum) {
return Err(format!(
"target set exceeds the {maximum}-target limit after address-family expansion"
));
}
}
self.discovered = discovered;
Ok((targets, self))
}
}
fn literal_ip(endpoint: &str) -> Option<IpAddr> {
endpoint
.parse::<SocketAddr>()
.map(|addr| addr.ip())
.ok()
.or_else(|| endpoint.parse().ok())
.or_else(|| {
if endpoint.starts_with('[') && endpoint.ends_with(']') {
format!("{endpoint}:2112")
.parse::<SocketAddr>()
.ok()
.map(|addr| addr.ip())
} else {
None
}
})
}
fn discovery_endpoint(endpoint: &str) -> Result<(&str, u16), String> {
match endpoint.rsplit_once(':') {
Some((host, port)) => {
let port = port.parse().map_err(|_| "invalid server port".to_owned())?;
if host.is_empty() || host.contains(':') || host.starts_with('[') {
return Err("invalid server endpoint".to_owned());
}
Ok((host, port))
}
None => Ok((endpoint, 2112)),
}
}
fn discovery_families(
result: io::Result<impl Iterator<Item = SocketAddr>>,
) -> Result<[bool; 2], String> {
let mut families = [false; 2];
for addr in result.map_err(|_| "DNS lookup failed".to_owned())? {
families[usize::from(addr.is_ipv6())] = true;
}
if families == [false; 2] {
return Err("DNS lookup returned no usable addresses".to_owned());
}
Ok(families)
}
fn expand_target(spec: TargetSpec, families: Option<[bool; 2]>) -> Vec<PreparedTarget> {
let selections = match families {
None => vec![(spec.label, None)],
Some(families) => [("v4", AddressFamily::Ipv4), ("v6", AddressFamily::Ipv6)]
.into_iter()
.zip(families)
.filter(|(_, exists)| *exists)
.map(|((suffix, family), _)| (format!("{}/{suffix}", spec.label), Some(family)))
.collect(),
};
selections
.into_iter()
.map(|(label, family)| {
let mut managed =
ManagedTargetConfig::new(TargetId::from(label.clone()), spec.addr.clone());
managed.address_family = family;
managed.auth = spec.auth.clone();
PreparedTarget { label, managed }
})
.collect()
} }
/// Parse one complete stdin target set after its line terminator was removed. /// Parse one complete stdin target set after its line terminator was removed.
@@ -277,7 +416,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 +427,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)
} }
+14
View File
@@ -0,0 +1,14 @@
/// Decode CLI key syntax without ever including key material in diagnostics.
pub(crate) fn decode_hmac_key(input: &str) -> Result<Vec<u8>, String> {
let key = match input.strip_prefix("0x") {
Some(hex) => hex::decode(hex).map_err(|_| {
"HMAC hexadecimal key must contain an even number of valid hexadecimal digits"
.to_owned()
})?,
None => input.as_bytes().to_vec(),
};
if key.is_empty() {
return Err("HMAC key must not be empty".to_owned());
}
Ok(key)
}
+5
View File
@@ -1,2 +1,7 @@
#[cfg(any(feature = "client", feature = "tui"))] #[cfg(any(feature = "client", feature = "tui"))]
pub mod client; pub mod client;
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
mod hmac;
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
pub(crate) use hmac::decode_hmac_key;
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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.
+9 -3
View File
@@ -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,
@@ -397,6 +397,9 @@ fn build_task(
.checked_add(1) .checked_add(1)
.ok_or(ManagedConfigError::GenerationExhausted)?; .ok_or(ManagedConfigError::GenerationExhausted)?;
let mut client_config = config.client.clone(); 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); client_config.auth = target.auth.resolve(&config.client.auth);
validate_target_config(&client_config).map_err(|source| { validate_target_config(&client_config).map_err(|source| {
ManagedConfigError::InvalidTarget { ManagedConfigError::InvalidTarget {
@@ -191,6 +191,9 @@ impl ManagedClientTask {
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id }); return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
} }
let mut client_config = self.config.client.clone(); 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); client_config.auth = target.auth.resolve(&self.config.client.auth);
validate_target_config(&client_config).map_err(|source| { validate_target_config(&client_config).map_err(|source| {
ManagedCommandApplyError::InvalidTarget { ManagedCommandApplyError::InvalidTarget {
+1 -1
View File
@@ -18,4 +18,4 @@ subtle = "2.6.1"
thiserror = "2.0.21" thiserror = "2.0.21"
[dev-dependencies] [dev-dependencies]
proptest = "1.11.0" proptest = "1.12.0"
+2 -2
View File
@@ -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
@@ -19,4 +19,4 @@ irtt-client.workspace = true
name = "finite_vs_continuous" name = "finite_vs_continuous"
[dev-dependencies] [dev-dependencies]
proptest = "1.11.0" proptest = "1.12.0"
+66 -3
View File
@@ -37,12 +37,13 @@ overrides it for that target, and `@hmac=` explicitly disables HMAC for that
target. The modifier uses `@`, not a shell command separator; quote an argument target. The modifier uses `@`, not a shell command separator; quote an argument
if its key needs shell quoting. if its key needs shell quoting.
Global `--hmac KEY` and per-target `@hmac=KEY` accept plain-text keys or hexadecimal keys prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent.
Target syntax supports these backslash escapes in labels, addresses, and HMAC Target syntax supports these backslash escapes in labels, addresses, and HMAC
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
label or address when it would otherwise begin `@hmac=`; escaping the first label or address when it would otherwise begin `@hmac=`; escaping the first
`=` is how an unlabeled target or a label can contain that character. An `=` in `=` is how an unlabeled target or a label can contain that character. An `=` in an HMAC value does not need escaping. Positional
an HMAC value, including Base64 padding, does not need escaping. Positional
target arguments may contain a raw comma; stdin target sets have the additional target arguments may contain a raw comma; stdin target sets have the additional
`\,` escape described below. `\,` escape described below.
@@ -58,6 +59,60 @@ 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.
## LOCAL SOCKET CONFIGURATION
The client supports configuring its local UDP sockets using the following options:
- `--bind-addr IP[:PORT]` binds sockets to the specified local IP address and optional port. If no port is specified, or the port is `0`, the operating system assigns an ephemeral port. IPv6 addresses with an explicit port must use `[ADDR]:PORT` syntax. The address family must match the target's resolved address family.
- `--bind-device DEVICE` (Linux, Android, Fuchsia) binds sockets to a network interface using `SO_BINDTODEVICE`.
- `--mark MARK` (Linux, Android, Fuchsia) sets the socket's firewall mark using `SO_MARK`, allowing traffic to be selected by policy routing or firewall rules. Accepts a decimal or 0x-prefixed hexadecimal unsigned 32-bit integer.
- `--fib FIB` (FreeBSD) selects a routing table using `SO_SETFIB`. The FIB is specified as a decimal unsigned 32-bit integer.
For example, bind IPv4 probes to a specific source address and interface, with a firewall mark:
```sh
irtt-client -4 --bind-addr 192.0.2.10 --bind-device wan0 --mark 1234 host.example
```
Setting a firewall mark or binding to a network device may require elevated privileges or appropriate capabilities. If the operating system rejects a socket option, socket setup fails.
A fixed local port may cause binding conflicts when multiple target sockets are created. Use an ephemeral port unless a specific source port is required. A family-specific bind address is also incompatible with targets using the other address family.
## 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 +130,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 +169,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
+3 -2
View File
@@ -74,8 +74,9 @@ IPv6 listener.
### `--hmac KEY` ### `--hmac KEY`
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured, Accepts a plain-text key or a hexadecimal key prefixed with `0x`. For example, `secret` and `0x736563726574` are equivalent. The `IRTT_SERVER_HMAC` environment variable uses the same syntax.
every request must carry a valid MAC and every reply is authenticated;
With a key configured, every request must carry a valid MAC and every reply is authenticated;
without one, authenticated requests are dropped. The key is visible in the without one, authenticated requests are dropped. The key is visible in the
process arguments. process arguments.
+31 -2
View File
@@ -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**
@@ -38,6 +53,11 @@ sessions continue undisturbed. Even if every target fails, the TUI stays alive
until you quit. Explicit stops, removed or replaced targets, and no-test until you quit. Explicit stops, removed or replaced targets, and no-test
completion do not trigger retries. Quitting cancels pending retry work. 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 Each reconnect keeps the same logical target row. Statistics reset when a new
session generation is accepted, including attempts that fail before opening. session generation is accepted, including attempts that fail before opening.
Retained graph history and the age of the last actual reply remain; the graph Retained graph history and the age of the last actual reply remain; the graph
@@ -108,11 +128,20 @@ controls appropriate to the currently open graph or details sheet.
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.
At 500,000 replies per target, retention is approximately 13h53m20s at
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 `r` clears graph history for all targets and returns the viewport to
live; it does not change the 100,000-sample cap itself. These are 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
+8 -8
View File
@@ -4,9 +4,9 @@ version = 4
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
version = "1.4.2" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
@@ -31,9 +31,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.6.0" version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -76,9 +76,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",
] ]
@@ -285,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.6" version = "3.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+1 -1
View File
@@ -8,7 +8,7 @@ edition = "2021"
cargo-fuzz = true cargo-fuzz = true
[dependencies] [dependencies]
arbitrary = "1.4.2" arbitrary = "1.5.0"
libfuzzer-sys = "0.4.13" libfuzzer-sys = "0.4.13"
[dependencies.irtt-proto] [dependencies.irtt-proto]