Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d2554cf2b | ||
|
|
e52c03f3b8 | ||
|
|
643e3b8b76 | ||
|
|
52f2a07490 | ||
|
|
d2a7acdec4 | ||
|
|
56fe30d0e2 | ||
|
|
d4c89ee22c | ||
|
|
5481009fe3 | ||
|
|
7d640793b6 | ||
|
|
9e8559d713 | ||
|
|
06fff94505 | ||
|
|
d7d3f58716 | ||
|
|
2a82951572 | ||
|
|
7da8d2141c | ||
|
|
bdb8c13d7d | ||
|
|
a04ea5727b | ||
|
|
ab5f1ac807 | ||
|
|
45b0c8549d | ||
|
|
18a8e307fb | ||
|
|
ab241c4a2b |
No files matched your search
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
# The automatic per-job token comes back unauthorized for package
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
# It remains here while portable actionlint runs with Gitea CI, but scans
|
||||
# both workflow directories through its GitHub Actions rule set.
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
||||
with:
|
||||
persona: regular
|
||||
inputs: |
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
# the old --skip-git flag is no longer supported.
|
||||
scan-scheduled:
|
||||
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:
|
||||
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
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
exit 0
|
||||
|
||||
- name: Upload Clippy SARIF
|
||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: rust-clippy-results.sarif
|
||||
|
||||
@@ -192,6 +192,24 @@ release. The format is loosely based on
|
||||
|
||||
## 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
|
||||
|
||||
Generated
+232
-136
@@ -123,11 +123,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
"bit-vec 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -138,9 +138,13 @@ checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
@@ -181,6 +185,30 @@ dependencies = [
|
||||
"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]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -199,6 +227,12 @@ version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
@@ -210,9 +244,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -230,6 +264,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -251,6 +296,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
@@ -272,7 +326,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -328,6 +382,12 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -337,6 +397,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
@@ -440,7 +509,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -451,7 +520,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -462,9 +531,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
@@ -539,9 +608,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
@@ -591,9 +660,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
@@ -674,17 +743,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -719,6 +778,12 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
@@ -759,10 +824,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
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 = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -777,7 +852,7 @@ dependencies = [
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -815,13 +890,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "irtt-rs"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap-num",
|
||||
"crossterm",
|
||||
"ctrlc",
|
||||
"futures-core",
|
||||
"hex",
|
||||
"humantime",
|
||||
"irtt-client",
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
@@ -886,11 +964,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.12"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
||||
checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -909,9 +987,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@@ -936,9 +1014,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
@@ -961,16 +1039,16 @@ version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b367b50a9be9a5d3b5718c1da74e5d6b9c17647f89752ee2562a470cc3c4eb1a"
|
||||
checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
|
||||
dependencies = [
|
||||
"nix 0.30.1",
|
||||
"nix 0.31.3",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
@@ -1041,19 +1119,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nix"
|
||||
version = "0.31.3"
|
||||
@@ -1105,9 +1170,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -1206,19 +1271,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.9.2"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
|
||||
checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"psm",
|
||||
"stacker",
|
||||
"ucd-trie",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.9.2"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
|
||||
checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -1226,22 +1293,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.9.2"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
|
||||
checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.9.2"
|
||||
version = "2.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
|
||||
checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
@@ -1317,12 +1384,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1336,16 +1403,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bit-set 0.11.1",
|
||||
"bit-vec 0.10.1",
|
||||
"bitflags 2.13.2",
|
||||
"chacha20",
|
||||
"core_detect",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
"rand 0.10.3",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
@@ -1353,6 +1421,15 @@ dependencies = [
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -1391,22 +1468,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1417,20 +1484,17 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1458,7 +1522,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"compact_str",
|
||||
"critical-section",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown",
|
||||
"itertools",
|
||||
"kasuari",
|
||||
"lru",
|
||||
@@ -1521,7 +1585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hashbrown 0.17.1",
|
||||
"hashbrown",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools",
|
||||
@@ -1657,7 +1721,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1667,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
@@ -1722,9 +1786,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
version = "1.16.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -1736,6 +1800,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -1799,9 +1876,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1934,7 +2011,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1960,9 +2037,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
@@ -1980,7 +2057,37 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"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]]
|
||||
@@ -2003,9 +2110,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
@@ -2038,9 +2145,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
version = "1.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.4.3",
|
||||
@@ -2119,7 +2226,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -2294,28 +2401,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
@@ -104,6 +104,9 @@ Probe a server:
|
||||
|
||||
```sh
|
||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
|
||||
|
||||
# Negotiate parameters without sending measurement probes
|
||||
irtt-client --no-test netperf-eu.bufferbloat.net:2112
|
||||
```
|
||||
|
||||
Probe continuously until interrupted (`Ctrl-C`):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-rs"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "IRTT-compatible client, TUI, and server applications"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -42,26 +42,29 @@ doc = false
|
||||
|
||||
[features]
|
||||
default = ["client", "server", "tui"]
|
||||
client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
||||
server = ["dep:irtt-server", "dep:tokio"]
|
||||
tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"]
|
||||
client = ["dep:humantime", "dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
||||
server = ["dep:irtt-server", "dep:irtt-proto", "dep:tokio"]
|
||||
tui =["dep:humantime", "dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"]
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
|
||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||
clap-num = "=1.2.0"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
||||
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
||||
hex = "0.4.3"
|
||||
humantime = { version = "2.4.0", optional = true }
|
||||
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||
irtt-client = { workspace = true, optional = true }
|
||||
irtt-proto = { workspace = true, optional = true }
|
||||
irtt-server = { workspace = true, optional = true }
|
||||
irtt-stats = { workspace = true, optional = true }
|
||||
ratatui = { version = "0.30.2", optional = true }
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
|
||||
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "sync"] }
|
||||
tokio = { version = "1.54.1", default-features = false, features = ["rt", "sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
nix = { version = "0.31.3", features = ["term", "poll", "signal", "process"] }
|
||||
|
||||
@@ -55,6 +55,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
|
||||
};
|
||||
|
||||
match command {
|
||||
"-V" | "--version" => Ok(AppletDispatch::Help(crate::version::app())),
|
||||
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
||||
"client" => Ok(AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
@@ -78,6 +79,7 @@ pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String>
|
||||
|
||||
pub fn dispatcher_help() -> String {
|
||||
let mut command = Command::new("irtt-rs")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.about("IRTT-compatible multi-applet dispatcher")
|
||||
.subcommand(Command::new("client").about(client_applet_about()))
|
||||
.subcommand(Command::new("tui").about(tui_applet_about()))
|
||||
|
||||
@@ -9,14 +9,18 @@ use clap::{Parser, ValueEnum};
|
||||
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
||||
#[command(
|
||||
name = "irtt-client",
|
||||
version,
|
||||
about = "Minimal IRTT-compatible stream client"
|
||||
)]
|
||||
pub struct ClientArgs {
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
value_name = "TARGET",
|
||||
num_args = 0..,
|
||||
value_parser = parse_target,
|
||||
long_help = "Server address/host, optionally prefixed with LABEL= 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>,
|
||||
|
||||
@@ -27,6 +31,15 @@ pub struct ClientArgs {
|
||||
)]
|
||||
pub targets_stdin: bool,
|
||||
|
||||
/// Negotiate parameters and exit without sending measurement probes.
|
||||
#[arg(
|
||||
short = 'n',
|
||||
long,
|
||||
conflicts_with = "targets_stdin",
|
||||
long_help = "Negotiate parameters with each target and exit without sending measurement probes. Duration options still set the requested duration, but do not keep this mode running. Reports negotiated parameters using the selected output format, without a measurement summary."
|
||||
)]
|
||||
pub no_test: bool,
|
||||
|
||||
/// Managed group pacing for multi-target mode.
|
||||
#[arg(
|
||||
long,
|
||||
@@ -38,13 +51,34 @@ pub struct ClientArgs {
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
short = 'd',
|
||||
default_value = "10s",
|
||||
value_parser = parse_test_duration,
|
||||
help = "Test duration; use 0 for continuous mode",
|
||||
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
|
||||
help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
|
||||
long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted).\n\nUnless --no-stats is selected, finite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
|
||||
)]
|
||||
pub duration: Duration,
|
||||
|
||||
/// Run continuously until interrupted (equivalent to --duration 0).
|
||||
#[arg(long, conflicts_with = "duration")]
|
||||
pub continuous: bool,
|
||||
|
||||
/// Disable statistics collection and final summaries; retain measurement event output.
|
||||
#[arg(long)]
|
||||
pub no_stats: bool,
|
||||
|
||||
/// Retry failed, closed, or silent sessions (requires continuous mode).
|
||||
#[arg(
|
||||
long,
|
||||
conflicts_with = "no_test",
|
||||
long_help = "Retry failed initial opens and closed, failed, or silent sessions independently per target. Requires --continuous or --duration 0. Unsuccessful connections back off from 250ms to 10s. Use --reconnect-timeout to configure silence detection and --no-stats when only measurement events are needed."
|
||||
)]
|
||||
pub reconnect: bool,
|
||||
|
||||
/// Reconnect after this long without valid replies while sending probes.
|
||||
#[arg(long, default_value = "10s", value_parser = crate::shared::client::parse_duration, requires = "reconnect")]
|
||||
pub reconnect_timeout: Duration,
|
||||
|
||||
#[command(flatten)]
|
||||
pub common: CommonClientArgs,
|
||||
|
||||
@@ -52,6 +86,7 @@ pub struct ClientArgs {
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
short = 'f',
|
||||
default_value_t = OutputFormat::Table,
|
||||
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
|
||||
)]
|
||||
@@ -83,7 +118,7 @@ pub struct ClientArgs {
|
||||
pub header: HeaderMode,
|
||||
|
||||
/// Include extra fields in table output and final summaries.
|
||||
#[arg(long)]
|
||||
#[arg(short = 'v', long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
@@ -93,23 +128,34 @@ impl ClientArgs {
|
||||
/// Targets are only required here. `--list-columns` returns before this is
|
||||
/// called, so listing columns still needs no target.
|
||||
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
if self.targets_stdin && !self.is_continuous() {
|
||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||
if self.reconnect && !self.is_continuous() {
|
||||
return Err("--reconnect requires --continuous or --duration 0".to_owned());
|
||||
}
|
||||
prepare_managed_run(
|
||||
if self.targets_stdin && !self.is_continuous() {
|
||||
return Err("--targets-stdin requires --continuous or --duration 0".to_owned());
|
||||
}
|
||||
let mut setup = prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
if self.continuous {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
self.duration
|
||||
},
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
stdin_controlled: self.targets_stdin,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
if self.no_test {
|
||||
setup.client.request.run_mode = irtt_client::RunMode::NoTest;
|
||||
}
|
||||
Ok(setup)
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
self.duration == Duration::ZERO
|
||||
self.continuous || self.duration == Duration::ZERO
|
||||
}
|
||||
|
||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||
|
||||
@@ -541,7 +541,7 @@ columns! {
|
||||
SendDelay("sd", ["send_delay"], 9, right, "human-readable client-to-server delay");
|
||||
SendDelayUs("sd_us", ["send_delay_us"], 10, right, "client-to-server delay in signed microseconds");
|
||||
Ipdv("ipdv", [], 9, right, "human-readable round-trip IPDV for adjacent samples");
|
||||
IpdvUs("ipdv_us", [], 10, right, "round-trip IPDV in microseconds");
|
||||
IpdvUs("ipdv_us", [], 10, right, "round-trip IPDV in signed microseconds");
|
||||
ServerProcessing("proc", ["server_processing"], 9, right, "human-readable server processing time");
|
||||
ServerProcessingUs("server_processing_us", [], 20, right, "server processing time in microseconds");
|
||||
Bytes("bytes", [], 10, right, "packet bytes for packet events");
|
||||
@@ -820,11 +820,15 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
||||
Column::Ipdv => row
|
||||
.seq()
|
||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
||||
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))),
|
||||
.map(|pair| {
|
||||
CellValue::text(format_signed_duration(SignedDuration::from_nanos(
|
||||
pair.rtt_ipdv_ns,
|
||||
)))
|
||||
}),
|
||||
Column::IpdvUs => row
|
||||
.seq()
|
||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
||||
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
|
||||
.map(|pair| CellValue::Integer(pair.rtt_ipdv_ns / 1_000)),
|
||||
Column::ServerProcessing => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
||||
|
||||
@@ -58,8 +58,11 @@ pub async fn run_stream(
|
||||
args.verbose,
|
||||
)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
if let Some(warning) = reconnect_stats_warning(&args) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
if setup.stdin_controlled {
|
||||
return run_stdin_stream(setup, output_config, shutdown).await;
|
||||
return run_stdin_stream(setup, output_config, shutdown, &args).await;
|
||||
}
|
||||
let continuous = args.is_continuous();
|
||||
let target_count = setup.target_count();
|
||||
@@ -70,12 +73,21 @@ pub async fn run_stream(
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
|
||||
let (task, handle) = ManagedClient::task(
|
||||
{
|
||||
let mut config = setup.managed_config();
|
||||
config.reconnect_timeout = args.reconnect.then_some(args.reconnect_timeout);
|
||||
config
|
||||
},
|
||||
setup.managed_targets(),
|
||||
)?;
|
||||
let mut events = handle.subscribe()?;
|
||||
let mut status = handle.subscribe_status();
|
||||
let owner = ManagedWorker::start(task, handle.clone())?;
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
let mut stream_output = StreamOutput {
|
||||
continuous: args.is_continuous(),
|
||||
collect_stats: !args.no_stats,
|
||||
config: output_config,
|
||||
header_printed: false,
|
||||
print_final_summary: false,
|
||||
@@ -86,6 +98,7 @@ pub async fn run_stream(
|
||||
let mut stats = setup
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|_| !args.no_stats)
|
||||
.map(|target| {
|
||||
(
|
||||
target.label.clone(),
|
||||
@@ -167,13 +180,14 @@ pub async fn run_stream(
|
||||
})
|
||||
}),
|
||||
};
|
||||
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
|
||||
stream_output.print_final_summary =
|
||||
!args.no_stats && !args.no_test && should_print_final_summary(continuous, interrupted);
|
||||
stream_output.show_running_only_summary_note =
|
||||
continuous && interrupted && stream_output.print_final_summary;
|
||||
for target in &setup.targets {
|
||||
let target_stats = stats.get(&target.label).expect(
|
||||
"every prepared target must have a stats collector inserted before the run begins",
|
||||
);
|
||||
let Some(target_stats) = stats.get(&target.label) else {
|
||||
continue;
|
||||
};
|
||||
if multi_target
|
||||
&& stream_output.print_final_summary
|
||||
&& stream_output.config.prints_summary()
|
||||
@@ -360,17 +374,22 @@ fn process_stdin_event<W: Write>(
|
||||
terminal_targets: &mut BoundedTargetSet,
|
||||
) -> io::Result<()> {
|
||||
match event {
|
||||
ManagedEvent::TargetStateChanged { target, .. } => {
|
||||
ManagedEvent::TargetStateChanged { target, .. } if stream_output.collect_stats => {
|
||||
stats
|
||||
.entry(target)
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
}
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let collector = stats
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
let update = collector.process(&event);
|
||||
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
|
||||
let update = stream_output.collect_stats.then(|| {
|
||||
let collector = stats
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
if matches!(event, ClientEvent::SessionStarted(_)) {
|
||||
*collector = StatsCollector::new(stats_config(true));
|
||||
}
|
||||
collector.process(&event)
|
||||
});
|
||||
stream_output.print_event(&event, Some(target.id.as_str()), update.as_ref())?;
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome }
|
||||
if terminal_targets.insert(outcome.target.clone()) =>
|
||||
@@ -401,7 +420,11 @@ fn reconcile_stdin_stats(
|
||||
fn snapshot_stdin_summary_targets(
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
status: &ManagedStatus,
|
||||
enabled: bool,
|
||||
) -> BTreeSet<TargetInstance> {
|
||||
if !enabled {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
let targets = status
|
||||
.targets
|
||||
.iter()
|
||||
@@ -426,11 +449,19 @@ async fn run_stdin_stream(
|
||||
setup: ManagedRunSetup,
|
||||
output_config: OutputConfig,
|
||||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
args: &ClientArgs,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
|
||||
let (task, handle) = ManagedClient::task(
|
||||
{
|
||||
let mut config = setup.managed_config();
|
||||
config.reconnect_timeout = args.reconnect.then_some(args.reconnect_timeout);
|
||||
config
|
||||
},
|
||||
setup.managed_targets(),
|
||||
)?;
|
||||
let mut events = handle.subscribe()?;
|
||||
let mut status = handle.subscribe_status();
|
||||
let owner = ManagedWorker::start(task, handle.clone())?;
|
||||
@@ -439,6 +470,8 @@ async fn run_stdin_stream(
|
||||
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
let mut stream_output = StreamOutput {
|
||||
continuous: args.is_continuous(),
|
||||
collect_stats: !args.no_stats,
|
||||
config: output_config,
|
||||
header_printed: false,
|
||||
print_final_summary: false,
|
||||
@@ -509,7 +542,11 @@ async fn run_stdin_stream(
|
||||
}
|
||||
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,
|
||||
!args.no_stats,
|
||||
));
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
@@ -536,7 +573,7 @@ async fn run_stdin_stream(
|
||||
}
|
||||
Err(error) => {
|
||||
stop = Some(StdinStop::Fatal(format!("invalid --targets-stdin target set: {error}")));
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats));
|
||||
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
|
||||
}
|
||||
}
|
||||
@@ -557,7 +594,7 @@ async fn run_stdin_stream(
|
||||
}
|
||||
Err(_) => {
|
||||
stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned()));
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow(), !args.no_stats));
|
||||
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
|
||||
}
|
||||
}
|
||||
@@ -594,7 +631,8 @@ async fn run_stdin_stream(
|
||||
);
|
||||
}
|
||||
|
||||
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
|
||||
let print_summary =
|
||||
!args.no_stats && matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
|
||||
stream_output.print_final_summary = print_summary;
|
||||
stream_output.show_running_only_summary_note = print_summary;
|
||||
if let Some(summary_targets) = summary_targets {
|
||||
@@ -635,11 +673,17 @@ fn process_event<W: Write>(
|
||||
) -> io::Result<()> {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let collector = stats
|
||||
.entry(target.id.as_str().to_owned())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(false)));
|
||||
let update = collector.process(&event);
|
||||
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
|
||||
let update = stream_output.collect_stats.then(|| {
|
||||
let collector = stats
|
||||
.get_mut(target.id.as_str())
|
||||
.expect("prepared target has a statistics collector");
|
||||
if matches!(event, ClientEvent::SessionStarted(_)) {
|
||||
let config = stats_config(stream_output.continuous);
|
||||
*collector = StatsCollector::new(config);
|
||||
}
|
||||
collector.process(&event)
|
||||
});
|
||||
stream_output.print_event(&event, Some(target.id.as_str()), update.as_ref())?;
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome } => {
|
||||
terminal_targets.insert(outcome.target.clone());
|
||||
@@ -677,6 +721,8 @@ fn dropped_event_warning(dropped_events: u64) -> Option<String> {
|
||||
}
|
||||
|
||||
struct StreamOutput<'a, W: Write> {
|
||||
continuous: bool,
|
||||
collect_stats: bool,
|
||||
config: OutputConfig,
|
||||
header_printed: bool,
|
||||
print_final_summary: bool,
|
||||
@@ -688,10 +734,10 @@ impl<W: Write> StreamOutput<'_, W> {
|
||||
&mut self,
|
||||
event: &ClientEvent,
|
||||
target: Option<&str>,
|
||||
update: &EventStatsUpdate,
|
||||
update: Option<&EventStatsUpdate>,
|
||||
) -> io::Result<()> {
|
||||
self.print_header()?;
|
||||
if let Some(line) = self.config.render_event(event, target, Some(update)) {
|
||||
if let Some(line) = self.config.render_event(event, target, update) {
|
||||
writeln!(self.out, "{line}")?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -733,7 +779,7 @@ fn stats_config(continuous: bool) -> StatsConfig {
|
||||
}
|
||||
}
|
||||
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
|
||||
if args.is_continuous() || args.duration.is_zero() {
|
||||
if args.no_stats || args.no_test || args.is_continuous() || args.duration.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
|
||||
@@ -760,3 +806,8 @@ fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option
|
||||
};
|
||||
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
|
||||
}
|
||||
|
||||
fn reconnect_stats_warning(args: &ClientArgs) -> Option<&'static str> {
|
||||
(args.reconnect && !args.no_stats).then_some("irtt-rs: warning: statistics spanning multiple sessions may not accurately represent one uninterrupted measurement run; use --no-stats for measurement events only. Session statistics reset on reconnect.")
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ const DEFAULT_PORT: u16 = 2112;
|
||||
// Every policy option is optional. An omitted option leaves the corresponding
|
||||
// `ServerConfig` default in place rather than restating it here, so library and
|
||||
// CLI defaults cannot drift apart.
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[derive(Clone, Parser)]
|
||||
#[command(
|
||||
name = "irtt-server",
|
||||
version,
|
||||
about = "Minimal IRTT-compatible UDP server",
|
||||
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
|
||||
)]
|
||||
@@ -28,13 +29,14 @@ pub struct ServerArgs {
|
||||
)]
|
||||
pub bind: Vec<SocketAddr>,
|
||||
|
||||
// Decode during configuration preparation so Clap cannot echo rejected keys.
|
||||
/// HMAC key; every request must then carry a valid MAC.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "KEY",
|
||||
value_parser = parse_hmac_key,
|
||||
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>,
|
||||
|
||||
@@ -126,6 +128,23 @@ pub struct ServerArgs {
|
||||
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`].
|
||||
///
|
||||
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
|
||||
@@ -166,11 +185,10 @@ impl ServerArgs {
|
||||
}
|
||||
|
||||
/// Builds the server configuration this invocation asks for.
|
||||
#[must_use]
|
||||
pub fn server_config(&self) -> ServerConfig {
|
||||
pub fn server_config(&self) -> Result<ServerConfig, String> {
|
||||
let mut config = ServerConfig::default();
|
||||
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 {
|
||||
config = config.with_max_sessions(value);
|
||||
@@ -198,19 +216,10 @@ impl ServerArgs {
|
||||
if self.no_dscp {
|
||||
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.
|
||||
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
||||
if input == "0" {
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn serve(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = args.server_config();
|
||||
let config = args.server_config()?;
|
||||
let binds = args.resolve_binds();
|
||||
let is_implicit_default = args.bind.is_empty();
|
||||
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
||||
|
||||
@@ -10,7 +10,11 @@ use crate::shared::client::{
|
||||
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
||||
#[command(
|
||||
name = "irtt-tui",
|
||||
version,
|
||||
about = "Minimal IRTT-compatible TUI client"
|
||||
)]
|
||||
pub struct TuiArgs {
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
@@ -18,7 +22,7 @@ pub struct TuiArgs {
|
||||
num_args = 1..,
|
||||
required = true,
|
||||
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>,
|
||||
|
||||
@@ -33,13 +37,18 @@ pub struct TuiArgs {
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
short = 'd',
|
||||
default_value = "0",
|
||||
value_parser = parse_test_duration,
|
||||
help = "Test duration; use 0 for continuous mode",
|
||||
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
||||
help = "Test duration (e.g. 2.5s, 1m30s, 1h); use --continuous for continuous mode (0 is also accepted)",
|
||||
long_help = "Test duration (e.g. 2.5s, 1m30s, 500us, 100ns, 1h); use --continuous for continuous mode (0 is also accepted). The TUI defaults to continuous mode."
|
||||
)]
|
||||
pub duration: Duration,
|
||||
|
||||
/// Run continuously until interrupted (equivalent to --duration 0).
|
||||
#[arg(long, conflicts_with = "duration")]
|
||||
pub continuous: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
pub common: CommonClientArgs,
|
||||
}
|
||||
@@ -52,7 +61,11 @@ impl TuiArgs {
|
||||
pub async fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
if self.continuous {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
self.duration
|
||||
},
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
@@ -63,7 +76,7 @@ impl TuiArgs {
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
self.duration == Duration::ZERO
|
||||
self.continuous || self.duration == Duration::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,11 +89,10 @@ fn with_shutdown_flag(
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let shutdown = crate::signal::install_async_signal_handler()
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||
let args =
|
||||
crate::version::parse::<crate::cmd::client::ClientArgs>(argv, crate::version::client);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
@@ -107,10 +106,9 @@ fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
with_shutdown_flag(|shutdown_requested| {
|
||||
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
||||
let args =
|
||||
crate::version::parse::<crate::cmd::server::ServerArgs>(argv, crate::version::server);
|
||||
crate::cmd::server::run_server(args, shutdown_requested)
|
||||
})
|
||||
}
|
||||
@@ -122,11 +120,9 @@ fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Err
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let shutdown = crate::signal::install_async_signal_handler()
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||
let args = crate::version::parse::<crate::cmd::tui::TuiArgs>(argv, crate::version::client);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
|
||||
@@ -6,3 +6,5 @@ pub mod cmd;
|
||||
pub mod entry;
|
||||
pub mod shared;
|
||||
pub mod signal;
|
||||
|
||||
mod version;
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{fmt, time::Duration};
|
||||
use std::{fmt, net::IpAddr, net::SocketAddr, time::Duration};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
|
||||
@@ -18,16 +18,21 @@ pub struct CommonClientArgs {
|
||||
#[arg(long, conflicts_with_all = ["ipv4", "ipv6"], long_help = "Probe available IPv4 and IPv6 paths separately for each hostname. Labels gain /v4 or /v6; IP literals remain single targets. Families are discovered when a target is added; reconnects resolve the original hostname again within the chosen family.")]
|
||||
pub dual_stack: bool,
|
||||
|
||||
/// Probe interval.
|
||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||
/// Positive probe interval (e.g. 2.5s, 1m30s, 500us, 100ns, 1h).
|
||||
#[arg(short = 'i', long, default_value = "1s", value_parser = parse_duration)]
|
||||
pub interval: Duration,
|
||||
|
||||
/// Comma-separated session-opening timeouts, one per attempt (each >= 200ms).
|
||||
#[arg(long, value_name = "DURATIONS", value_delimiter = ',', value_parser = parse_duration, long_help = "Comma-separated session-opening receive timeouts, one per attempt (each at least 200ms), e.g. 1s,2s,4s,8s. Omit to use the library default retry schedule.")]
|
||||
pub timeouts: Vec<Duration>,
|
||||
|
||||
/// UDP payload length.
|
||||
#[arg(long, default_value_t = 0, value_parser = parse_length)]
|
||||
#[arg(short = 'l', long, default_value_t = 0, value_parser = parse_length)]
|
||||
pub length: u32,
|
||||
|
||||
/// HMAC key.
|
||||
#[arg(long, value_parser = parse_hmac_key)]
|
||||
// Decode during configuration preparation so Clap cannot echo rejected keys.
|
||||
/// HMAC key: literal UTF-8 or lowercase 0x followed by hexadecimal bytes.
|
||||
#[arg(long)]
|
||||
pub hmac: Option<String>,
|
||||
|
||||
/// Clock mode to request.
|
||||
@@ -70,6 +75,38 @@ pub struct CommonClientArgs {
|
||||
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
|
||||
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.
|
||||
#[arg(long)]
|
||||
pub loose: bool,
|
||||
@@ -77,11 +114,13 @@ pub struct CommonClientArgs {
|
||||
|
||||
impl fmt::Debug for CommonClientArgs {
|
||||
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("timeouts", &self.timeouts)
|
||||
.field("length", &self.length)
|
||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("clock", &self.clock)
|
||||
@@ -90,8 +129,14 @@ impl fmt::Debug for CommonClientArgs {
|
||||
.field("server_fill", &self.server_fill)
|
||||
.field("dscp", &self.dscp)
|
||||
.field("ttl", &self.ttl)
|
||||
.field("loose", &self.loose)
|
||||
.finish()
|
||||
.field("bind_addr", &self.bind_addr);
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,63 +197,26 @@ 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> {
|
||||
let (number, unit) = split_duration(input)?;
|
||||
let value: u64 = number
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
||||
if value == 0 {
|
||||
let duration = parse_test_duration(input)?;
|
||||
if duration.is_zero() {
|
||||
return Err("duration must be greater than zero".to_owned());
|
||||
}
|
||||
match unit {
|
||||
"ms" => Ok(Duration::from_millis(value)),
|
||||
"s" => Ok(Duration::from_secs(value)),
|
||||
"m" => value
|
||||
.checked_mul(60)
|
||||
.map(Duration::from_secs)
|
||||
.ok_or_else(|| "duration is too large".to_owned()),
|
||||
_ => Err(format!(
|
||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
||||
)),
|
||||
}
|
||||
Ok(duration)
|
||||
}
|
||||
|
||||
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
|
||||
if input == "0" {
|
||||
return Ok(Duration::ZERO);
|
||||
}
|
||||
let (number, unit) = split_duration(input)?;
|
||||
let value: u64 = number
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
||||
if value == 0 {
|
||||
return Ok(Duration::ZERO);
|
||||
}
|
||||
match unit {
|
||||
"ms" => Ok(Duration::from_millis(value)),
|
||||
"s" => Ok(Duration::from_secs(value)),
|
||||
"m" => value
|
||||
.checked_mul(60)
|
||||
.map(Duration::from_secs)
|
||||
.ok_or_else(|| "duration is too large".to_owned()),
|
||||
_ => Err(format!(
|
||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_duration(input: &str) -> Result<(&str, &str), String> {
|
||||
let split = input
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
|
||||
let (number, unit) = input.split_at(split);
|
||||
if number.is_empty() || unit.is_empty() {
|
||||
return Err("duration must include a positive value and unit".to_owned());
|
||||
}
|
||||
if number.starts_with('-') {
|
||||
return Err("duration must be greater than zero".to_owned());
|
||||
}
|
||||
Ok((number, unit))
|
||||
humantime::parse_duration(input).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn parse_length(input: &str) -> Result<u32, String> {
|
||||
@@ -221,13 +229,6 @@ pub fn parse_length(input: &str) -> Result<u32, String> {
|
||||
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> {
|
||||
if input.is_empty() {
|
||||
return Err("server fill must not be empty".to_owned());
|
||||
|
||||
@@ -21,17 +21,22 @@ impl CommonClientArgs {
|
||||
/// Build the shared client configuration template for a managed run.
|
||||
///
|
||||
/// The config is endpoint-independent; managed targets own their addresses.
|
||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
||||
ClientConfig {
|
||||
address_family: self.address_family(),
|
||||
open: OpenPolicy {
|
||||
negotiation: if self.loose {
|
||||
NegotiationPolicy::Loose
|
||||
} else {
|
||||
NegotiationPolicy::Strict
|
||||
},
|
||||
..Default::default()
|
||||
pub fn to_client_config(&self, duration: Duration) -> Result<ClientConfig, String> {
|
||||
let mut open = OpenPolicy {
|
||||
negotiation: if self.loose {
|
||||
NegotiationPolicy::Loose
|
||||
} else {
|
||||
NegotiationPolicy::Strict
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
if !self.timeouts.is_empty() {
|
||||
open.timeouts.clone_from(&self.timeouts);
|
||||
}
|
||||
|
||||
Ok(ClientConfig {
|
||||
address_family: self.address_family(),
|
||||
open,
|
||||
request: SessionRequest {
|
||||
duration: (!duration.is_zero()).then_some(duration),
|
||||
interval: self.interval,
|
||||
@@ -45,16 +50,24 @@ impl CommonClientArgs {
|
||||
},
|
||||
auth: self
|
||||
.hmac
|
||||
.as_ref()
|
||||
.as_deref()
|
||||
.map(crate::shared::decode_hmac_key)
|
||||
.transpose()?
|
||||
.map_or(Authentication::Unauthenticated, |key| {
|
||||
Authentication::Hmac(HmacKey::new(key.as_bytes()))
|
||||
Authentication::Hmac(HmacKey::new(key))
|
||||
}),
|
||||
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,
|
||||
..SocketConfig::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ pub async fn prepare_managed_run(
|
||||
.await?;
|
||||
Ok(ManagedRunSetup {
|
||||
targets,
|
||||
client: common.to_client_config(duration),
|
||||
client: common.to_client_config(duration)?,
|
||||
pacing: selection.pacing.into(),
|
||||
stdin_controlled: selection.stdin_controlled,
|
||||
preparation,
|
||||
|
||||
@@ -88,12 +88,13 @@ impl From<GroupPacingArg> for ManagedPacing {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum TargetParseError {
|
||||
EmptyLabel,
|
||||
EmptyAddress,
|
||||
InvalidEscape,
|
||||
EmptyTarget,
|
||||
InvalidHmac(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetParseError {
|
||||
@@ -103,6 +104,7 @@ impl fmt::Display for TargetParseError {
|
||||
Self::EmptyAddress => "target address must not be empty",
|
||||
Self::InvalidEscape => "invalid target escape sequence",
|
||||
Self::EmptyTarget => "target must not be empty",
|
||||
Self::InvalidHmac(message) => message,
|
||||
};
|
||||
f.write_str(message)
|
||||
}
|
||||
@@ -197,7 +199,10 @@ fn parse_target_syntax(
|
||||
let auth = if value.is_empty() {
|
||||
TargetAuth::Override(Authentication::Unauthenticated)
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,2 +1,7 @@
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
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;
|
||||
@@ -0,0 +1,40 @@
|
||||
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||
use std::ffi::OsString;
|
||||
|
||||
pub(crate) fn app() -> String {
|
||||
clap::Command::new("irtt-rs")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.render_version()
|
||||
}
|
||||
|
||||
// Preserve Clap's version banner and append the compiled component versions.
|
||||
#[cfg(any(feature = "client", feature = "tui", feature = "server"))]
|
||||
pub(crate) fn parse<T: clap::Parser>(argv: Vec<OsString>, report: fn() -> String) -> T {
|
||||
match T::try_parse_from(argv) {
|
||||
Ok(args) => args,
|
||||
Err(err) if err.kind() == clap::error::ErrorKind::DisplayVersion => {
|
||||
print!("{}{}", err.render().ansi(), report());
|
||||
std::process::exit(0)
|
||||
}
|
||||
Err(err) => err.exit(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
pub(crate) fn client() -> String {
|
||||
format!(
|
||||
" client: {}\n stats: {}\n proto: {}\n",
|
||||
irtt_client::VERSION,
|
||||
irtt_stats::VERSION,
|
||||
irtt_proto::VERSION,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub(crate) fn server() -> String {
|
||||
format!(
|
||||
" server: {}\n proto: {}\n",
|
||||
irtt_server::VERSION,
|
||||
irtt_proto::VERSION,
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ categories = ["network-programming"]
|
||||
irtt-proto.workspace = true
|
||||
socket2 = { version = "0.6.5", features = ["all"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
||||
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true }
|
||||
|
||||
@@ -93,8 +93,6 @@ pub(crate) struct AsyncOpenState {
|
||||
deadline: Option<Instant>,
|
||||
deadline_timer: Option<Pin<Box<time::Sleep>>>,
|
||||
request_submitted: bool,
|
||||
stop_after_current_attempt: bool,
|
||||
stopped_after_current_attempt: bool,
|
||||
buffer: [u8; MAX_OPEN_PACKET_SIZE],
|
||||
cleanup: Option<AsyncOpenCleanup>,
|
||||
}
|
||||
@@ -106,8 +104,6 @@ impl AsyncOpenState {
|
||||
deadline: None,
|
||||
deadline_timer: None,
|
||||
request_submitted: false,
|
||||
stop_after_current_attempt: false,
|
||||
stopped_after_current_attempt: false,
|
||||
buffer: [0_u8; MAX_OPEN_PACKET_SIZE],
|
||||
cleanup: None,
|
||||
}
|
||||
@@ -128,21 +124,12 @@ impl AsyncOpenState {
|
||||
self.deadline = None;
|
||||
self.deadline_timer = None;
|
||||
self.request_submitted = false;
|
||||
self.stopped_after_current_attempt = self.stop_after_current_attempt;
|
||||
}
|
||||
|
||||
pub(crate) fn has_in_flight_work(&self) -> bool {
|
||||
self.request_submitted || self.cleanup.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn request_stop_after_current_attempt(&mut self) {
|
||||
self.stop_after_current_attempt = true;
|
||||
}
|
||||
|
||||
pub(crate) fn stopped_after_current_attempt(&self) -> bool {
|
||||
self.stopped_after_current_attempt
|
||||
}
|
||||
|
||||
fn deadline(&self) -> Instant {
|
||||
self.deadline
|
||||
.expect("an open attempt has an active deadline")
|
||||
@@ -324,10 +311,6 @@ impl AsyncClient {
|
||||
if state.attempt >= attempt_count {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
if state.stop_after_current_attempt && state.deadline.is_none() {
|
||||
state.stopped_after_current_attempt = true;
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
if state.deadline.is_none() {
|
||||
let timeout = self.machine.config().open.timeouts[state.attempt];
|
||||
if let Err(error) = state.start_attempt(timeout) {
|
||||
@@ -368,9 +351,6 @@ impl AsyncClient {
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
Poll::Ready(Ok(false)) => {
|
||||
state.finish_attempt();
|
||||
if state.stopped_after_current_attempt() {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Ok(true)) => {}
|
||||
@@ -396,9 +376,6 @@ impl AsyncClient {
|
||||
};
|
||||
if datagram.received_at.mono > state.deadline() {
|
||||
state.finish_attempt();
|
||||
if state.stopped_after_current_attempt() {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -778,6 +755,16 @@ impl AsyncClient {
|
||||
.map_err(ClientError::Socket)
|
||||
}
|
||||
|
||||
/// Cancel opening without waiting for a reply or socket writability.
|
||||
/// Retained post-token cleanup gets one best-effort nonblocking send.
|
||||
pub(crate) fn cancel_open(&self, state: &mut AsyncOpenState) -> Option<ClientError> {
|
||||
let cleanup = state.cleanup.take()?;
|
||||
if let Some(packet) = cleanup.packet() {
|
||||
let _ = self.socket.try_send(packet);
|
||||
}
|
||||
Some(cleanup.into_primary())
|
||||
}
|
||||
|
||||
fn poll_open_cleanup(
|
||||
&self,
|
||||
state: &mut AsyncOpenState,
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
//!
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Cargo package version of this compiled library.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
mod async_client;
|
||||
mod auth;
|
||||
|
||||
@@ -101,6 +101,9 @@ pub struct ManagedClientConfig {
|
||||
pub outcome_history_limit: usize,
|
||||
pub max_live_target_generations: usize,
|
||||
pub final_drain: Duration,
|
||||
/// Retry failed/closed sessions and detect silence while sending. Only valid
|
||||
/// for continuous measurement runs. None (the default) disables reconnection.
|
||||
pub reconnect_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for ManagedClientConfig {
|
||||
@@ -114,6 +117,7 @@ impl Default for ManagedClientConfig {
|
||||
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
|
||||
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
|
||||
final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
|
||||
reconnect_timeout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,6 +368,8 @@ pub struct ManagedTargetFailure {
|
||||
/// Configuration failure detected before any runtime work begins.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManagedConfigError {
|
||||
#[error("reconnection requires continuous measurement mode and a positive, schedulable silence timeout")]
|
||||
InvalidReconnectPolicy,
|
||||
#[error("FinishWhenQuiescent requires at least one initial target")]
|
||||
EmptyInitialTargets,
|
||||
#[error("duplicate managed target id {id}")]
|
||||
|
||||
@@ -327,7 +327,14 @@ impl ManagedClientTask {
|
||||
let mut non_send_deadline = None;
|
||||
let mut send_deadline = None;
|
||||
for target in &self.targets {
|
||||
non_send_deadline = non_send_deadline
|
||||
.into_iter()
|
||||
.chain(target.silence_deadline(self.config.reconnect_timeout))
|
||||
.min();
|
||||
match target.phase() {
|
||||
TargetPhase::Retrying { deadline } => {
|
||||
non_send_deadline = non_send_deadline.into_iter().chain(Some(*deadline)).min();
|
||||
}
|
||||
TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
|
||||
@@ -367,6 +367,15 @@ fn build_task(
|
||||
maximum: tokio::sync::Semaphore::MAX_PERMITS,
|
||||
});
|
||||
}
|
||||
if let Some(timeout) = config.reconnect_timeout {
|
||||
if timeout.is_zero()
|
||||
|| Instant::now().checked_add(timeout).is_none()
|
||||
|| config.client.request.duration.is_some()
|
||||
|| config.client.request.run_mode != crate::RunMode::Normal
|
||||
{
|
||||
return Err(ManagedConfigError::InvalidReconnectPolicy);
|
||||
}
|
||||
}
|
||||
if config.max_live_target_generations == 0 {
|
||||
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
|
||||
}
|
||||
@@ -417,6 +426,10 @@ fn build_task(
|
||||
server_addr: Arc::from(target.server_addr),
|
||||
remote: None,
|
||||
counters: TargetCounters::default(),
|
||||
sent_before_session: 0,
|
||||
retry_delay: std::time::Duration::from_millis(250),
|
||||
silence_since: None,
|
||||
last_reply_at: None,
|
||||
phase: Some(TargetPhase::Pending { client_config }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,6 +206,12 @@ impl ManagedClientTask {
|
||||
};
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
send_waiting = result.is_pending();
|
||||
if send_waiting {
|
||||
// A blocked sender is not evidence of an unresponsive peer. Arm
|
||||
// a fresh silence period only after transmission resumes.
|
||||
self.targets[index].silence_since = None;
|
||||
self.targets[index].last_reply_at = None;
|
||||
}
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -27,6 +27,9 @@ type ConnectFuture =
|
||||
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
|
||||
|
||||
pub(super) enum TargetPhase {
|
||||
Retrying {
|
||||
deadline: Instant,
|
||||
},
|
||||
Pending {
|
||||
client_config: ClientConfig,
|
||||
},
|
||||
@@ -65,6 +68,7 @@ pub(super) enum TargetPhase {
|
||||
impl TargetPhase {
|
||||
pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle {
|
||||
match self {
|
||||
Self::Retrying { .. } => ManagedTargetLifecycle::Pending,
|
||||
Self::Pending { .. } => ManagedTargetLifecycle::Pending,
|
||||
Self::Connecting { .. } => ManagedTargetLifecycle::Connecting,
|
||||
Self::Opening { .. } => ManagedTargetLifecycle::Opening,
|
||||
@@ -182,11 +186,30 @@ pub(super) struct TargetRuntime {
|
||||
pub(super) server_addr: Arc<str>,
|
||||
pub(super) remote: Option<std::net::SocketAddr>,
|
||||
pub(super) counters: TargetCounters,
|
||||
pub(super) sent_before_session: u64,
|
||||
pub(super) retry_delay: Duration,
|
||||
pub(super) silence_since: Option<Instant>,
|
||||
pub(super) last_reply_at: Option<Instant>,
|
||||
// None only while a synchronous operation owns the phase locally.
|
||||
pub(super) phase: Option<TargetPhase>,
|
||||
}
|
||||
|
||||
impl TargetRuntime {
|
||||
pub(super) fn silence_deadline(&self, timeout: Option<Duration>) -> Option<Instant> {
|
||||
let TargetPhase::Active {
|
||||
schedule,
|
||||
send_waiting,
|
||||
..
|
||||
} = self.phase()
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if *send_waiting || schedule.is_finished() {
|
||||
return None;
|
||||
}
|
||||
self.silence_since?.checked_add(timeout?)
|
||||
}
|
||||
|
||||
pub(super) fn phase(&self) -> &TargetPhase {
|
||||
self.phase
|
||||
.as_ref()
|
||||
@@ -218,16 +241,18 @@ impl TargetRuntime {
|
||||
self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. })
|
||||
}
|
||||
|
||||
/// Adopt the underlying client's authoritative sent count.
|
||||
///
|
||||
/// The client owns `packets_sent`; this runtime only mirrors it so a
|
||||
/// terminal outcome can report it after the client is gone.
|
||||
/// Combine the current client's authoritative sent count with retired
|
||||
/// sessions, so one target generation keeps durable accounting on retries.
|
||||
pub(super) fn sync_packets_sent(&mut self, client: &AsyncClient) {
|
||||
self.counters.packets_sent = client.packets_sent();
|
||||
self.counters.packets_sent = self
|
||||
.sent_before_session
|
||||
.saturating_add(client.packets_sent());
|
||||
}
|
||||
|
||||
pub(super) fn sync_live_packets_sent(&mut self) {
|
||||
self.counters.packets_sent = self.phase().client().packets_sent();
|
||||
self.counters.packets_sent = self
|
||||
.sent_before_session
|
||||
.saturating_add(self.phase().client().packets_sent());
|
||||
}
|
||||
|
||||
/// Build this target's durable outcome.
|
||||
@@ -284,6 +309,30 @@ impl ManagedClientTask {
|
||||
|
||||
pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
|
||||
for event in events {
|
||||
match &event {
|
||||
ClientEvent::EchoSent { sent_at, .. } => {
|
||||
let target = &mut self.targets[index];
|
||||
let anchor = target
|
||||
.last_reply_at
|
||||
.filter(|reply| {
|
||||
self.config.reconnect_timeout.is_some_and(|timeout| {
|
||||
sent_at.mono.duration_since(*reply) < timeout
|
||||
})
|
||||
})
|
||||
.unwrap_or(sent_at.mono);
|
||||
target.silence_since.get_or_insert(anchor);
|
||||
}
|
||||
ClientEvent::EchoReply { received_at, .. }
|
||||
| ClientEvent::LateReply { received_at, .. }
|
||||
| ClientEvent::DuplicateReply { received_at, .. } => {
|
||||
// A reply disarms silence until another probe is committed.
|
||||
// A healthy sender with a long interval can remain idle.
|
||||
self.targets[index].silence_since = None;
|
||||
self.targets[index].last_reply_at = Some(received_at.mono);
|
||||
self.targets[index].retry_delay = Duration::from_millis(250);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.targets[index].counters.observe(&event);
|
||||
self.publish_event(ManagedEvent::Client {
|
||||
target: self.targets[index].instance.clone(),
|
||||
@@ -298,6 +347,23 @@ impl ManagedClientTask {
|
||||
end_reason: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
) {
|
||||
if self.can_reconnect(index)
|
||||
&& matches!(
|
||||
end_reason,
|
||||
ManagedTargetEndReason::Failed(_)
|
||||
| ManagedTargetEndReason::PeerClosed
|
||||
| ManagedTargetEndReason::TestComplete
|
||||
)
|
||||
{
|
||||
let target = &mut self.targets[index];
|
||||
target.sent_before_session = target.counters.packets_sent;
|
||||
target.silence_since = None;
|
||||
target.last_reply_at = None;
|
||||
let deadline = Instant::now() + target.retry_delay;
|
||||
target.retry_delay = (target.retry_delay * 2).min(Duration::from_secs(10));
|
||||
self.install_target_phase(index, TargetPhase::Retrying { deadline });
|
||||
return;
|
||||
}
|
||||
let outcome = Arc::new(self.targets[index].outcome(end_reason, cleanup_failure));
|
||||
// Finalize history before the phase publisher installs Terminal, updates
|
||||
// pacing and publishes one complete status followed by the lifecycle event.
|
||||
@@ -311,6 +377,12 @@ impl ManagedClientTask {
|
||||
self.publish_event(ManagedEvent::TargetFinished { outcome });
|
||||
}
|
||||
|
||||
fn can_reconnect(&self, index: usize) -> bool {
|
||||
self.config.reconnect_timeout.is_some()
|
||||
&& !self.phase.is_stopping()
|
||||
&& self.targets[index].membership.is_desired()
|
||||
}
|
||||
|
||||
fn fail_target(&mut self, index: usize, phase: ManagedTargetFailurePhase, error: ClientError) {
|
||||
let failure = classify_client_error(phase, &error);
|
||||
self.finish_target(index, ManagedTargetEndReason::Failed(failure), None);
|
||||
@@ -332,6 +404,9 @@ impl ManagedClientTask {
|
||||
};
|
||||
client.discard_prepared_probe();
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
if self.can_reconnect(index) {
|
||||
return self.begin_close(index, primary_end, None, now);
|
||||
}
|
||||
match cleanup {
|
||||
OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now),
|
||||
OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now),
|
||||
@@ -359,6 +434,27 @@ impl ManagedClientTask {
|
||||
let retirement = self.effective_retirement(index);
|
||||
let stopping = self.phase.is_stopping() || retirement.is_some();
|
||||
match self.targets[index].phase_mut() {
|
||||
TargetPhase::Retrying { deadline } => {
|
||||
if stopping {
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
None,
|
||||
);
|
||||
false
|
||||
} else if now >= *deadline {
|
||||
let target = &self.targets[index].config;
|
||||
let mut client_config = self.config.client.clone();
|
||||
client_config.address_family = target
|
||||
.address_family
|
||||
.unwrap_or(client_config.address_family);
|
||||
client_config.auth = target.auth.resolve(&client_config.auth);
|
||||
self.install_target_phase(index, TargetPhase::Pending { client_config });
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
TargetPhase::Pending { .. } => {
|
||||
if stopping {
|
||||
self.finish_target(
|
||||
@@ -409,12 +505,12 @@ impl ManagedClientTask {
|
||||
let retirement = retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped);
|
||||
if !open.has_in_flight_work() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(index, retirement, None);
|
||||
return false;
|
||||
}
|
||||
open.request_stop_after_current_attempt();
|
||||
let cleanup_failure = client.cancel_open(open).map(|error| {
|
||||
classify_client_error(ManagedTargetFailurePhase::Opening, &error)
|
||||
});
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(index, retirement, cleanup_failure);
|
||||
return false;
|
||||
}
|
||||
match client.poll_open(open, cx) {
|
||||
Poll::Pending => false,
|
||||
@@ -438,64 +534,25 @@ impl ManagedClientTask {
|
||||
index,
|
||||
vec![ClientEvent::SessionStarted(started)],
|
||||
);
|
||||
if stopping {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.begin_drain(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
now,
|
||||
)
|
||||
} else {
|
||||
let previous = self.targets[index].take_phase();
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client: previous.into_client(),
|
||||
schedule,
|
||||
send_waiting: false,
|
||||
});
|
||||
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
|
||||
true
|
||||
}
|
||||
let previous = self.targets[index].take_phase();
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client: previous.into_client(),
|
||||
schedule,
|
||||
send_waiting: false,
|
||||
});
|
||||
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
|
||||
true
|
||||
}
|
||||
Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => {
|
||||
self.publish_client_events(
|
||||
index,
|
||||
vec![ClientEvent::NoTestCompleted(completed)],
|
||||
);
|
||||
let end_reason = if stopping {
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped)
|
||||
} else {
|
||||
ManagedTargetEndReason::NoTestComplete
|
||||
};
|
||||
self.finish_target(index, end_reason, None);
|
||||
self.finish_target(index, ManagedTargetEndReason::NoTestComplete, None);
|
||||
false
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
if stopping {
|
||||
let TargetPhase::Opening { open, .. } = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let cleanup_failure =
|
||||
(!open.stopped_after_current_attempt()).then(|| {
|
||||
classify_client_error(
|
||||
ManagedTargetFailurePhase::Opening,
|
||||
&error,
|
||||
)
|
||||
});
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
cleanup_failure,
|
||||
);
|
||||
} else {
|
||||
self.fail_target(index, ManagedTargetFailurePhase::Opening, error);
|
||||
}
|
||||
self.fail_target(index, ManagedTargetFailurePhase::Opening, error);
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -504,6 +561,16 @@ impl ManagedClientTask {
|
||||
if stopping {
|
||||
client.discard_prepared_probe();
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
if self.config.reconnect_timeout.is_some() {
|
||||
return self.begin_close(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
None,
|
||||
now,
|
||||
);
|
||||
}
|
||||
return self.begin_drain(
|
||||
index,
|
||||
retirement
|
||||
@@ -539,6 +606,22 @@ impl ManagedClientTask {
|
||||
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
|
||||
return false;
|
||||
}
|
||||
if self.targets[index]
|
||||
.silence_deadline(self.config.reconnect_timeout)
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
{
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
return self.begin_close(
|
||||
index,
|
||||
ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Receiving,
|
||||
kind: ManagedTargetFailureKind::Timeout,
|
||||
message: Arc::from("session silent while sending probes"),
|
||||
}),
|
||||
None,
|
||||
now,
|
||||
);
|
||||
}
|
||||
let TargetPhase::Active {
|
||||
client, schedule, ..
|
||||
} = self.targets[index].phase_mut()
|
||||
|
||||
@@ -45,7 +45,8 @@ struct UpdatePlan {
|
||||
|
||||
fn synchronously_retireable(phase: &TargetPhase) -> bool {
|
||||
match phase {
|
||||
TargetPhase::Pending { .. }
|
||||
TargetPhase::Retrying { .. }
|
||||
| TargetPhase::Pending { .. }
|
||||
| TargetPhase::Connecting { .. }
|
||||
| TargetPhase::Terminal { .. } => true,
|
||||
TargetPhase::Opening { open, .. } => !open.has_in_flight_work(),
|
||||
@@ -140,6 +141,10 @@ impl ManagedClientTask {
|
||||
membership: TargetMembership::Desired,
|
||||
remote: None,
|
||||
counters: TargetCounters::default(),
|
||||
sent_before_session: 0,
|
||||
retry_delay: std::time::Duration::from_millis(250),
|
||||
silence_since: None,
|
||||
last_reply_at: None,
|
||||
phase: Some(TargetPhase::Pending {
|
||||
client_config: planned.client_config,
|
||||
}),
|
||||
|
||||
@@ -10,4 +10,4 @@ publish = false
|
||||
irtt-client = { workspace = true, features = ["ancillary", "tokio"] }
|
||||
irtt-server.workspace = true
|
||||
irtt-proto.workspace = true
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
||||
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
||||
@@ -18,4 +18,4 @@ subtle = "2.6.1"
|
||||
thiserror = "2.0.21"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.11.0"
|
||||
proptest = "1.12.0"
|
||||
@@ -77,6 +77,9 @@
|
||||
//!
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Cargo package version of this compiled library.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub mod echo;
|
||||
mod envelope;
|
||||
pub mod error;
|
||||
|
||||
@@ -16,7 +16,7 @@ irtt-proto.workspace = true
|
||||
getrandom = "0.4.3"
|
||||
socket2 = { version = "0.6.5", features = ["all"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
||||
tokio = { version = "1.54.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
||||
|
||||
# Wildcard listeners recover each request's local destination address and send
|
||||
# the reply from it. Only these targets have a safe destination-metadata and
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
//! payload bytes never reach a reply under any mode.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Cargo package version of this compiled library.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
mod clock;
|
||||
mod config;
|
||||
mod core;
|
||||
|
||||
@@ -19,4 +19,4 @@ irtt-client.workspace = true
|
||||
name = "finite_vs_continuous"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.11.0"
|
||||
proptest = "1.12.0"
|
||||
@@ -175,29 +175,31 @@ impl CoreStats {
|
||||
let mut updates = Vec::new();
|
||||
|
||||
for pair in self.ipdv_tracker.insert(sample) {
|
||||
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(pair.rtt_ipdv_ns) else {
|
||||
let rtt_magnitude_ns = pair.rtt_ipdv_ns.saturating_abs();
|
||||
let send_magnitude_ns = pair.send_ipdv_ns.map(i128::saturating_abs);
|
||||
let receive_magnitude_ns = pair.receive_ipdv_ns.map(i128::saturating_abs);
|
||||
let Some(rtt_ipdv) = duration_from_non_negative_i128_ns(rtt_magnitude_ns) else {
|
||||
continue;
|
||||
};
|
||||
let send_ipdv = pair
|
||||
.send_ipdv_ns
|
||||
.and_then(duration_from_non_negative_i128_ns);
|
||||
let receive_ipdv = pair
|
||||
.receive_ipdv_ns
|
||||
.and_then(duration_from_non_negative_i128_ns);
|
||||
let send_ipdv = send_magnitude_ns.and_then(duration_from_non_negative_i128_ns);
|
||||
let receive_ipdv = receive_magnitude_ns.and_then(duration_from_non_negative_i128_ns);
|
||||
|
||||
self.ipdv_round_trip.push_ns(pair.rtt_ipdv_ns);
|
||||
self.ipdv_round_trip.push_ns(rtt_magnitude_ns);
|
||||
|
||||
if let Some(value) = pair.send_ipdv_ns {
|
||||
if let Some(value) = send_magnitude_ns {
|
||||
self.ipdv_send.push_ns(value);
|
||||
}
|
||||
|
||||
if let Some(value) = pair.receive_ipdv_ns {
|
||||
if let Some(value) = receive_magnitude_ns {
|
||||
self.ipdv_receive.push_ns(value);
|
||||
}
|
||||
|
||||
updates.push(IpdvPairUpdate {
|
||||
previous_seq: pair.previous_seq,
|
||||
current_seq: pair.current_seq,
|
||||
rtt_ipdv_ns: pair.rtt_ipdv_ns,
|
||||
send_ipdv_ns: pair.send_ipdv_ns,
|
||||
receive_ipdv_ns: pair.receive_ipdv_ns,
|
||||
rtt_ipdv,
|
||||
send_ipdv,
|
||||
receive_ipdv,
|
||||
|
||||
@@ -89,9 +89,9 @@ impl IpdvTracker {
|
||||
Some(CompletedIpdvPair {
|
||||
previous_seq,
|
||||
current_seq,
|
||||
rtt_ipdv_ns: abs_i128_ns(current.rtt_primary_ns - previous.rtt_primary_ns),
|
||||
send_ipdv_ns: send_ipdv_ns(previous, current).map(abs_i128_ns),
|
||||
receive_ipdv_ns: receive_ipdv_ns(previous, current).map(abs_i128_ns),
|
||||
rtt_ipdv_ns: current.rtt_primary_ns - previous.rtt_primary_ns,
|
||||
send_ipdv_ns: send_ipdv_ns(previous, current),
|
||||
receive_ipdv_ns: receive_ipdv_ns(previous, current),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -161,10 +161,6 @@ fn duration_ns_i128(duration: Duration) -> i128 {
|
||||
i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX)
|
||||
}
|
||||
|
||||
fn abs_i128_ns(value: i128) -> i128 {
|
||||
value.saturating_abs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
#![warn(missing_docs)]
|
||||
#![warn(rustdoc::missing_crate_level_docs)]
|
||||
|
||||
/// Cargo package version of this compiled library.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::ClientEvent;
|
||||
@@ -520,6 +523,12 @@ pub struct IpdvPairUpdate {
|
||||
pub previous_seq: u32,
|
||||
/// Sequence number of the later reply in the adjacent pair.
|
||||
pub current_seq: u32,
|
||||
/// Signed round-trip delay change (current minus previous) in nanoseconds.
|
||||
pub rtt_ipdv_ns: i128,
|
||||
/// Signed send-side delay change in nanoseconds, when available for both replies.
|
||||
pub send_ipdv_ns: Option<i128>,
|
||||
/// Signed receive-side delay change in nanoseconds, when available for both replies.
|
||||
pub receive_ipdv_ns: Option<i128>,
|
||||
/// Absolute round-trip IPDV between the adjacent replies.
|
||||
pub rtt_ipdv: Duration,
|
||||
/// Absolute send-side IPDV when send one-way delay is available for both replies.
|
||||
|
||||
@@ -191,10 +191,15 @@ impl ReplySample {
|
||||
client_receive_mono: received_at.mono,
|
||||
client_send_wall_ns: system_time_ns(sent_at.wall),
|
||||
client_receive_wall_ns: system_time_ns(received_at.wall),
|
||||
server_receive_mono_ns: server_timing.and_then(|timing| timing.receive_mono_ns),
|
||||
server_send_mono_ns: server_timing.and_then(|timing| timing.send_mono_ns),
|
||||
server_receive_wall_ns: server_timing.and_then(|timing| timing.receive_wall_ns),
|
||||
server_send_wall_ns: server_timing.and_then(|timing| timing.send_wall_ns),
|
||||
// Midpoint timestamps serve as both directional endpoints.
|
||||
server_receive_mono_ns: server_timing
|
||||
.and_then(|timing| timing.receive_mono_ns.or(timing.midpoint_mono_ns)),
|
||||
server_send_mono_ns: server_timing
|
||||
.and_then(|timing| timing.send_mono_ns.or(timing.midpoint_mono_ns)),
|
||||
server_receive_wall_ns: server_timing
|
||||
.and_then(|timing| timing.receive_wall_ns.or(timing.midpoint_wall_ns)),
|
||||
server_send_wall_ns: server_timing
|
||||
.and_then(|timing| timing.send_wall_ns.or(timing.midpoint_wall_ns)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+73
-3
@@ -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
|
||||
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
|
||||
values: `\\` for a literal backslash, `\@` for a literal `@`, `\=` for a
|
||||
literal `=`, and `\;` for a literal semicolon. Escaping `@` is necessary in a
|
||||
label or address when it would otherwise begin `@hmac=`; escaping the first
|
||||
`=` is how an unlabeled target or a label can contain that character. An `=` in
|
||||
an HMAC value, including Base64 padding, does not need escaping. Positional
|
||||
`=` is how an unlabeled target or a label can contain that character. An `=` in an HMAC value does not need escaping. Positional
|
||||
target arguments may contain a raw comma; stdin target sets have the additional
|
||||
`\,` escape described below.
|
||||
|
||||
@@ -93,6 +94,25 @@ labels; choose another alias if one collides with an explicit label.
|
||||
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
|
||||
|
||||
`--targets-stdin` is available only with continuous mode (`--duration 0`).
|
||||
@@ -152,8 +172,39 @@ desired set to 128 targets and retains at most 256 live target generations
|
||||
while replacements drain. The 128-target limit applies after dual-stack
|
||||
expansion: a hostname producing both families consumes two target slots.
|
||||
|
||||
## NEGOTIATION ONLY
|
||||
|
||||
`-n` / `--no-test` connects to each target, negotiates the requested parameters,
|
||||
and exits without sending measurement probes. For example:
|
||||
|
||||
```sh
|
||||
irtt-client --no-test host.example:2112 --duration 30s --interval 100ms
|
||||
```
|
||||
|
||||
The `no_test_completed` event reports successful negotiation, including the
|
||||
negotiated duration, interval, and packet length. Output format and column
|
||||
selection work as usual; no measurement summary or statistics-memory warning
|
||||
is printed. Negotiation failures use the normal target diagnostics and exit
|
||||
policy: an uninterrupted run exits nonzero if no target succeeds.
|
||||
|
||||
Duration options still specify the duration to negotiate; even with
|
||||
`--continuous` or `--duration 0`, the client exits after negotiation.
|
||||
`--no-test` cannot be combined with `--targets-stdin`.
|
||||
|
||||
## FINITE VERSUS CONTINUOUS OPERATION
|
||||
|
||||
`--duration` and `--interval` accept fractional and composite durations, such as
|
||||
`2.5s`, `1m30s`, and `1h`, as well as `ms`, `us` (or `µs`), and `ns` units
|
||||
(e.g. `500us` or `100ns`). A nonzero value requires a unit. The interval must
|
||||
be greater than zero; a zero test duration, including `0s`, selects continuous
|
||||
mode.
|
||||
|
||||
`--timeouts 1s,2s,4s,8s` sets the session-opening retry schedule. Each
|
||||
comma-separated duration is the receive timeout for one open attempt; attempts
|
||||
stop when a valid open reply arrives. The list must be nonempty, with each
|
||||
value at least `200ms`, and uses the same duration syntax as `--interval`.
|
||||
Omitting the option preserves the library default schedule.
|
||||
|
||||
`--duration` controls the run length:
|
||||
|
||||
- Any nonzero duration (default `10s`) is a **finite** run: probing stops
|
||||
@@ -169,6 +220,25 @@ See [MEASUREMENTS AND MEMORY](#measurements-and-memory) below for why this
|
||||
distinction also determines what statistics can be computed and how much
|
||||
memory a long run retains.
|
||||
|
||||
### Optional recovery and event-only runs
|
||||
|
||||
`--no-stats` disables statistics collection and final summaries while preserving individual measurement output. Statistics-dependent columns (such as IPDV) are unavailable. This does not remove the `irtt-stats` dependency.
|
||||
|
||||
`--reconnect` enables automatic recovery for individual targets in continuous mode (`--continuous` or `--duration 0`). Failed connections, closed sessions, and unresponsive servers are retried without interrupting other targets. Retries use exponential backoff from 250 ms up to 10 seconds.
|
||||
|
||||
`--reconnect-timeout DURATION` controls how long a session can go without valid replies before reconnection is attempted (default: `10s`). The timeout applies only while actively sending probes and should account for the probe interval and expected packet loss.
|
||||
|
||||
**Statistics reset on reconnection**, so final summaries only reflect the latest session per target. A warning is emitted when reconnection is enabled with statistics collection. Use `--no-stats` to disable collection and suppress the warning.
|
||||
|
||||
Example for continuous, multi-target CSV output without statistics:
|
||||
|
||||
```sh
|
||||
irtt-client --continuous --reconnect --no-stats \
|
||||
--format csv --header never \
|
||||
--columns client_receive_wall_ns,target,seq,rd_us,sd_us \
|
||||
reflector1 reflector2
|
||||
```
|
||||
|
||||
## OUTPUT
|
||||
|
||||
### `--format FORMAT`
|
||||
@@ -304,7 +374,7 @@ approaches a fixed multiple of `targets` regardless of how long it runs.
|
||||
## PEER CLOSE AND EXIT BEHAVIOR
|
||||
|
||||
A peer closing a finite run is accepted as a terminal, successful outcome.
|
||||
In continuous mode, an uninterrupted peer closure is treated as an error —
|
||||
Without `--reconnect`, in continuous mode, an uninterrupted peer closure is treated as an error —
|
||||
the client exits nonzero — so a process supervisor can restart it; an
|
||||
interrupted run (`Ctrl-C`) never reports peer closure as an error, whichever
|
||||
mode it was in.
|
||||
|
||||
+3
-2
@@ -74,8 +74,9 @@ IPv6 listener.
|
||||
|
||||
### `--hmac KEY`
|
||||
|
||||
HMAC key, taken as the UTF-8 bytes of this argument. With a key configured,
|
||||
every request must carry a valid MAC and every reply is authenticated;
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+7
-4
@@ -16,9 +16,9 @@ At least one target is required.
|
||||
|
||||
Target syntax, multiple targets, and pacing are identical to
|
||||
`irtt-client(1)` — see that manual for `[LABEL=]TARGET` syntax and the
|
||||
`--pacing staggered|burst` option. The full set of negotiation flags
|
||||
(`--interval`, `--length`, `--hmac`, `--clock`, `--tstamp`, `--stats`,
|
||||
`--sfill`, `--dscp`, `--ttl`, `--loose`) is shared with `irtt-client` as
|
||||
`--pacing staggered|burst` option. The client configuration flags
|
||||
(`--interval`, `--timeouts`, `--length`, `--hmac`, `--clock`, `--tstamp`,
|
||||
`--stats`, `--sfill`, `--dscp`, `--ttl`, `--loose`) are shared with `irtt-client` as
|
||||
well; consult that manual for their meaning.
|
||||
|
||||
Address-family controls are shared too: `-4` / `--ipv4` uses IPv4 only,
|
||||
@@ -40,7 +40,10 @@ See the client manual's address-family section for discovery errors and labels.
|
||||
|
||||
Unlike `irtt-client`, **the TUI defaults to continuous mode**
|
||||
(`--duration 0`): it runs until you quit. Pass an explicit `--duration` for
|
||||
a finite run:
|
||||
a finite run. Both `--duration` and `--interval` use the
|
||||
[client duration syntax](irtt-client.md#finite-versus-continuous-operation),
|
||||
including fractional values (`2.5s`), composite values (`1m30s`), and units
|
||||
from nanoseconds (`ns`) through hours (`h`):
|
||||
|
||||
```sh
|
||||
irtt-tui <server> --duration 30s
|
||||
|
||||
Generated
+16
-16
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
@@ -31,9 +31,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -76,9 +76,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
@@ -97,9 +97,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
@@ -174,9 +174,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
@@ -285,9 +285,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -316,9 +316,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
version = "1.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
@@ -347,9 +347,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ edition = "2021"
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
arbitrary = "1.4.2"
|
||||
arbitrary = "1.5.0"
|
||||
libfuzzer-sys = "0.4.13"
|
||||
|
||||
[dependencies.irtt-proto]
|
||||
|
||||
Reference in new issue
Block a user