Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c436bfb686 | ||
|
|
156db4c720 | ||
|
|
9c703ecff2 | ||
|
|
798efaf360 | ||
|
|
4b05c3027b | ||
|
|
596dd20bcf | ||
|
|
ebc1f2d0b1 | ||
|
|
a45396c236 | ||
|
|
743a050416 | ||
|
|
524d56f72c | ||
|
|
ccdf4ea39c | ||
|
|
1d211c81ad | ||
|
|
ded61b58e2 | ||
|
|
bdc5d152d4 | ||
|
|
8986caecb0 | ||
|
|
edff75e55a | ||
|
|
48a50d7f3a | ||
|
|
74e7532965 | ||
|
|
dc5b19059e | ||
|
|
50fe83a4c8 | ||
|
|
f2fce2f330 | ||
|
|
f350c82dae | ||
|
|
22d3f6cb27 | ||
|
|
85238682b8 | ||
|
|
9240152e4b | ||
|
|
7bbc89e1c0 | ||
|
|
b6043e4961 |
No files matched your search
@@ -60,7 +60,7 @@ jobs:
|
||||
exit 0
|
||||
|
||||
- name: Upload Clippy SARIF
|
||||
uses: github/codeql-action/upload-sarif@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
|
||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: rust-clippy-results.sarif
|
||||
|
||||
@@ -96,6 +96,11 @@ Prefer production interfaces and component/integration tests at the lowest archi
|
||||
|
||||
Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them.
|
||||
|
||||
Concurrency correctness is a production requirement, but deterministic coverage
|
||||
does not by itself justify a test-only synchronization hook. Prefer simplifying
|
||||
the concurrency machinery or observing receipts/status/events through the public
|
||||
control surface before introducing or restoring internal race gates.
|
||||
|
||||
### Every persistent test must earn its place
|
||||
|
||||
Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation.
|
||||
|
||||
@@ -34,6 +34,12 @@ release. The format is loosely based on
|
||||
|
||||
## irtt-client
|
||||
|
||||
### 0.5.3
|
||||
|
||||
#### Added
|
||||
|
||||
- `SocketConfig` can bind UDP sockets to a network interface and set a packet mark on Linux, Android, and Fuchsia, or select a routing FIB on FreeBSD.
|
||||
|
||||
### 0.5.2
|
||||
|
||||
#### Fixed
|
||||
|
||||
Generated
+86
-80
@@ -141,9 +141,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@@ -201,9 +201,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
@@ -242,14 +242,14 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
@@ -313,7 +313,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
@@ -403,7 +403,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -414,7 +414,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -479,7 +479,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
@@ -554,9 +554,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
@@ -678,9 +678,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
@@ -702,27 +702,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.13"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8"
|
||||
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "irtt-client"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"irtt-proto",
|
||||
"irtt-server",
|
||||
"nix 0.31.3",
|
||||
"proptest",
|
||||
"socket2",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -744,7 +743,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"proptest",
|
||||
"subtle",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -770,7 +769,7 @@ dependencies = [
|
||||
"irtt-proto",
|
||||
"nix 0.31.3",
|
||||
"socket2",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -779,6 +778,7 @@ name = "irtt-stats"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"irtt-client",
|
||||
"measurement-stats",
|
||||
"proptest",
|
||||
]
|
||||
|
||||
@@ -805,9 +805,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.105"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -822,7 +822,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -855,7 +855,7 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -887,9 +887,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.4"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
@@ -914,6 +914,12 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "measurement-stats"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edbfa45a060f44b15568637075ecebbe258c22f86ce6f2b5d58d7a64cbdb689"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -959,7 +965,7 @@ version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -972,7 +978,7 @@ version = "0.31.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -1118,9 +1124,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad"
|
||||
checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
@@ -1128,9 +1134,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f"
|
||||
checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -1138,9 +1144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5"
|
||||
checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -1151,9 +1157,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.9.1"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e"
|
||||
checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
@@ -1254,7 +1260,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
@@ -1367,7 +1373,7 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"compact_str",
|
||||
"critical-section",
|
||||
"hashbrown 0.17.1",
|
||||
@@ -1377,7 +1383,7 @@ dependencies = [
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
@@ -1432,7 +1438,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"hashbrown 0.17.1",
|
||||
"indoc",
|
||||
"instability",
|
||||
@@ -1452,7 +1458,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1495,11 +1501,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@@ -1569,7 +1575,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1616,9 +1622,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
@@ -1628,9 +1634,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.0"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
@@ -1705,9 +1711,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.5"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1733,7 +1739,7 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
@@ -1769,7 +1775,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bitflags 2.13.1",
|
||||
"bitflags 2.13.2",
|
||||
"fancy-regex",
|
||||
"filedescriptor",
|
||||
"finl_unicode",
|
||||
@@ -1814,11 +1820,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.20",
|
||||
"thiserror-impl 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1834,13 +1840,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1886,7 +1892,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1909,9 +1915,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
@@ -1944,9 +1950,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.4.3",
|
||||
@@ -1995,9 +2001,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -2008,9 +2014,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -2018,22 +2024,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.5",
|
||||
"syn 3.0.6",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.128"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -2155,18 +2161,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
version = "0.8.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
version = "0.8.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -180,148 +180,3 @@ fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
||||
argv.extend(args.iter().cloned());
|
||||
argv
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn applet_detection_maps_binary_names() {
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("/usr/bin/irtt-client"),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("irtt-tui"),
|
||||
Some(RequestedApplet::Tui)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0("irtt-server"),
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(detect_applet_from_argv0("irttd"), None);
|
||||
assert_eq!(detect_applet_from_argv0("irtt-rs"), None);
|
||||
assert_eq!(detect_applet_from_argv0("custom-name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_irtt_cli_binary_name_is_no_longer_a_recognized_applet() {
|
||||
assert_eq!(detect_applet_from_argv0("irtt-cli"), None);
|
||||
assert_eq!(detect_applet_from_argv0("/usr/bin/irtt-cli"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applet_detection_ignores_the_platform_executable_suffix() {
|
||||
let exe = std::env::consts::EXE_SUFFIX;
|
||||
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("/opt/irtt/irtt-server{exe}")),
|
||||
Some(RequestedApplet::Server)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_applet_from_argv0(&format!("irtt-client{exe}")),
|
||||
Some(RequestedApplet::Client)
|
||||
);
|
||||
|
||||
// The dispatcher's own name must not be taken for an unknown applet.
|
||||
let dispatcher = format!("irtt-rs{exe}");
|
||||
assert_eq!(detect_applet_from_argv0(&dispatcher), None);
|
||||
assert!(dispatch_from_argv(vec![dispatcher.into(), "--help".into()]).is_ok());
|
||||
|
||||
// A name that merely ends in the suffix letters keeps them.
|
||||
assert!(detect_applet_from_argv0("irtt-serverexe").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_executable_suffix_handling_is_assertable_from_any_host() {
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case("IRTT-RS.EXE", ".exe"), "IRTT-RS");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server", ".exe"),
|
||||
"irtt-server"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-serverexe", ".exe"),
|
||||
"irtt-serverexe"
|
||||
);
|
||||
assert_eq!(strip_suffix_ignoring_case(".exe", ".exe"), ".exe");
|
||||
assert_eq!(
|
||||
strip_suffix_ignoring_case("irtt-server.exe", ""),
|
||||
"irtt-server.exe"
|
||||
);
|
||||
// A multi-byte tail must not split mid-character.
|
||||
assert_eq!(strip_suffix_ignoring_case("irtt-så", ".exe"), "irtt-så");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_help_renders_dispatcher_help() {
|
||||
let dispatch = dispatch_from_argv(vec!["irtt-rs".into(), "--help".into()]).unwrap();
|
||||
let AppletDispatch::Help(help) = dispatch else {
|
||||
panic!("expected dispatcher help");
|
||||
};
|
||||
assert!(help.contains("IRTT-compatible multi-applet dispatcher"));
|
||||
assert!(help.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(help.contains("Run the terminal UI applet"));
|
||||
} else {
|
||||
assert!(help.contains("Terminal UI applet is not available in this build"));
|
||||
}
|
||||
assert!(help.contains("server"));
|
||||
if cfg!(feature = "server") {
|
||||
assert!(help.contains("Run the UDP server applet"));
|
||||
} else {
|
||||
assert!(help.contains("UDP server applet is not available in this build"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_without_applet_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["irtt-rs".into()]).unwrap_err();
|
||||
assert!(err.contains("choose an applet"));
|
||||
assert!(err.contains("client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("tui"));
|
||||
}
|
||||
assert!(err.contains("server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_subcommands_dispatch_to_applet_argv() {
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "client".into(), "host:2112".into()])
|
||||
.unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
argv: vec!["irtt-client".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "tui".into(), "host:2112".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Tui,
|
||||
argv: vec!["irtt-tui".into(), "host:2112".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
dispatch_from_argv(vec!["irtt-rs".into(), "server".into()]).unwrap(),
|
||||
AppletDispatch::Run {
|
||||
applet: RequestedApplet::Server,
|
||||
argv: vec!["irtt-server".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_irtt_binary_name_is_an_error() {
|
||||
let err = dispatch_from_argv(vec!["/usr/bin/irtt-typo".into()]).unwrap_err();
|
||||
assert!(err.contains("unknown applet name 'irtt-typo'"));
|
||||
assert!(err.contains("irtt-client"));
|
||||
if cfg!(feature = "tui") {
|
||||
assert!(err.contains("irtt-tui"));
|
||||
}
|
||||
assert!(err.contains("irtt-server"));
|
||||
}
|
||||
}
|
||||
@@ -151,195 +151,3 @@ pub enum HeaderMode {
|
||||
/// Never print headers.
|
||||
Never,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use irtt_client::NegotiationPolicy;
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-client"];
|
||||
argv.extend_from_slice(args);
|
||||
ClientArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_parser_keeps_finite_default_duration() {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_CLIENT_DURATION);
|
||||
assert_eq!(args.interval, Duration::from_secs(1));
|
||||
assert_eq!(args.format, OutputFormat::Table);
|
||||
assert_eq!(args.columns, None);
|
||||
assert_eq!(args.header, HeaderMode::Auto);
|
||||
assert!(!args.targets_stdin);
|
||||
assert!(!args.is_continuous());
|
||||
assert_eq!(args.timestamp_mode(), TimestampArg::Both);
|
||||
|
||||
let config = args.prepare().unwrap().client;
|
||||
assert_eq!(config.duration, Some(DEFAULT_CLIENT_DURATION));
|
||||
assert_eq!(config.received_stats, ReceivedStats::Both);
|
||||
assert_eq!(config.stamp_at, StampAt::Both);
|
||||
assert_eq!(config.clock, Clock::Both);
|
||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_parser_accepts_stream_formats_and_rejects_tui_output() {
|
||||
for format in ["table", "csv", "tsv", "jsonl"] {
|
||||
assert!(parse(&["--format", format, "127.0.0.1:2112"]).is_ok());
|
||||
}
|
||||
assert!(parse(&["--format", "tui", "127.0.0.1:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_columns_does_not_require_server() {
|
||||
let args = parse(&["--list-columns"]).unwrap();
|
||||
assert!(args.targets.is_empty());
|
||||
assert!(args.list_columns);
|
||||
// The parser accepts no target; preparation is where a target becomes
|
||||
// mandatory, and the stream command returns before preparing when
|
||||
// listing columns.
|
||||
let err = args.prepare().unwrap_err();
|
||||
assert!(err.contains("--list-columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_positional_targets_parse_as_targets() {
|
||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-b:2112");
|
||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_labeled_targets_parse() {
|
||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "ams");
|
||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
||||
assert_eq!(specs[1].label, "sg");
|
||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_labeled_and_unlabeled_targets_preserve_argument_order() {
|
||||
let args = parse(&["local.example", "eu=a.example", "backup.example"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
let labels: Vec<_> = specs.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local.example", "eu", "backup.example"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_labels_are_rejected() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
|
||||
let err = args.prepare().unwrap_err();
|
||||
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_target_endpoints_are_allowed() {
|
||||
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
||||
assert_eq!(args.prepare().unwrap().target_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_target_option_is_rejected() {
|
||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_option_accepts_supported_values() {
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Staggered
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Burst
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_help_lists_shared_protocol_options() {
|
||||
let help = ClientArgs::command().render_help().to_string();
|
||||
assert!(help.contains("--format <FORMAT>"));
|
||||
assert!(!help.contains("--target "));
|
||||
assert!(help.contains("--pacing <PACING>"));
|
||||
assert!(help.contains("--columns <COLUMNS>"));
|
||||
assert!(help.contains("--list-columns"));
|
||||
assert!(help.contains("--header <HEADER>"));
|
||||
assert!(help.contains("--tstamp <MODE>"));
|
||||
assert!(help.contains("--stats <STATS>"));
|
||||
assert!(help.contains("--sfill <STRING>"));
|
||||
assert!(help.contains("--dscp <0..=63>"));
|
||||
assert!(help.contains("--ttl <1..=255>"));
|
||||
assert!(help.contains("--loose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_empty_hmac_key_is_accepted() {
|
||||
let args = parse(&["--hmac", "secret", "127.0.0.1:2112"]).unwrap();
|
||||
let config = args.prepare().unwrap().client;
|
||||
assert_eq!(config.hmac_key, Some(b"secret".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_stdin_requires_continuous_mode_and_allows_empty_initial_set() {
|
||||
assert!(parse(&["--targets-stdin"]).unwrap().prepare().is_err());
|
||||
let args = parse(&["--duration", "0", "--targets-stdin"]).unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert!(setup.targets.is_empty());
|
||||
assert!(setup.stdin_controlled);
|
||||
let config = setup.managed_config();
|
||||
assert_eq!(
|
||||
config.completion,
|
||||
irtt_client::managed::ManagedCompletionPolicy::ExplicitStop
|
||||
);
|
||||
assert_eq!(config.max_live_target_generations, 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_format_summary_policy_is_table_only() {
|
||||
assert!(OutputFormat::Table.prints_summary());
|
||||
assert!(!OutputFormat::Csv.prints_summary());
|
||||
assert!(!OutputFormat::Tsv.prints_summary());
|
||||
assert!(!OutputFormat::Jsonl.prints_summary());
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ enum OutputRow {
|
||||
client_send_wall: SystemTime,
|
||||
bytes: usize,
|
||||
send_call: Duration,
|
||||
timer_error: Duration,
|
||||
timer_error: Option<Duration>,
|
||||
},
|
||||
EchoReply(ReplyRow),
|
||||
Loss {
|
||||
@@ -950,7 +950,7 @@ fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Opti
|
||||
},
|
||||
Column::TimerErrorUs => match row {
|
||||
OutputRow::EchoSent { timer_error, .. } => {
|
||||
Some(CellValue::Unsigned(duration_us(*timer_error)))
|
||||
timer_error.map(|error| CellValue::Unsigned(duration_us(error)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
@@ -1205,273 +1205,3 @@ fn write_json_string_content(out: &mut String, value: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::ClientTimestamp;
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
fn output_config(format: OutputFormat, columns: Option<&str>) -> OutputConfig {
|
||||
OutputConfig::new(format, columns, HeaderMode::Auto, false).unwrap()
|
||||
}
|
||||
|
||||
fn remote() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
||||
}
|
||||
|
||||
fn reply() -> ClientEvent {
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: UNIX_EPOCH + Duration::from_secs(1),
|
||||
mono: Instant::now(),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
wall: sent_at.wall + Duration::from_micros(1200),
|
||||
mono: sent_at.mono + Duration::from_micros(1200),
|
||||
};
|
||||
ClientEvent::EchoReply {
|
||||
seq: 7,
|
||||
remote: remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1200),
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_columns_includes_target() {
|
||||
assert!(OutputConfig::list_columns().contains("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_table_columns_always_include_target_first() {
|
||||
let header = output_config(OutputFormat::Table, None)
|
||||
.render_header()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(header.split_whitespace().next(), Some("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_structured_columns_always_include_target_first() {
|
||||
for format in [OutputFormat::Csv, OutputFormat::Tsv, OutputFormat::Jsonl] {
|
||||
let columns = default_columns(format, false);
|
||||
assert_eq!(columns.first(), Some(&Column::Target));
|
||||
assert_eq!(columns, ALL_COLUMNS);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_default_keyword_matches_omitted_columns() {
|
||||
for format in [
|
||||
OutputFormat::Table,
|
||||
OutputFormat::Csv,
|
||||
OutputFormat::Tsv,
|
||||
OutputFormat::Jsonl,
|
||||
] {
|
||||
let omitted = output_config(format, None).render_header();
|
||||
let explicit = output_config(format, Some("default")).render_header();
|
||||
assert_eq!(omitted, explicit);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_columns_do_not_insert_target() {
|
||||
let config = output_config(OutputFormat::Csv, Some("seq,effective_rtt_us"));
|
||||
let header = config.render_header().unwrap();
|
||||
|
||||
assert!(!header.split(',').any(|column| column == "target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_target_column_renders_label() {
|
||||
let config = output_config(OutputFormat::Csv, Some("target,seq,effective_rtt_us"));
|
||||
let line = config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.render_header().as_deref(),
|
||||
Some("target,seq,effective_rtt_us")
|
||||
);
|
||||
assert_eq!(line, "ams,7,1200");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_csv_renders_target_label_in_row() {
|
||||
let config = output_config(OutputFormat::Csv, None);
|
||||
let line = config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line.split(',').next(), Some("ams"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_jsonl_renders_target_key_first() {
|
||||
let config = output_config(OutputFormat::Jsonl, None);
|
||||
let line = config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
assert!(line.starts_with("{\"target\":\"ams\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_leaves_short_value_unchanged() {
|
||||
assert_eq!(truncate_for_table("ams", 18), "ams");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_leaves_exact_width_value_unchanged() {
|
||||
let value = "a".repeat(18);
|
||||
assert_eq!(truncate_for_table(&value, 18), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_shortens_long_value_to_width() {
|
||||
let value = "a".repeat(40);
|
||||
let truncated = truncate_for_table(&value, 18);
|
||||
|
||||
assert_eq!(truncated.chars().count(), 18);
|
||||
assert!(truncated.len() <= 18); // ASCII: byte len matches char len
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_keeps_prefix_and_appends_ellipsis_deterministically() {
|
||||
let value = "abcdefghijklmnopqrstuvwxyz";
|
||||
let truncated = truncate_for_table(value, 18);
|
||||
|
||||
assert_eq!(truncated, "abcdefghijklmno...");
|
||||
assert_eq!(truncated.chars().count(), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_table_does_not_split_multibyte_code_points() {
|
||||
// Each character below is multibyte in UTF-8; a byte-index slice
|
||||
// would panic or corrupt the string on a non-boundary cut.
|
||||
let value = "\u{1F600}".repeat(40); // 😀 repeated, well past width 18
|
||||
let truncated = truncate_for_table(&value, 18);
|
||||
|
||||
// Must not panic (validated by test completing) and must remain
|
||||
// valid UTF-8 with exactly 18 chars (15 prefix chars + "...").
|
||||
assert_eq!(truncated.chars().count(), 18);
|
||||
assert!(truncated.ends_with("..."));
|
||||
assert_eq!(truncated.chars().filter(|c| *c == '\u{1F600}').count(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_row_with_long_target_keeps_next_column_aligned() {
|
||||
let config = output_config(OutputFormat::Table, None);
|
||||
let long_target = "a".repeat(40);
|
||||
let header = config.render_header().unwrap();
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Target column is truncated to its table width (18), so the
|
||||
// "event" column starts at the same offset for both a normal and an
|
||||
// overlong target label.
|
||||
let short_config = output_config(OutputFormat::Table, None);
|
||||
let short_line = short_config
|
||||
.render_event(&reply(), Some("ams"), Some(&EventRenderStats::default()))
|
||||
.unwrap();
|
||||
|
||||
let target_width = Column::Target.table_width();
|
||||
let expected_event_offset = target_width + " ".len();
|
||||
assert_eq!(&header[expected_event_offset..][..5], "event");
|
||||
// The event column ("echo_reply") starts at the same offset
|
||||
// regardless of whether the target label was short or overlong.
|
||||
assert_eq!(
|
||||
&line[expected_event_offset..][.."echo_reply".len()],
|
||||
"echo_reply"
|
||||
);
|
||||
assert_eq!(
|
||||
&short_line[expected_event_offset..][.."echo_reply".len()],
|
||||
"echo_reply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Csv, Some("target,seq"));
|
||||
let long_target = "a".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{long_target},7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Tsv, Some("target,seq"));
|
||||
let long_target = "b".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{long_target}\t7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonl_preserves_full_long_target_label() {
|
||||
let config = output_config(OutputFormat::Jsonl, Some("target,seq"));
|
||||
let long_target = "c".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(line, format!("{{\"target\":\"{long_target}\",\"seq\":7}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_table_columns_with_target_truncate_same_as_default() {
|
||||
let config = output_config(OutputFormat::Table, Some("target,seq"));
|
||||
let long_target = "d".repeat(40);
|
||||
let line = config
|
||||
.render_event(
|
||||
&reply(),
|
||||
Some(&long_target),
|
||||
Some(&EventRenderStats::default()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let target_width = Column::Target.table_width();
|
||||
let expected_prefix: String = long_target
|
||||
.chars()
|
||||
.take(target_width - 3)
|
||||
.chain("...".chars())
|
||||
.collect();
|
||||
assert!(line.starts_with(&expected_prefix));
|
||||
assert_eq!(&line[..target_width], expected_prefix.as_str());
|
||||
}
|
||||
}
|
||||
@@ -938,321 +938,3 @@ 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}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::managed::{
|
||||
ManagedLifecycle, ManagedTargetFailure, ManagedTargetFailureKind,
|
||||
ManagedTargetFailurePhase, ManagedTargetLifecycle, ManagedTargetStatus, TargetId,
|
||||
};
|
||||
use std::{io::BufReader, io::Cursor, sync::Arc};
|
||||
|
||||
#[test]
|
||||
fn stdin_record_reader_removes_only_line_framing() {
|
||||
let mut reader = BufReader::with_capacity(2, Cursor::new(b" first \r\n\nthird\r"));
|
||||
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||
|
||||
assert_eq!(
|
||||
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||
Some(" first ".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||
Some(String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
read_stdin_record(&mut reader, &mut buffer).unwrap(),
|
||||
Some("third\r".to_owned())
|
||||
);
|
||||
assert_eq!(read_stdin_record(&mut reader, &mut buffer).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_record_reader_rejects_over_limit_unterminated_record() {
|
||||
let input = vec![b'x'; MAX_STDIN_RECORD_BYTES + 1];
|
||||
let mut reader = BufReader::with_capacity(17, Cursor::new(input));
|
||||
let mut buffer = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||
|
||||
let error = read_stdin_record(&mut reader, &mut buffer).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
|
||||
assert!(error.to_string().contains("maximum size"));
|
||||
assert!(buffer.len() <= MAX_STDIN_RECORD_BYTES + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_mailbox_value_survives_an_earlier_submitted_acknowledgement() {
|
||||
let mailbox = StdinMailbox::default();
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
|
||||
let submitted = mailbox.latest().unwrap();
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
|
||||
let pending = mailbox.latest().unwrap();
|
||||
|
||||
mailbox.clear_if_current(submitted.revision);
|
||||
|
||||
assert_eq!(mailbox.latest().unwrap().revision, pending.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_update_retries_after_status_publishes_during_acknowledgement() {
|
||||
let mailbox = StdinMailbox::default();
|
||||
mailbox.publish(Vec::new());
|
||||
let target_set = mailbox.latest().unwrap();
|
||||
let saturated = Arc::new(status_with_targets(&[TargetInstance {
|
||||
id: TargetId::from("old"),
|
||||
generation: 1,
|
||||
}]));
|
||||
let capacity_freed = Arc::new(status_with_targets(&[]));
|
||||
// Capacity becomes available after the transaction rejected but before
|
||||
// its acknowledgement reaches the controller. The rejection path must
|
||||
// retain the pre-submission status, not this later snapshot.
|
||||
let mut retry_after =
|
||||
retry_after_capacity_rejection(&mailbox, &target_set, Arc::clone(&saturated));
|
||||
|
||||
assert!(retry_waits_for_status_change(
|
||||
&mut retry_after,
|
||||
&target_set,
|
||||
&saturated
|
||||
));
|
||||
assert!(!retry_waits_for_status_change(
|
||||
&mut retry_after,
|
||||
&target_set,
|
||||
&capacity_freed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_desired_set_supersedes_a_capacity_rejected_candidate_before_retry() {
|
||||
let mailbox = StdinMailbox::default();
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("a", "127.0.0.1:2112")]);
|
||||
let applied = mailbox.latest().unwrap();
|
||||
mailbox.clear_if_current(applied.revision);
|
||||
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("b", "127.0.0.1:2113")]);
|
||||
let rejected = mailbox.latest().unwrap();
|
||||
let saturated = Arc::new(status_with_targets(&[TargetInstance {
|
||||
id: TargetId::from("old"),
|
||||
generation: 1,
|
||||
}]));
|
||||
let mut retry_after = retry_after_capacity_rejection(&mailbox, &rejected, saturated);
|
||||
mailbox.publish(vec![ManagedTargetConfig::new("c", "127.0.0.1:2114")]);
|
||||
let replacement = mailbox.latest().unwrap();
|
||||
|
||||
assert!(!retry_waits_for_status_change(
|
||||
&mut retry_after,
|
||||
&replacement,
|
||||
&Arc::new(status_with_targets(&[TargetInstance {
|
||||
id: TargetId::from("old"),
|
||||
generation: 1,
|
||||
}]))
|
||||
));
|
||||
assert!(retry_after.is_none());
|
||||
assert_eq!(replacement.targets[0].id.as_str(), "c");
|
||||
mailbox.clear_if_current(replacement.revision);
|
||||
assert!(mailbox.latest().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_and_shutdown_stop_without_waiting_for_the_stdin_reader() {
|
||||
let mailbox = StdinMailbox::default();
|
||||
let shutdown_requested = AtomicBool::new(false);
|
||||
assert!(stdin_stop_request(&shutdown_requested, &mailbox).is_none());
|
||||
|
||||
mailbox.finish();
|
||||
assert!(matches!(
|
||||
stdin_stop_request(&shutdown_requested, &mailbox),
|
||||
Some(StdinStop::Eof)
|
||||
));
|
||||
|
||||
let mailbox = StdinMailbox::default();
|
||||
shutdown_requested.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(matches!(
|
||||
stdin_stop_request(&shutdown_requested, &mailbox),
|
||||
Some(StdinStop::Interrupted)
|
||||
));
|
||||
}
|
||||
|
||||
fn status_with_targets(targets: &[TargetInstance]) -> ManagedStatus {
|
||||
ManagedStatus {
|
||||
lifecycle: ManagedLifecycle::Running,
|
||||
stop_requested: false,
|
||||
applied_command_sequence: 0,
|
||||
desired_target_count: targets.len(),
|
||||
connecting_target_count: 0,
|
||||
opening_target_count: 0,
|
||||
active_target_count: 0,
|
||||
draining_target_count: 0,
|
||||
closing_target_count: 0,
|
||||
terminal_target_count: 0,
|
||||
total_target_outcomes: 0,
|
||||
successful_target_outcomes: 0,
|
||||
failed_target_outcomes: 0,
|
||||
peer_closed_target_outcomes: 0,
|
||||
discarded_target_outcomes: 0,
|
||||
targets: Arc::from(
|
||||
targets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|target| ManagedTargetStatus {
|
||||
target,
|
||||
desired: true,
|
||||
lifecycle: ManagedTargetLifecycle::Active,
|
||||
server_addr: Arc::from("127.0.0.1:2112"),
|
||||
remote: None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
recent_target_outcomes: Arc::from([]),
|
||||
final_outcome: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_accounting_prunes_retired_generations_and_retains_only_shutdown_snapshot() {
|
||||
let retired = TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 1,
|
||||
};
|
||||
let replacement = TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 2,
|
||||
};
|
||||
let live_status = status_with_targets(std::slice::from_ref(&replacement));
|
||||
let empty_status = status_with_targets(&[]);
|
||||
let mut stats = BTreeMap::from([
|
||||
(retired.clone(), StatsCollector::new(stats_config(true))),
|
||||
(replacement.clone(), StatsCollector::new(stats_config(true))),
|
||||
]);
|
||||
|
||||
reconcile_stdin_stats(&mut stats, &live_status, None);
|
||||
assert_eq!(stats.keys().cloned().collect::<Vec<_>>(), [replacement]);
|
||||
|
||||
let summary_targets = snapshot_stdin_summary_targets(&mut stats, &live_status);
|
||||
stats.insert(retired, StatsCollector::new(stats_config(true)));
|
||||
reconcile_stdin_stats(&mut stats, &empty_status, Some(&summary_targets));
|
||||
|
||||
assert_eq!(
|
||||
stats.keys().cloned().collect::<Vec<_>>(),
|
||||
[TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 2,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_deduplication_is_bounded_under_target_churn() {
|
||||
let mut terminal_targets = BoundedTargetSet::new(2);
|
||||
let target = |generation| TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation,
|
||||
};
|
||||
|
||||
assert!(terminal_targets.insert(target(1)));
|
||||
assert!(terminal_targets.insert(target(2)));
|
||||
assert!(terminal_targets.insert(target(3)));
|
||||
assert!(terminal_targets.insert(target(1)));
|
||||
assert!(!terminal_targets.insert(target(3)));
|
||||
}
|
||||
|
||||
fn long_finite_args() -> ClientArgs {
|
||||
use clap::Parser;
|
||||
|
||||
ClientArgs::try_parse_from([
|
||||
"irtt-client",
|
||||
"--duration",
|
||||
"4200s",
|
||||
"--interval",
|
||||
"100ms",
|
||||
"127.0.0.1:2112",
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Smallest target count whose aggregate estimate reaches the warning
|
||||
/// threshold, derived from the stats crate's own estimate rather than from
|
||||
/// any bytes-per-probe assumption held here.
|
||||
fn first_warning_target_count(args: &ClientArgs) -> usize {
|
||||
let probes = expected_probe_count(args.duration, args.interval);
|
||||
(1..=1024)
|
||||
.find(|targets| {
|
||||
let total = probes.saturating_mul(*targets as u64);
|
||||
stats_config(false).estimated_retained_bytes(total)
|
||||
>= FINITE_STATS_MEMORY_WARNING_BYTES
|
||||
})
|
||||
.expect("a long finite run should cross the warning threshold within 1024 targets")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_stats_memory_warning_accounts_for_target_count() {
|
||||
let args = long_finite_args();
|
||||
let crossing = first_warning_target_count(&args);
|
||||
|
||||
assert!(
|
||||
crossing > 1,
|
||||
"this run should need more than one target to cross the threshold, \
|
||||
otherwise the test proves nothing about target-count accounting"
|
||||
);
|
||||
assert!(
|
||||
finite_stats_memory_warning(&args, crossing - 1).is_none(),
|
||||
"an aggregate estimate below the threshold should not warn"
|
||||
);
|
||||
assert!(
|
||||
finite_stats_memory_warning(&args, crossing).is_some(),
|
||||
"an aggregate estimate at the threshold should warn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_stats_memory_warning_saturates_target_count_multiplication() {
|
||||
assert!(finite_stats_memory_warning(&long_finite_args(), usize::MAX).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lagged_events_are_counted_saturatingly() {
|
||||
let mut dropped = 0_u64;
|
||||
dropped = dropped.saturating_add(7);
|
||||
dropped = dropped.saturating_add(3);
|
||||
assert_eq!(dropped, 10);
|
||||
assert_eq!(u64::MAX.saturating_add(1), u64::MAX);
|
||||
assert!(dropped_event_warning(dropped)
|
||||
.unwrap()
|
||||
.contains("dropped 10 managed run events"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_failure_diagnostics_include_primary_and_cleanup_failures() {
|
||||
let target = ManagedTargetOutcome {
|
||||
target: TargetInstance {
|
||||
id: TargetId::from("edge"),
|
||||
generation: 1,
|
||||
},
|
||||
server_addr: Arc::from("127.0.0.1:2112"),
|
||||
remote: None,
|
||||
end_reason: ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Receiving,
|
||||
kind: ManagedTargetFailureKind::Protocol,
|
||||
message: "primary failure".into(),
|
||||
}),
|
||||
packets_sent: 0,
|
||||
replies_received: 0,
|
||||
duplicates: 0,
|
||||
late: 0,
|
||||
warning_events: 0,
|
||||
cleanup_failure: Some(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Closing,
|
||||
kind: ManagedTargetFailureKind::Socket,
|
||||
message: "cleanup failure".into(),
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
target_failure_messages(&target),
|
||||
[
|
||||
"irtt-rs: target edge failed (receiving protocol): primary failure",
|
||||
"irtt-rs: target edge cleanup failed (closing socket): cleanup failure",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -121,257 +121,3 @@ fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
time::{Duration, Instant, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
fn test_timestamp(offset: Duration) -> ClientTimestamp {
|
||||
ClientTimestamp {
|
||||
wall: UNIX_EPOCH + offset,
|
||||
mono: Instant::now() + offset,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_remote() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_summary_omits_optional_metric_sections() {
|
||||
let summary = StatsCollector::new(StatsConfig::finite()).snapshot();
|
||||
let output = format_summary(&summary);
|
||||
|
||||
assert!(output.contains("Metric"));
|
||||
assert!(output.contains("Min"));
|
||||
assert!(output.contains("Mean"));
|
||||
assert!(output.contains("Median"));
|
||||
assert!(output.contains("Max"));
|
||||
assert!(output.contains("Stddev"));
|
||||
assert!(output.contains("packets: sent=0 received=0 unique=0 lost=0"));
|
||||
assert!(!output.contains("RTT 0"));
|
||||
assert!(!output.contains("server processing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_formats_counts_and_available_metrics() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
let sent_at = test_timestamp(Duration::from_secs(1));
|
||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
||||
|
||||
collector.process(&ClientEvent::EchoSent {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
scheduled_at: sent_at.mono,
|
||||
sent_at,
|
||||
bytes: 64,
|
||||
send_call: Duration::from_micros(10),
|
||||
timer_error: Duration::from_micros(2),
|
||||
});
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: Some(ServerTiming {
|
||||
receive_wall_ns: None,
|
||||
receive_mono_ns: None,
|
||||
send_wall_ns: None,
|
||||
send_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
midpoint_mono_ns: None,
|
||||
processing: Some(Duration::from_micros(300)),
|
||||
}),
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
|
||||
let output = format_summary(&collector.snapshot());
|
||||
|
||||
assert!(output.contains("packets: sent=1 received=1 unique=1 lost=0 loss=0.00%"));
|
||||
assert!(output.contains("bytes: sent=64 received=64"));
|
||||
assert!(output.contains("RTT"));
|
||||
assert!(output.contains("1.2ms"));
|
||||
assert!(!output.contains("raw RTT"));
|
||||
assert!(!output.contains("adjusted RTT"));
|
||||
assert!(output.contains("server processing"));
|
||||
assert!(output.contains("300.0µs"));
|
||||
assert!(output.contains("send call"));
|
||||
assert!(output.contains("10.0µs"));
|
||||
assert!(output.contains("timer error"));
|
||||
assert!(output.contains("2.0µs"));
|
||||
assert!(!output.contains("medians unavailable"));
|
||||
}
|
||||
|
||||
/// Returns the Median cell of the row with this label.
|
||||
fn median_cell(output: &str, label: &str) -> String {
|
||||
let row = output
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(label))
|
||||
.unwrap_or_else(|| panic!("summary should contain a {label:?} row:\n{output}"));
|
||||
// Metric, Count, Min, Mean, Median, ...
|
||||
// Counting from the right keeps multi-word labels out of the way.
|
||||
row.split_whitespace()
|
||||
.rev()
|
||||
.nth(2)
|
||||
.unwrap_or_else(|| panic!("row {row:?} should have a Median cell"))
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Collects probes whose send-call, timer-error, and server-processing
|
||||
/// values each have a median distinct from their min, mean, and max, so
|
||||
/// the rendered cell can only come from a real median calculation.
|
||||
fn spread_finite_summary(config: StatsConfig) -> Snapshot {
|
||||
let mut collector = StatsCollector::new(config);
|
||||
for (seq, (send_call_us, timer_error_us, processing_us)) in
|
||||
[(10_u64, 2_u64, 100_u64), (40, 12, 700), (20, 4, 300)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let seq = seq as u32;
|
||||
let offset = Duration::from_secs(1) + Duration::from_millis(u64::from(seq));
|
||||
let sent_at = test_timestamp(offset);
|
||||
let received_at = test_timestamp(offset + Duration::from_micros(1500));
|
||||
|
||||
collector.process(&ClientEvent::EchoSent {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
scheduled_at: sent_at.mono,
|
||||
sent_at,
|
||||
bytes: 64,
|
||||
send_call: Duration::from_micros(send_call_us),
|
||||
timer_error: Duration::from_micros(timer_error_us),
|
||||
});
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: None,
|
||||
effective: SignedDuration::from_nanos(1_500_000),
|
||||
},
|
||||
server_timing: Some(ServerTiming {
|
||||
receive_wall_ns: None,
|
||||
receive_mono_ns: None,
|
||||
send_wall_ns: None,
|
||||
send_mono_ns: None,
|
||||
midpoint_wall_ns: None,
|
||||
midpoint_mono_ns: None,
|
||||
processing: Some(Duration::from_micros(processing_us)),
|
||||
}),
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
}
|
||||
collector.snapshot()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_summary_shows_medians_for_send_call_timer_error_and_processing() {
|
||||
let output = format_summary(&spread_finite_summary(StatsConfig::finite()));
|
||||
|
||||
// Medians of 10/20/40, 2/4/12, and 100/300/700 µs. None of these
|
||||
// equals the row's min, mean, or max.
|
||||
assert_eq!(median_cell(&output, "send call"), "20.0µs");
|
||||
assert_eq!(median_cell(&output, "timer error"), "4.0µs");
|
||||
assert_eq!(median_cell(&output, "server processing"), "300.0µs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_summary_still_shows_no_medians_for_those_rows() {
|
||||
let output = format_summary(&spread_finite_summary(StatsConfig::continuous()));
|
||||
|
||||
for label in ["send call", "timer error", "server processing"] {
|
||||
assert_eq!(
|
||||
median_cell(&output, label),
|
||||
"-",
|
||||
"continuous mode retains no exact samples for {label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbose_summary_includes_raw_and_adjusted_rtt_rows() {
|
||||
let mut collector = StatsCollector::new(StatsConfig::finite());
|
||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1500));
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
sent_at: test_timestamp(Duration::from_secs(1)),
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1500),
|
||||
adjusted: Some(SignedDuration::from_nanos(1_200_000)),
|
||||
effective: SignedDuration::from_nanos(1_200_000),
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
|
||||
let output = format_summary_with_options(
|
||||
&collector.snapshot(),
|
||||
SummaryFormatOptions {
|
||||
verbose: true,
|
||||
..SummaryFormatOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(output.contains("raw RTT"));
|
||||
assert!(output.contains("adjusted RTT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_only_note_is_format_option() {
|
||||
let summary = StatsCollector::new(StatsConfig::continuous()).snapshot();
|
||||
let output = format_summary_with_options(
|
||||
&summary,
|
||||
SummaryFormatOptions {
|
||||
show_running_only_note: true,
|
||||
..SummaryFormatOptions::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(output.contains("irtt-rs summary"));
|
||||
assert!(output.contains("medians unavailable"));
|
||||
assert!(output.contains("continuous mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_stats_can_format_negative_values() {
|
||||
let stats = TimeStats {
|
||||
count: 1,
|
||||
total_ns: -500,
|
||||
min_ns: Some(-500),
|
||||
max_ns: Some(-500),
|
||||
mean_ns: -500.0,
|
||||
median_ns: Some(-500.0),
|
||||
variance_ns2: 0.0,
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
write_time_row(&mut out, "RTT", &stats);
|
||||
assert!(out.contains("-500ns"));
|
||||
}
|
||||
}
|
||||
@@ -115,93 +115,3 @@ pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
||||
fn absent() -> String {
|
||||
ABSENT.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nanosecond_scalars_select_units_at_the_defined_boundaries() {
|
||||
assert_eq!(format_ns_f64(0.0), "0ns");
|
||||
assert_eq!(format_ns_f64(999.0), "999ns");
|
||||
assert_eq!(format_ns_f64(1_000.0), "1.0µs");
|
||||
assert_eq!(format_ns_f64(999_999.0), "1000.0µs");
|
||||
assert_eq!(format_ns_f64(1_000_000.0), "1.0ms");
|
||||
assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms");
|
||||
assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s");
|
||||
assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_nanosecond_scalars_keep_their_sign() {
|
||||
assert_eq!(format_ns_f64(-500.0), "-500ns");
|
||||
assert_eq!(format_ns_f64(-1_500.0), "-1.5µs");
|
||||
assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms");
|
||||
assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s");
|
||||
assert_eq!(format_ns_i128(-1_500), "-1.5µs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_and_floating_nanosecond_scalars_agree() {
|
||||
for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] {
|
||||
assert_eq!(format_ns_i128(value), format_ns_f64(value as f64));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn microseconds_use_the_micro_sign() {
|
||||
assert!(format_ns_f64(1_500.0).ends_with("µs"));
|
||||
assert!(!format_ns_f64(1_500.0).contains("us"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms");
|
||||
assert_eq!(format_duration(Duration::from_secs(1)), "1.000s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn signed_durations_render_as_nanosecond_scalars() {
|
||||
assert_eq!(
|
||||
format_signed_duration(SignedDuration::from_nanos(-1_500)),
|
||||
"-1.5µs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_non_finite_scalars_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_i128(None), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::NAN), ABSENT);
|
||||
assert_eq!(format_ns_f64(f64::INFINITY), ABSENT);
|
||||
assert_eq!(format_percent(f64::NAN), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "client")]
|
||||
fn absent_optional_ns_f64_renders_as_a_placeholder() {
|
||||
assert_eq!(format_optional_ns_f64(None), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn absent_counts_and_ratios_render_as_a_placeholder() {
|
||||
assert_eq!(format_optional_count(None), ABSENT);
|
||||
assert_eq!(format_percent_ratio(1, 0), ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentages_have_one_spelling() {
|
||||
assert_eq!(format_percent(0.0), "0.00%");
|
||||
assert_eq!(format_percent(12.345), "12.35%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "tui")]
|
||||
fn percent_ratios_and_counts_have_one_spelling() {
|
||||
assert_eq!(format_percent_ratio(1, 4), "25.00%");
|
||||
assert_eq!(format_percent_ratio(5, 4), "100.00%");
|
||||
assert_eq!(format_count(42), "42");
|
||||
assert_eq!(format_optional_count(Some(0)), "0");
|
||||
}
|
||||
}
|
||||
@@ -257,394 +257,3 @@ fn parse_duration_units(input: &str) -> Result<Duration, String> {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<ServerArgs, clap::Error> {
|
||||
with_env_lock(|| {
|
||||
let mut argv = vec!["irtt-server"];
|
||||
argv.extend_from_slice(args);
|
||||
ServerArgs::try_parse_from(argv)
|
||||
})
|
||||
}
|
||||
|
||||
fn bound(args: &[&str]) -> ServerArgs {
|
||||
let mut argv = vec!["--bind", "127.0.0.1:2112"];
|
||||
argv.extend_from_slice(args);
|
||||
parse(&argv).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_optional_and_parses_both_families() {
|
||||
let ipv4 = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(ipv4.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))]);
|
||||
|
||||
let ipv6 = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(ipv6.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||
|
||||
assert!(parse(&["--bind", "127.0.0.1"]).is_err());
|
||||
assert!(parse(&["--bind", "localhost:2112"]).is_err());
|
||||
// Still one address per option: comma-separated syntax is not a thing
|
||||
// here, and accepting it silently would be worse than rejecting it.
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112,[::1]:2112"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_explicit_bind_resolves_to_the_wildcard_default_pair() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"the empty list is the parsed representation of \"no explicit bind\""
|
||||
);
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[
|
||||
SocketAddr::from((Ipv6Addr::UNSPECIFIED, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::UNSPECIFIED, 2112)),
|
||||
],
|
||||
"zero explicit binds means the ordinary port on both wildcard families, IPv6 first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_replaces_the_defaults_rather_than_augmenting_them() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
[SocketAddr::from((Ipv4Addr::LOCALHOST, 2112))],
|
||||
"one explicit bind means exactly that one listener, not that listener plus defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_explicit_binds_are_unaffected_by_the_default_resolution() {
|
||||
let args = parse(&["--bind", "127.0.0.1:2112", "--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.resolve_binds(), args.bind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_is_repeatable_and_keeps_the_requested_order() {
|
||||
let args = parse(&[
|
||||
"--bind",
|
||||
"[::1]:2112",
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--bind",
|
||||
"127.0.0.1:2113",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args.bind,
|
||||
[
|
||||
SocketAddr::from((Ipv6Addr::LOCALHOST, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2112)),
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, 2113)),
|
||||
],
|
||||
"listeners are served in the order they were requested"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_configuration_is_one_policy_however_many_listeners_there_are() {
|
||||
// Addresses never enter the server configuration: every listener is
|
||||
// built from a clone of this one value.
|
||||
let one = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
let several = parse(&[
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--bind",
|
||||
"[::1]:2112",
|
||||
"--max-sessions",
|
||||
"4",
|
||||
])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
|
||||
assert_eq!(one, several);
|
||||
assert_eq!(several.max_sessions(), 4, "and it is a per-listener bound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_policy_options_keep_the_library_defaults() {
|
||||
assert_eq!(bound(&[]).server_config(), ServerConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_options_map_onto_the_server_config() {
|
||||
let config = bound(&[
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--max-sessions",
|
||||
"512",
|
||||
"--max-packet-length",
|
||||
"1472",
|
||||
"--min-interval",
|
||||
"20ms",
|
||||
"--burst",
|
||||
"3",
|
||||
"--idle-timeout",
|
||||
"30s",
|
||||
"--max-duration",
|
||||
"2m",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_restrictions_map_onto_the_server_config() {
|
||||
let config = bound(&["--timestamp-allowance", "single", "--no-dscp"]).server_config();
|
||||
|
||||
assert_eq!(
|
||||
config.timestamp_allowance(),
|
||||
TimestampAllowance::Single,
|
||||
"a request for both timestamps is negotiated to midpoint"
|
||||
);
|
||||
assert!(!config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_timestamp_allowance_mode_parses() {
|
||||
for (argument, expected) in [
|
||||
("dual", TimestampAllowance::Dual),
|
||||
("single", TimestampAllowance::Single),
|
||||
("none", TimestampAllowance::None),
|
||||
] {
|
||||
assert_eq!(
|
||||
bound(&["--timestamp-allowance", argument])
|
||||
.server_config()
|
||||
.timestamp_allowance(),
|
||||
expected,
|
||||
"--timestamp-allowance {argument}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(parse(&[
|
||||
"--bind",
|
||||
"127.0.0.1:2112",
|
||||
"--timestamp-allowance",
|
||||
"midpoint"
|
||||
])
|
||||
.is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--timestamp-allowance", "both"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_restrictions_are_off_when_their_flags_are_absent() {
|
||||
// Explicitly, and not only through the whole-config comparison above:
|
||||
// these are the two values that would silently change every existing
|
||||
// deployment's negotiation if the CLI restated them.
|
||||
let config = bound(&["--idle-timeout", "30s"]).server_config();
|
||||
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
|
||||
assert!(config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_zero_policy_values_are_preserved() {
|
||||
let config = bound(&[
|
||||
"--min-interval",
|
||||
"0",
|
||||
"--idle-timeout",
|
||||
"0ms",
|
||||
"--burst",
|
||||
"0",
|
||||
"--max-sessions",
|
||||
"0",
|
||||
"--max-packet-length",
|
||||
"0",
|
||||
])
|
||||
.server_config();
|
||||
|
||||
assert_eq!(config.min_send_interval(), Duration::ZERO);
|
||||
assert_eq!(config.idle_timeout(), Duration::ZERO);
|
||||
assert_eq!(config.burst_allowance(), 0);
|
||||
assert_eq!(config.max_sessions(), 0);
|
||||
assert_eq!(config.max_packet_length(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durations_require_a_supported_unit() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "30"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--min-interval", "20us"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "2h"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--idle-timeout", "ms"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected() {
|
||||
let err = parse(&["--bind", "127.0.0.1:2112", "--hmac", ""]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whitespace_only_hmac_key_is_accepted_literally() {
|
||||
let config = bound(&["--hmac", " "]).server_config();
|
||||
assert_eq!(config.hmac_key(), Some(b" ".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_maximum_duration_is_not_a_spelling_of_no_maximum() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0"]).is_err());
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--max-duration", "0s"]).is_err());
|
||||
assert_eq!(bound(&[]).server_config().max_test_duration(), None);
|
||||
}
|
||||
|
||||
/// Runs `body` with the given environment variables set, restoring
|
||||
/// whatever was there before (present or absent) once `body` returns.
|
||||
/// Held under `with_env_lock` for the whole span, including `body`, so a
|
||||
/// `parse` call elsewhere never observes a partially-applied environment.
|
||||
fn with_env<T>(vars: &[(&str, &str)], body: impl FnOnce() -> T) -> T {
|
||||
with_env_lock(|| {
|
||||
let previous: Vec<(&str, Option<String>)> = vars
|
||||
.iter()
|
||||
.map(|(k, _)| (*k, std::env::var(k).ok()))
|
||||
.collect();
|
||||
for (k, v) in vars {
|
||||
std::env::set_var(k, v);
|
||||
}
|
||||
let result = body();
|
||||
for (k, v) in previous {
|
||||
match v {
|
||||
Some(v) => std::env::set_var(k, v),
|
||||
None => std::env::remove_var(k),
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_variables_supply_policy_when_no_flag_is_given() {
|
||||
with_env(
|
||||
&[
|
||||
("IRTT_SERVER_MAX_SESSIONS", "512"),
|
||||
("IRTT_SERVER_MAX_PACKET_LENGTH", "1472"),
|
||||
("IRTT_SERVER_MIN_INTERVAL", "20ms"),
|
||||
("IRTT_SERVER_BURST", "3"),
|
||||
("IRTT_SERVER_IDLE_TIMEOUT", "30s"),
|
||||
("IRTT_SERVER_MAX_DURATION", "2m"),
|
||||
("IRTT_SERVER_TIMESTAMP_ALLOWANCE", "single"),
|
||||
("IRTT_SERVER_HMAC", "secret"),
|
||||
("IRTT_SERVER_NO_DSCP", "true"),
|
||||
],
|
||||
|| {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(config.max_sessions(), 512);
|
||||
assert_eq!(config.max_packet_length(), 1472);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(20));
|
||||
assert_eq!(config.burst_allowance(), 3);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(30));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(120)));
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert_eq!(config.hmac_key(), Some(b"secret".as_slice()));
|
||||
assert!(!config.dscp_allowed());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// `IRTT_SERVER_NO_DSCP` is the one policy variable whose value is a
|
||||
/// boolean rather than a number, a duration or a string, and the
|
||||
/// container/orchestrator deployments this whole environment surface
|
||||
/// exists for spell booleans `1`/`0`. clap's default parser for a flag
|
||||
/// accepts only the literals `true`/`false` and turns everything else
|
||||
/// into a *parse failure*, so `IRTT_SERVER_NO_DSCP=1` used to stop the
|
||||
/// server from starting at all rather than enabling the flag. Falsey
|
||||
/// semantics are what keeps that from being a startup failure.
|
||||
#[test]
|
||||
fn the_no_dscp_environment_variable_accepts_ordinary_boolean_spellings() {
|
||||
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
|
||||
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.expect("a truthy value must not fail argument parsing")
|
||||
.server_config();
|
||||
assert!(!config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||
});
|
||||
}
|
||||
|
||||
for value in ["0", "false", "no", "off", ""] {
|
||||
with_env(&[("IRTT_SERVER_NO_DSCP", value)], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112"])
|
||||
.expect("a falsey value must not fail argument parsing")
|
||||
.server_config();
|
||||
assert!(config.dscp_allowed(), "IRTT_SERVER_NO_DSCP={value}");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The command-line flag stays a flag: it takes no value, whatever the
|
||||
/// environment parser accepts.
|
||||
#[test]
|
||||
fn the_no_dscp_flag_still_takes_no_command_line_value() {
|
||||
assert!(parse(&["--bind", "127.0.0.1:2112", "--no-dscp=false"]).is_err());
|
||||
assert!(!bound(&["--no-dscp"]).server_config().dscp_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_flag_overrides_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_MAX_SESSIONS", "512")], || {
|
||||
let config = parse(&["--bind", "127.0.0.1:2112", "--max-sessions", "4"])
|
||||
.unwrap()
|
||||
.server_config();
|
||||
assert_eq!(
|
||||
config.max_sessions(),
|
||||
4,
|
||||
"the command-line value wins over the environment"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_wins_over_the_built_in_default() {
|
||||
with_env(&[("IRTT_SERVER_BURST", "9")], || {
|
||||
let config = bound(&[]).server_config();
|
||||
assert_eq!(
|
||||
config.burst_allowance(),
|
||||
9,
|
||||
"an unset flag still falls back to the environment before the library default"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irtt_server_bind_supplies_a_single_address_from_the_environment() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv4Addr::LOCALHOST, 2113))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bind_flag_overrides_irtt_server_bind() {
|
||||
with_env(&[("IRTT_SERVER_BIND", "127.0.0.1:2113")], || {
|
||||
let args = parse(&["--bind", "[::1]:2112"]).unwrap();
|
||||
assert_eq!(args.bind, [SocketAddr::from((Ipv6Addr::LOCALHOST, 2112))]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_irtt_server_bind_unset_keeps_the_wildcard_default() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert!(
|
||||
args.bind.is_empty(),
|
||||
"with no --bind and no IRTT_SERVER_BIND, resolve_binds still falls back to the wildcard pair"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! Serializes every test in this module tree that touches process
|
||||
//! environment variables.
|
||||
//!
|
||||
//! `ServerArgs` parsing reads `IRTT_SERVER_*` via `clap`'s `env` support, and
|
||||
//! `std::env::set_var`/`remove_var` mutate genuinely process-wide state.
|
||||
//! Every test that parses `ServerArgs` — whether or not it sets an env var
|
||||
//! itself — must go through [`with_env_lock`], or a parse in one test thread
|
||||
//! can observe a value another thread's test set only for the duration of
|
||||
//! its own assertions. Reentrant per-thread, since a test that sets env vars
|
||||
//! typically calls `parse` again from inside its own locked section.
|
||||
|
||||
use std::{cell::Cell, sync::Mutex};
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
thread_local! {
|
||||
static ENV_LOCK_HELD: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(crate) fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
if ENV_LOCK_HELD.with(Cell::get) {
|
||||
return f();
|
||||
}
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
ENV_LOCK_HELD.with(|held| held.set(true));
|
||||
let result = f();
|
||||
ENV_LOCK_HELD.with(|held| held.set(false));
|
||||
result
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
pub mod args;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod env_lock;
|
||||
pub mod run;
|
||||
|
||||
pub use args::ServerArgs;
|
||||
|
||||
@@ -105,105 +105,3 @@ async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::server::env_lock::with_env_lock;
|
||||
|
||||
/// The ordinary invocation, and the one that keeps the single-listener case
|
||||
/// on the same orchestration path as every other.
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_one_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server", "--bind", "127.0.0.1:0"]).unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_requested_shutdown_binds_every_listener_and_returns() {
|
||||
let args = with_env_lock(|| {
|
||||
ServerArgs::try_parse_from([
|
||||
"irtt-server",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
"--bind",
|
||||
"127.0.0.1:0",
|
||||
])
|
||||
.unwrap()
|
||||
});
|
||||
let shutdown_requested = AtomicBool::new(true);
|
||||
|
||||
run_server(args, &shutdown_requested).unwrap();
|
||||
}
|
||||
|
||||
fn default_binds() -> Vec<SocketAddr> {
|
||||
with_env_lock(|| {
|
||||
ServerArgs::try_parse_from(["irtt-server"])
|
||||
.unwrap()
|
||||
.resolve_binds()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_default_losing_an_unsupported_family_falls_back_to_the_other() {
|
||||
let binds = default_binds();
|
||||
let failed = binds[0];
|
||||
|
||||
let remaining = implicit_default_fallback(true, false, &binds, failed).unwrap();
|
||||
|
||||
assert_eq!(remaining, [binds[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supported_familys_failure_never_falls_back() {
|
||||
// The family itself works fine here; whatever made this specific
|
||||
// bind fail (port in use, permission, ...) is a real problem this
|
||||
// invocation should surface, not paper over.
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, true, &binds, binds[0]),
|
||||
None,
|
||||
"family_available: true means this is not a family-support problem"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_binds_never_fall_back() {
|
||||
let binds = default_binds();
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(false, false, &binds, binds[0]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_outside_the_requested_set_does_not_fall_back() {
|
||||
let binds = default_binds();
|
||||
let unrelated = SocketAddr::from(([127, 0, 0, 1], 2113));
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &binds, unrelated),
|
||||
None,
|
||||
"the failing address must actually be one this invocation asked for"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_the_only_remaining_default_address_does_not_fall_back() {
|
||||
let single = [SocketAddr::from(([0, 0, 0, 0], 2112))];
|
||||
|
||||
assert_eq!(
|
||||
implicit_default_fallback(true, false, &single, single[0]),
|
||||
None,
|
||||
"no address would be left to retry with"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::shared::client::TimestampArg;
|
||||
use crate::shared::client::{
|
||||
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
||||
ManagedRunSetup, TargetArg, TargetSelection,
|
||||
@@ -66,11 +64,6 @@ impl TuiArgs {
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
self.duration == Duration::ZERO
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||
self.common.tstamp
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TuiArgs {
|
||||
@@ -80,208 +73,3 @@ impl std::ops::Deref for TuiArgs {
|
||||
&self.common
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::{CommandFactory, Parser};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct SharedOnlyArgs {
|
||||
#[command(flatten)]
|
||||
common: CommonClientArgs,
|
||||
}
|
||||
|
||||
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-tui"];
|
||||
argv.extend_from_slice(args);
|
||||
TuiArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
fn parse_shared(args: &[&str]) -> Result<SharedOnlyArgs, clap::Error> {
|
||||
let mut argv = vec!["shared-only"];
|
||||
argv.extend_from_slice(args);
|
||||
SharedOnlyArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(args.targets.len(), 1);
|
||||
assert_eq!(args.prepare().unwrap().targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(args.pacing, GroupPacingArg::Staggered);
|
||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||
assert!(args.is_continuous());
|
||||
assert_eq!(args.prepare().unwrap().client.duration, None);
|
||||
|
||||
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(finite.duration, Duration::from_secs(30));
|
||||
assert_eq!(
|
||||
finite.prepare().unwrap().client.duration,
|
||||
Some(Duration::from_secs(30))
|
||||
);
|
||||
|
||||
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
|
||||
let help = TuiArgs::command().render_help().to_string();
|
||||
assert!(!help.contains("--output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_positional_targets_parse() {
|
||||
let args = parse(&["host-a:2112", "host-b:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[0].managed.server_addr, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-b:2112");
|
||||
assert_eq!(specs[1].managed.server_addr, "host-b:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_labeled_targets_parse() {
|
||||
let args = parse(&["ams=ams.example.com:2112", "sg=sg.example.com:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "ams");
|
||||
assert_eq!(specs[0].managed.server_addr, "ams.example.com:2112");
|
||||
assert_eq!(specs[1].label, "sg");
|
||||
assert_eq!(specs[1].managed.server_addr, "sg.example.com:2112");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_least_one_target_is_required() {
|
||||
// The TUI has no target-free mode, so this is rejected at parse time
|
||||
// rather than during preparation.
|
||||
assert!(parse(&[]).is_err());
|
||||
assert!(parse(&["ams=ams.example.com:2112"]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_labels_are_rejected() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap();
|
||||
let err = args.prepare().unwrap_err();
|
||||
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positional_target_strings_get_stable_suffixes() {
|
||||
let args = parse(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
let specs = args.prepare().unwrap().targets;
|
||||
|
||||
assert_eq!(specs[0].label, "host-a:2112");
|
||||
assert_eq!(specs[1].label, "host-a:2112#2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_target_endpoints_are_allowed() {
|
||||
let args = parse(&["127.0.0.1:2112", "127.0.0.1"]).unwrap();
|
||||
assert_eq!(args.prepare().unwrap().target_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_labelled_target_syntax_is_rejected() {
|
||||
assert!(parse(&["=127.0.0.1:2112"]).unwrap().prepare().is_err());
|
||||
assert!(parse(&["label="]).unwrap().prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_target_option_is_rejected() {
|
||||
assert!(parse(&["--target", "eu=host.example"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacing_option_accepts_supported_values() {
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "staggered", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Staggered
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["--pacing", "burst", "127.0.0.1:2112"])
|
||||
.unwrap()
|
||||
.pacing,
|
||||
GroupPacingArg::Burst
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_options_match_tui_config_mapping() {
|
||||
let shared = [
|
||||
"--interval",
|
||||
"250ms",
|
||||
"--length",
|
||||
"128",
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--clock",
|
||||
"monotonic",
|
||||
"--tstamp",
|
||||
"receive",
|
||||
"--stats",
|
||||
"count",
|
||||
"--sfill",
|
||||
"abc",
|
||||
"--dscp",
|
||||
"46",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--loose",
|
||||
];
|
||||
let tui_args = [
|
||||
"--duration",
|
||||
"30s",
|
||||
"--interval",
|
||||
"250ms",
|
||||
"--length",
|
||||
"128",
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--clock",
|
||||
"monotonic",
|
||||
"--tstamp",
|
||||
"receive",
|
||||
"--stats",
|
||||
"count",
|
||||
"--sfill",
|
||||
"abc",
|
||||
"--dscp",
|
||||
"46",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--loose",
|
||||
"127.0.0.1:2112",
|
||||
];
|
||||
let tui = parse(&tui_args).unwrap().prepare().unwrap().client;
|
||||
let common = parse_shared(&shared).unwrap().common;
|
||||
let shared = common.to_client_config(Duration::from_secs(30));
|
||||
|
||||
assert_eq!(shared.duration, tui.duration);
|
||||
assert_eq!(shared.interval, tui.interval);
|
||||
assert_eq!(shared.length, tui.length);
|
||||
assert_eq!(shared.received_stats, tui.received_stats);
|
||||
assert_eq!(shared.stamp_at, tui.stamp_at);
|
||||
assert_eq!(shared.clock, tui.clock);
|
||||
assert_eq!(shared.dscp, tui.dscp);
|
||||
assert_eq!(shared.hmac_key, tui.hmac_key);
|
||||
assert_eq!(shared.server_fill, tui.server_fill);
|
||||
assert_eq!(shared.negotiation_policy, tui.negotiation_policy);
|
||||
assert_eq!(shared.socket_config.ttl, tui.socket_config.ttl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_hmac_key_is_rejected_via_the_shared_args() {
|
||||
// CommonClientArgs is flattened into both the client and TUI parsers,
|
||||
// so proving rejection once here covers both without duplicating it.
|
||||
let err = parse(&["--hmac", "", "127.0.0.1:2112"]).unwrap_err();
|
||||
assert!(err.to_string().contains("HMAC key must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_help_lists_multi_target_options() {
|
||||
let help = TuiArgs::command().render_help().to_string();
|
||||
assert!(!help.contains("--target "));
|
||||
assert!(help.contains("--pacing <PACING>"));
|
||||
}
|
||||
}
|
||||
@@ -87,12 +87,18 @@ pub fn run_tui(
|
||||
) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
let previous_dropped = dropped_events;
|
||||
let drain_state = drain_tui_events(
|
||||
&mut events,
|
||||
&mut state,
|
||||
&mut terminal_targets,
|
||||
&mut dropped_events,
|
||||
);
|
||||
if dropped_events != previous_dropped {
|
||||
// Data loss changes the meaning of displayed statistics, even while
|
||||
// ordinary measurement refreshes are paused.
|
||||
render_if_due(&mut terminal, &state, &mut next_render, true)?;
|
||||
}
|
||||
match drain_state {
|
||||
ManagedDrainState::Empty => {
|
||||
thread::sleep(managed_tui_wait_duration(&next_render, state.paused));
|
||||
@@ -178,11 +184,16 @@ fn drain_tui_events(
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
dropped_events: &mut u64,
|
||||
) -> ManagedDrainState {
|
||||
drain_managed_events(events, dropped_events, |event| {
|
||||
let previous_dropped = *dropped_events;
|
||||
let drained = drain_managed_events(events, dropped_events, |event| {
|
||||
process_tui_event(event, state, terminal_targets);
|
||||
Ok::<(), std::convert::Infallible>(())
|
||||
})
|
||||
.expect("processing TUI managed events is infallible")
|
||||
.expect("processing TUI managed events is infallible");
|
||||
if *dropped_events != previous_dropped {
|
||||
state.mark_dropped_managed_events(*dropped_events);
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
fn drain_final_tui_events(
|
||||
@@ -301,3 +312,45 @@ fn managed_tui_wait_duration(next_render: &Instant, paused: bool) -> Duration {
|
||||
};
|
||||
render_wait.min(TUI_WAIT_SLICE)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
|
||||
#[test]
|
||||
fn subscription_lag_keeps_incomplete_statistics_visible_while_paused() {
|
||||
for paused in [false, true] {
|
||||
// The production subscription is a broadcast receiver. Overflow the
|
||||
// real channel rather than synthesizing a lag result or UI state.
|
||||
let (sender, mut events) = tokio::sync::broadcast::channel(1);
|
||||
sender.send(ManagedEvent::Started).unwrap();
|
||||
sender.send(ManagedEvent::Started).unwrap();
|
||||
let mut state = TuiState::default();
|
||||
if paused {
|
||||
state.toggle_pause();
|
||||
}
|
||||
let mut dropped = 0;
|
||||
let mut terminal_targets = HashSet::new();
|
||||
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
|
||||
assert_eq!(dropped, 1);
|
||||
let mut terminal = Terminal::new(TestBackend::new(200, 40)).unwrap();
|
||||
for _ in 0..2 {
|
||||
// Empty drains must not clear the warning on later redraws.
|
||||
drain_tui_events(&mut events, &mut state, &mut terminal_targets, &mut dropped);
|
||||
terminal
|
||||
.draw(|frame| super::super::ui::draw_dashboard(frame, &state))
|
||||
.unwrap();
|
||||
let text = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<String>();
|
||||
assert!(text.contains("incomplete:dropped=1"));
|
||||
assert_eq!(text.contains("display paused"), paused);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,10 @@
|
||||
use std::time::Duration;
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
#[derive(Clone, clap::Args)]
|
||||
pub struct CommonClientArgs {
|
||||
/// Probe interval.
|
||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||
@@ -63,6 +63,23 @@ pub struct CommonClientArgs {
|
||||
pub loose: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CommonClientArgs {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CommonClientArgs")
|
||||
.field("interval", &self.interval)
|
||||
.field("length", &self.length)
|
||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("clock", &self.clock)
|
||||
.field("tstamp", &self.tstamp)
|
||||
.field("stats", &self.stats)
|
||||
.field("server_fill", &self.server_fill)
|
||||
.field("dscp", &self.dscp)
|
||||
.field("ttl", &self.ttl)
|
||||
.field("loose", &self.loose)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum ClockArg {
|
||||
Wall,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{ClientConfig, NegotiationPolicy, SocketConfig};
|
||||
use irtt_client::{Authentication, ClientConfig, HmacKey, NegotiationPolicy, SocketConfig};
|
||||
|
||||
use super::args::CommonClientArgs;
|
||||
|
||||
@@ -21,7 +21,12 @@ impl CommonClientArgs {
|
||||
stamp_at: self.tstamp.into(),
|
||||
clock: self.clock.into(),
|
||||
dscp: self.dscp,
|
||||
hmac_key: self.hmac.as_ref().map(|key| key.as_bytes().to_vec()),
|
||||
auth: self
|
||||
.hmac
|
||||
.as_ref()
|
||||
.map_or(Authentication::Unauthenticated, |key| {
|
||||
Authentication::Hmac(HmacKey::new(key.as_bytes()))
|
||||
}),
|
||||
server_fill: self.server_fill.clone(),
|
||||
negotiation_policy: if self.loose {
|
||||
NegotiationPolicy::Loose
|
||||
|
||||
@@ -121,156 +121,3 @@ pub fn prepare_managed_run(
|
||||
stdin_controlled: selection.stdin_controlled,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct CommonOnlyArgs {
|
||||
#[command(flatten)]
|
||||
common: CommonClientArgs,
|
||||
}
|
||||
|
||||
fn common(args: &[&str]) -> CommonClientArgs {
|
||||
let mut argv = vec!["common-only"];
|
||||
argv.extend_from_slice(args);
|
||||
CommonOnlyArgs::try_parse_from(argv).unwrap().common
|
||||
}
|
||||
|
||||
fn unlabeled(addr: &str) -> TargetArg {
|
||||
TargetArg::new(addr)
|
||||
}
|
||||
|
||||
fn labeled(label: &str, addr: &str) -> TargetArg {
|
||||
TargetArg::new(format!("{label}={addr}"))
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
targets: &[TargetArg],
|
||||
pacing: GroupPacingArg,
|
||||
duration: Duration,
|
||||
) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(
|
||||
&common(&[]),
|
||||
duration,
|
||||
TargetSelection {
|
||||
targets,
|
||||
pacing,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_target_prepares_one_managed_target_and_a_finite_duration() {
|
||||
let setup = prepare(
|
||||
&[unlabeled("127.0.0.1:2112")],
|
||||
GroupPacingArg::Staggered,
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(setup.target_count(), 1);
|
||||
assert!(!setup.is_multi_target());
|
||||
assert_eq!(setup.targets[0].label, "127.0.0.1:2112");
|
||||
assert_eq!(setup.targets[0].managed.server_addr, "127.0.0.1:2112");
|
||||
assert_eq!(setup.client.duration, Some(Duration::from_secs(30)));
|
||||
assert_eq!(setup.pacing, ManagedPacing::Staggered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_duration_prepares_a_continuous_client_config() {
|
||||
let setup = prepare(
|
||||
&[unlabeled("127.0.0.1:2112")],
|
||||
GroupPacingArg::Staggered,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(setup.client.duration, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_keep_argument_order_and_share_one_client_config() {
|
||||
let setup = prepare(
|
||||
&[
|
||||
unlabeled("host-a:2112"),
|
||||
unlabeled("host-b:2112"),
|
||||
labeled("sg", "sg.example.test:2112"),
|
||||
],
|
||||
GroupPacingArg::Burst,
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(setup.is_multi_target());
|
||||
let labels: Vec<_> = setup
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.label.as_str())
|
||||
.collect();
|
||||
assert_eq!(labels, ["host-a:2112", "host-b:2112", "sg"]);
|
||||
assert_eq!(setup.targets[2].managed.server_addr, "sg.example.test:2112");
|
||||
assert_eq!(setup.pacing, ManagedPacing::Burst);
|
||||
|
||||
let config = setup.managed_config();
|
||||
assert_eq!(config.outcome_history_limit, 3);
|
||||
assert_eq!(config.max_live_target_generations, 3);
|
||||
assert_eq!(config.event_capacity, MANAGED_EVENT_CAPACITY);
|
||||
assert_eq!(config.pacing, ManagedPacing::Burst);
|
||||
assert_eq!(config.client, setup.client);
|
||||
assert_eq!(setup.managed_targets().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shared_client_config_carries_no_target_of_its_own() {
|
||||
// The managed driver replaces the address, and the CLI's key, per
|
||||
// target, so the template must not pin either to one target.
|
||||
let targets = vec![unlabeled("host-a:2112"), unlabeled("host-b:2112")];
|
||||
let setup = prepare_managed_run(
|
||||
&common(&["--hmac", "secret"]),
|
||||
Duration::from_secs(10),
|
||||
TargetSelection {
|
||||
targets: &targets,
|
||||
pacing: GroupPacingArg::Staggered,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
setup.client.server_addr,
|
||||
ClientConfig::default().server_addr
|
||||
);
|
||||
assert_eq!(setup.client.hmac_key, Some(b"secret".to_vec()));
|
||||
// Per-target authentication is not exposed on the command line, so no
|
||||
// target carries an override of the shared key.
|
||||
assert!(setup
|
||||
.targets
|
||||
.iter()
|
||||
.all(|target| target.managed.auth.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_target_set_is_rejected_with_the_list_columns_hint() {
|
||||
let err = prepare(&[], GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
|
||||
|
||||
assert!(err.contains("at least one target is required"));
|
||||
assert!(err.contains("--list-columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_label_is_rejected_before_any_configuration_is_built() {
|
||||
let targets = vec![
|
||||
unlabeled("host-a:2112"),
|
||||
labeled("host-a:2112", "host-b:2112"),
|
||||
];
|
||||
let err =
|
||||
prepare(&targets, GroupPacingArg::Staggered, Duration::from_secs(10)).unwrap_err();
|
||||
|
||||
assert!(err.contains("duplicate target label"));
|
||||
}
|
||||
}
|
||||
@@ -81,61 +81,3 @@ pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
|
||||
*stop_requested = true;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn peer_close_run_error_is_run_mode_and_interruption_aware() {
|
||||
assert_eq!(peer_close_run_error(false, false, 1), None);
|
||||
assert_eq!(peer_close_run_error(true, false, 0), None);
|
||||
assert_eq!(peer_close_run_error(true, true, 1), None);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 1).as_deref(),
|
||||
Some("continuous run ended because of peer closure (1 target session)")
|
||||
);
|
||||
assert_eq!(
|
||||
peer_close_run_error(true, false, 3).as_deref(),
|
||||
Some("continuous run ended because of peer closure (3 target sessions)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_peer_close_stop_policy_is_run_mode_and_one_shot() {
|
||||
let mut stop_requested = false;
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
true,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(!stop_requested);
|
||||
|
||||
assert!(request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
assert!(stop_requested);
|
||||
assert!(!request_managed_stop_for_peer_close(
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
&mut stop_requested,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ use std::{collections::HashSet, fmt};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetId},
|
||||
ClientAuthConfig,
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||
Authentication, HmacKey,
|
||||
};
|
||||
|
||||
/// One raw positional target captured by Clap.
|
||||
@@ -35,23 +35,6 @@ pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||
Ok(TargetArg::new(input))
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum TargetAuth {
|
||||
Inherit,
|
||||
Override(Vec<u8>),
|
||||
Disable,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetAuth {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Inherit => f.write_str("Inherit"),
|
||||
Self::Override(_) => f.write_str("Override(<redacted>)"),
|
||||
Self::Disable => f.write_str("Disable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TargetSpec {
|
||||
pub label: String,
|
||||
@@ -80,16 +63,7 @@ impl fmt::Debug for PreparedTarget {
|
||||
f.debug_struct("PreparedTarget")
|
||||
.field("label", &self.label)
|
||||
.field("server_addr", &self.managed.server_addr)
|
||||
.field(
|
||||
"auth",
|
||||
&match &self.managed.auth {
|
||||
None => TargetAuth::Inherit,
|
||||
Some(ClientAuthConfig { hmac_key: Some(_) }) => {
|
||||
TargetAuth::Override(Vec::new())
|
||||
}
|
||||
Some(ClientAuthConfig { hmac_key: None }) => TargetAuth::Disable,
|
||||
},
|
||||
)
|
||||
.field("auth", &self.managed.auth)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -216,9 +190,9 @@ fn parse_target_syntax(
|
||||
Some(index) => {
|
||||
let value = unescape(&input[index + "@hmac=".len()..])?;
|
||||
let auth = if value.is_empty() {
|
||||
TargetAuth::Disable
|
||||
TargetAuth::Override(Authentication::Unauthenticated)
|
||||
} else {
|
||||
TargetAuth::Override(value.into_bytes())
|
||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
|
||||
};
|
||||
(&input[..index], auth)
|
||||
}
|
||||
@@ -287,13 +261,7 @@ pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTar
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||
managed.auth = match spec.auth {
|
||||
TargetAuth::Inherit => None,
|
||||
TargetAuth::Override(hmac_key) => Some(ClientAuthConfig {
|
||||
hmac_key: Some(hmac_key),
|
||||
}),
|
||||
TargetAuth::Disable => Some(ClientAuthConfig { hmac_key: None }),
|
||||
};
|
||||
managed.auth = spec.auth;
|
||||
targets.push(PreparedTarget {
|
||||
label: spec.label,
|
||||
managed,
|
||||
@@ -322,170 +290,3 @@ pub fn parse_stdin_target_set(
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn target(input: &str) -> TargetArg {
|
||||
TargetArg::new(input)
|
||||
}
|
||||
|
||||
fn specs(inputs: &[&str]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs(&inputs.iter().map(|input| target(input)).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_target_syntax_and_ipv6_remain_supported() {
|
||||
let parsed = specs(&[
|
||||
"host.example",
|
||||
"host.example:2112",
|
||||
"[::1]:2112",
|
||||
"eu=host.example",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(parsed[0].label, "host.example");
|
||||
assert_eq!(parsed[1].label, "host.example:2112");
|
||||
assert_eq!(parsed[2].label, "[::1]:2112");
|
||||
assert_eq!(parsed[3].label, "eu");
|
||||
assert!(parsed
|
||||
.iter()
|
||||
.all(|target| matches!(target.auth, TargetAuth::Inherit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_hmac_supports_inherit_override_disable_and_base64_padding() {
|
||||
let parsed = specs(&[
|
||||
"default=default.example",
|
||||
"private=private.example@hmac=YWJjZA==",
|
||||
"public=public.example@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(parsed[0].auth, TargetAuth::Inherit));
|
||||
assert_eq!(parsed[1].auth, TargetAuth::Override(b"YWJjZA==".to_vec()));
|
||||
assert!(matches!(parsed[2].auth, TargetAuth::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_delimiters_are_escaped_contextually() {
|
||||
let parsed = specs(&["eu\\=west=host\\@hmac=literal@hmac=a\\@b\\,c\\=d\\\\e"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "eu=west");
|
||||
assert_eq!(parsed[0].addr, "host@hmac=literal");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"a@b,c=d\\e".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_commas_are_not_reserved() {
|
||||
let parsed = specs(&["edge=host,region@hmac=key,part"]).unwrap();
|
||||
assert_eq!(parsed[0].addr, "host,region");
|
||||
assert_eq!(parsed[0].auth, TargetAuth::Override(b"key,part".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_diagnostics_and_debug_do_not_reveal_hmac_values() {
|
||||
let secret = "very-secret-value";
|
||||
let error = specs(&[&format!("target=host@hmac={secret}\\q")]).unwrap_err();
|
||||
assert!(!error.contains(secret));
|
||||
assert!(!format!("{:?}", target(&format!("target=host@hmac={secret}"))).contains(secret));
|
||||
assert!(
|
||||
!format!("{:?}", TargetAuth::Override(secret.as_bytes().to_vec())).contains(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_suffix_repeated_unlabeled_and_reject_duplicate_labels() {
|
||||
let parsed = specs(&["host-a:2112", "host-a:2112"]).unwrap();
|
||||
assert_eq!(parsed[0].label, "host-a:2112");
|
||||
assert_eq!(parsed[1].label, "host-a:2112#2");
|
||||
assert_eq!(
|
||||
specs(&["host-a:2112", "host-a:2112=host-b:2112"]).unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_preserve_argument_order_and_unlabeled_counts() {
|
||||
let parsed = specs(&["local", "eu=eu.example", "foo", "us=us.example", "foo"]).unwrap();
|
||||
let labels: Vec<_> = parsed.iter().map(|target| target.label.as_str()).collect();
|
||||
assert_eq!(labels, ["local", "eu", "foo", "us", "foo#2"]);
|
||||
let addresses: Vec<_> = parsed.iter().map(|target| target.addr.as_str()).collect();
|
||||
assert_eq!(
|
||||
addresses,
|
||||
["local", "eu.example", "foo", "us.example", "foo"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_specs_reject_generated_suffix_collisions() {
|
||||
assert_eq!(
|
||||
specs(&[
|
||||
"host.example",
|
||||
"host.example",
|
||||
"host.example#2=other.example"
|
||||
])
|
||||
.unwrap_err(),
|
||||
"duplicate target label"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_target_syntax_is_rejected_without_echoing_input() {
|
||||
for input in ["=host", "label=", "host@hmac=value\\q", "host\\x"] {
|
||||
assert!(specs(&[input]).is_err(), "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_maps_auth_to_managed_config() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=one@hmac=key", "b=two@hmac="]).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
prepared[0]
|
||||
.managed
|
||||
.auth
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hmac_key
|
||||
.as_deref(),
|
||||
Some(b"key".as_slice())
|
||||
);
|
||||
assert_eq!(prepared[1].managed.auth.as_ref().unwrap().hmac_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_preparation_preserves_duplicate_endpoints() {
|
||||
let prepared =
|
||||
prepare_managed_targets(specs(&["a=example.test", "b=example.test"]).unwrap()).unwrap();
|
||||
assert_eq!(prepared[0].managed.server_addr, "example.test");
|
||||
assert_eq!(prepared[1].managed.server_addr, "example.test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_target_sets_frame_commas_without_trimming_payload() {
|
||||
let parsed = parse_stdin_target_set("a=one@hmac=x\\,y,b=two", 128).unwrap();
|
||||
assert_eq!(
|
||||
parsed[0].managed.auth.as_ref().unwrap().hmac_key.as_deref(),
|
||||
Some(b"x,y".as_slice())
|
||||
);
|
||||
assert_eq!(parsed[1].label, "b");
|
||||
assert_eq!(
|
||||
parse_stdin_target_set(" []", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
" []"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_stdin_target_set("[] ", 128).unwrap()[0]
|
||||
.managed
|
||||
.server_addr,
|
||||
"[] "
|
||||
);
|
||||
assert!(parse_stdin_target_set("", 128).is_err());
|
||||
assert!(parse_stdin_target_set("a=one,b=two", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdin_empty_set_is_exactly_bracket_pair() {
|
||||
assert!(parse_stdin_target_set("[]", 128).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
#[cfg(feature = "client")]
|
||||
#[path = "support/in_tree_server.rs"]
|
||||
mod in_tree_server;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
use irtt_server::ServerConfig;
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
struct ClientProcess(std::process::Child);
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl Drop for ClientProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl ClientProcess {
|
||||
fn wait(&mut self) -> std::process::ExitStatus {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some(status) = self.0.try_wait().unwrap() {
|
||||
return status;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"client did not terminate within five seconds"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch/help/availability belong to the application and run in every build.
|
||||
#[test]
|
||||
fn dispatcher_reports_and_runs_the_enabled_applets() {
|
||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
||||
let help = Command::new(dispatcher).arg("--help").output().unwrap();
|
||||
assert!(help.status.success());
|
||||
let help = String::from_utf8(help.stdout).unwrap();
|
||||
for (applet, enabled) in [
|
||||
("client", cfg!(feature = "client")),
|
||||
("server", cfg!(feature = "server")),
|
||||
("tui", cfg!(feature = "tui")),
|
||||
] {
|
||||
let row = help
|
||||
.lines()
|
||||
.find(|line| line.trim_start().starts_with(applet))
|
||||
.unwrap();
|
||||
assert_eq!(row.contains("not available"), !enabled);
|
||||
let output = Command::new(dispatcher)
|
||||
.args([applet, "--help"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.success(), enabled, "{applet}");
|
||||
if !enabled {
|
||||
assert!(String::from_utf8(output.stderr)
|
||||
.unwrap()
|
||||
.contains("not available"));
|
||||
}
|
||||
}
|
||||
let binaries = [
|
||||
dispatcher,
|
||||
#[cfg(feature = "client")]
|
||||
env!("CARGO_BIN_EXE_irtt-client"),
|
||||
#[cfg(feature = "server")]
|
||||
env!("CARGO_BIN_EXE_irtt-server"),
|
||||
#[cfg(feature = "tui")]
|
||||
env!("CARGO_BIN_EXE_irtt-tui"),
|
||||
];
|
||||
for binary in binaries {
|
||||
assert!(Command::new(binary)
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
assert!(!Command::new(binary)
|
||||
.arg("--invalid-option")
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
}
|
||||
}
|
||||
|
||||
// The default presentation must reach a meaningful packet summary for a finite run.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn default_client_output_emits_a_packet_summary() {
|
||||
use std::{io::Read, process::Stdio, thread};
|
||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
||||
let mut client = ClientProcess(
|
||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
||||
.env_clear()
|
||||
.args([
|
||||
"--duration",
|
||||
"100ms",
|
||||
"--interval",
|
||||
"20ms",
|
||||
&server.addr.to_string(),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut stdout = client.0.stdout.take().unwrap();
|
||||
let stdout = thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
stdout.read_to_string(&mut text).unwrap();
|
||||
text
|
||||
});
|
||||
let mut stderr = client.0.stderr.take().unwrap();
|
||||
let stderr = thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
stderr.read_to_string(&mut text).unwrap();
|
||||
text
|
||||
});
|
||||
let status = client.wait();
|
||||
let stdout = stdout.join().unwrap();
|
||||
let stderr = stderr.join().unwrap();
|
||||
assert!(status.success(), "{stderr}");
|
||||
assert!(stdout.contains("irtt-rs summary"), "{stdout}");
|
||||
let packets = stdout
|
||||
.lines()
|
||||
.find(|line| line.starts_with("packets:"))
|
||||
.expect("default output must include the packet summary");
|
||||
for field in ["sent=", "received="] {
|
||||
let count = packets
|
||||
.split_whitespace()
|
||||
.find_map(|word| word.strip_prefix(field))
|
||||
.unwrap()
|
||||
.parse::<u64>()
|
||||
.unwrap();
|
||||
assert!(
|
||||
count > 0,
|
||||
"summary must report {field} a positive count: {packets}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn target_authentication_inherits_overrides_or_disables_the_global_key() {
|
||||
use clap::Parser;
|
||||
use irtt_app::cmd::client::args::ClientArgs;
|
||||
use irtt_client::{managed::TargetAuth, Authentication, HmacKey};
|
||||
let args = ClientArgs::try_parse_from([
|
||||
"irtt-client",
|
||||
"--hmac",
|
||||
"CONSPICUOUS_GLOBAL_HMAC_KEY",
|
||||
"inherit=127.0.0.1:2112",
|
||||
"override=127.0.0.1:2113@hmac=CONSPICUOUS_TARGET_HMAC_KEY",
|
||||
"disable=127.0.0.1:2114@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert_eq!(
|
||||
setup.managed_config().client.auth,
|
||||
Authentication::Hmac(HmacKey::new(b"CONSPICUOUS_GLOBAL_HMAC_KEY".as_slice()))
|
||||
);
|
||||
let targets = setup.managed_targets();
|
||||
let authentication: Vec<_> = targets.iter().map(|target| target.auth.clone()).collect();
|
||||
assert_eq!(
|
||||
authentication,
|
||||
[
|
||||
TargetAuth::Inherit,
|
||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(
|
||||
b"CONSPICUOUS_TARGET_HMAC_KEY".as_slice()
|
||||
))),
|
||||
TargetAuth::Override(Authentication::Unauthenticated)
|
||||
]
|
||||
);
|
||||
for output in [format!("{args:?}"), format!("{setup:?}")] {
|
||||
assert!(!output.contains("CONSPICUOUS_GLOBAL_HMAC_KEY"));
|
||||
assert!(!output.contains("CONSPICUOUS_TARGET_HMAC_KEY"));
|
||||
}
|
||||
}
|
||||
|
||||
// A real authenticated server detects broken client argument-to-config mapping.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn client_arguments_export_a_real_client_run() {
|
||||
use std::{io::Read, process::Stdio, thread};
|
||||
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
|
||||
for option in ["--dscp", "--ttl"] {
|
||||
assert!(!Command::new(dispatcher)
|
||||
.args(["client", option, "256", "127.0.0.1:9"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.status
|
||||
.success());
|
||||
}
|
||||
let server =
|
||||
in_tree_server::InTreeServer::start(ServerConfig::default().with_hmac_key(b"cli-test"));
|
||||
let mut client = ClientProcess(
|
||||
Command::new(dispatcher)
|
||||
.args([
|
||||
"client",
|
||||
"--duration",
|
||||
"100ms",
|
||||
"--interval",
|
||||
"20ms",
|
||||
"--hmac",
|
||||
"cli-test",
|
||||
"--length",
|
||||
"128",
|
||||
"--sfill",
|
||||
"pattern:ab",
|
||||
"--clock",
|
||||
"wall",
|
||||
"--tstamp",
|
||||
"both",
|
||||
"--stats",
|
||||
"both",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--format",
|
||||
"jsonl",
|
||||
"--columns",
|
||||
"target,event,bytes",
|
||||
&format!("edge={}", server.addr),
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
// Drain both pipes while waiting so output cannot block client termination.
|
||||
let mut stdout = client.0.stdout.take().unwrap();
|
||||
let stdout = thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
stdout.read_to_end(&mut bytes).unwrap();
|
||||
bytes
|
||||
});
|
||||
let mut stderr = client.0.stderr.take().unwrap();
|
||||
let stderr = thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
stderr.read_to_end(&mut bytes).unwrap();
|
||||
bytes
|
||||
});
|
||||
let status = client.wait();
|
||||
let stdout = stdout.join().unwrap();
|
||||
let stderr = stderr.join().unwrap();
|
||||
assert!(status.success(), "{}", String::from_utf8_lossy(&stderr));
|
||||
let stdout = String::from_utf8(stdout).unwrap();
|
||||
let rows: Vec<_> = stdout.lines().collect();
|
||||
assert!(!rows.is_empty());
|
||||
assert!(rows.iter().all(|row| row.starts_with('{')
|
||||
&& row.ends_with('}')
|
||||
&& row.contains("\"target\":\"edge\"")));
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"event\":\"echo_reply\"") && row.contains("\"bytes\":128")));
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"event\":\"session_closed\"")));
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
#[test]
|
||||
fn malformed_target_diagnostics_do_not_expose_authentication_keys() {
|
||||
let secret = "malformed-target-secret-92817";
|
||||
for applet in [
|
||||
#[cfg(feature = "client")]
|
||||
"client",
|
||||
#[cfg(feature = "tui")]
|
||||
"tui",
|
||||
] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_irtt-rs"))
|
||||
.args([applet, &format!(r"edge=127.0.0.1:9@hmac={secret}\q")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
!stderr.trim().is_empty(),
|
||||
"malformed target must report a diagnostic"
|
||||
);
|
||||
assert!(!stderr.contains(secret), "target authentication key leaked");
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains(secret));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn server_arguments_resolve_binds_and_map_policy() {
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
use irtt_app::cmd::server::ServerArgs;
|
||||
use irtt_server::TimestampAllowance;
|
||||
use std::time::Duration;
|
||||
// Isolate zero-argument defaults from the test runner's environment without
|
||||
// mutating process-global environment in this parallel test process.
|
||||
let defaults = ServerArgs::from_arg_matches(
|
||||
&ServerArgs::command()
|
||||
.mut_args(|arg| arg.env(None::<&str>))
|
||||
.try_get_matches_from(["irtt-server"])
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
defaults.resolve_binds(),
|
||||
vec![
|
||||
"[::]:2112".parse().unwrap(),
|
||||
"0.0.0.0:2112".parse().unwrap()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
defaults.server_config(),
|
||||
irtt_server::ServerConfig::default()
|
||||
);
|
||||
let args = ServerArgs::try_parse_from([
|
||||
"irtt-server",
|
||||
"--bind",
|
||||
"127.0.0.1:2113",
|
||||
"--bind",
|
||||
"[::1]:2114",
|
||||
"--hmac",
|
||||
"server-cli-test",
|
||||
"--max-sessions",
|
||||
"3",
|
||||
"--max-packet-length",
|
||||
"96",
|
||||
"--min-interval",
|
||||
"25ms",
|
||||
"--burst",
|
||||
"2",
|
||||
"--idle-timeout",
|
||||
"8s",
|
||||
"--max-duration",
|
||||
"1m",
|
||||
"--timestamp-allowance",
|
||||
"single",
|
||||
"--no-dscp",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
args.resolve_binds(),
|
||||
vec![
|
||||
"127.0.0.1:2113".parse().unwrap(),
|
||||
"[::1]:2114".parse().unwrap()
|
||||
]
|
||||
);
|
||||
let config = args.server_config();
|
||||
assert_eq!(config.hmac_key(), Some(b"server-cli-test".as_slice()));
|
||||
assert_eq!(config.max_sessions(), 3);
|
||||
assert_eq!(config.max_packet_length(), 96);
|
||||
assert_eq!(config.min_send_interval(), Duration::from_millis(25));
|
||||
assert_eq!(config.burst_allowance(), 2);
|
||||
assert_eq!(config.idle_timeout(), Duration::from_secs(8));
|
||||
assert_eq!(config.max_test_duration(), Some(Duration::from_secs(60)));
|
||||
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Single);
|
||||
assert!(!config.dscp_allowed());
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
#[test]
|
||||
fn server_command_line_binds_replace_the_environment_bind() {
|
||||
use std::net::UdpSocket;
|
||||
// Occupied ports give bounded, observable startup failures, without starting
|
||||
// a daemon or needing a process/signal harness merely to inspect arguments.
|
||||
let environment = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let explicit = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let environment_addr = environment.local_addr().unwrap().to_string();
|
||||
let explicit_addr = explicit.local_addr().unwrap().to_string();
|
||||
for use_explicit in [false, true] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_irtt-server"));
|
||||
command
|
||||
.env_clear()
|
||||
.env("IRTT_SERVER_BIND", &environment_addr)
|
||||
.env("IRTT_SERVER_MAX_SESSIONS", "invalid")
|
||||
.args(["--max-sessions", "3"]);
|
||||
if use_explicit {
|
||||
command.args(["--bind", "127.0.0.1:0", "--bind", &explicit_addr]);
|
||||
}
|
||||
let output = command.output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
let expected = if use_explicit {
|
||||
&explicit_addr
|
||||
} else {
|
||||
&environment_addr
|
||||
};
|
||||
assert!(
|
||||
stderr.contains(expected),
|
||||
"startup must attempt the selected bind: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("listening on"),
|
||||
"partial startup must not announce listeners"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise stdin replacement and EOF through the actual application process.
|
||||
#[cfg(feature = "client")]
|
||||
#[test]
|
||||
fn targets_stdin_supersedes_the_active_set_and_stops_on_eof() {
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
process::Stdio,
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
let server = in_tree_server::InTreeServer::start(ServerConfig::default());
|
||||
let mut client = ClientProcess(
|
||||
Command::new(env!("CARGO_BIN_EXE_irtt-client"))
|
||||
.env_clear()
|
||||
.args([
|
||||
"--duration",
|
||||
"0",
|
||||
"--targets-stdin",
|
||||
"--interval",
|
||||
"20ms",
|
||||
"--format",
|
||||
"jsonl",
|
||||
"--columns",
|
||||
"target,event",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let stdout = client.0.stdout.take().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let reader = thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
if tx.send(line.unwrap()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut stdin = client.0.stdin.take().unwrap();
|
||||
let mut rows = Vec::new();
|
||||
for target in ["first", "second"] {
|
||||
writeln!(stdin, "{target}={}", server.addr).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let row = rx
|
||||
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||
.expect("stdin target set did not produce a reply");
|
||||
let replied = row.contains(&format!("\"target\":\"{target}\""))
|
||||
&& row.contains("\"event\":\"echo_reply\"");
|
||||
rows.push(row);
|
||||
if replied {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retirement can finish after the newer target's first reply.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !rows.iter().any(|row| {
|
||||
row.contains("\"target\":\"first\"") && row.contains("\"event\":\"session_closed\"")
|
||||
}) {
|
||||
rows.push(
|
||||
rx.recv_timeout(deadline.saturating_duration_since(Instant::now()))
|
||||
.expect("superseded stdin target did not retire"),
|
||||
);
|
||||
}
|
||||
drop(stdin);
|
||||
let status = client.wait();
|
||||
reader.join().unwrap();
|
||||
rows.extend(rx.try_iter());
|
||||
let mut stderr = String::new();
|
||||
client
|
||||
.0
|
||||
.stderr
|
||||
.take()
|
||||
.unwrap()
|
||||
.read_to_string(&mut stderr)
|
||||
.unwrap();
|
||||
assert!(status.success(), "{stderr}");
|
||||
assert!(rows
|
||||
.iter()
|
||||
.any(|row| row.contains("\"target\":\"second\"")
|
||||
&& row.contains("\"event\":\"session_closed\"")));
|
||||
}
|
||||
-8
@@ -1,11 +1,3 @@
|
||||
//! A real `irtt-server` running in-process, for tests that need to prove
|
||||
//! `AsyncClient` and the blocking `Client` are equivalent against a real peer.
|
||||
//!
|
||||
//! This duplicates `crates/irtt-integration-tests/tests/support/in_tree_server.rs`
|
||||
//! rather than depending on it: this module backs private (`super::*`) unit
|
||||
//! tests here, while the integration crate covers black-box client/server
|
||||
//! behavior, and neither can depend on the other's test-only code.
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
sync::mpsc,
|
||||
@@ -93,6 +93,29 @@ risks clearing readiness state that the normal Echo receive path depends on.
|
||||
There is no background task or second `AsyncFd` around the Tokio-owned
|
||||
socket.
|
||||
|
||||
### Testing kernel timestamp behavior
|
||||
|
||||
The requirements above describe production behavior, not a required unit-test
|
||||
architecture.
|
||||
|
||||
Prefer Linux tests that exercise the real socket and kernel timestamp path
|
||||
through production APIs. A test intended to prove TX timestamp support must
|
||||
actually require and observe a kernel TX timestamp when running on a supported
|
||||
Linux environment; accepting the userspace fallback does not prove that path.
|
||||
|
||||
Likewise, prefer observable socket/client behavior for genuine error-queue
|
||||
errors and bounded draining where it can reasonably be induced.
|
||||
|
||||
Do not restore synthetic `ControlMessageOwned` permutations, classifier-unit
|
||||
suites, raw ancillary fixtures, test-only socket hooks, or other internal seams
|
||||
solely to cover every `MSG_ERRQUEUE` branch. `nix` owns ancillary-message
|
||||
decoding and ABI safety; `irtt-client` tests should primarily prove its own
|
||||
observable interpretation and behavior through the real path.
|
||||
|
||||
If some invariant cannot reasonably be induced through a production interface,
|
||||
a small focused internal test may be retained, but the burden is on that test to
|
||||
justify why integration coverage is insufficient.
|
||||
|
||||
## Retained state
|
||||
|
||||
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
|
||||
@@ -127,7 +150,8 @@ remains anchored to before the send, on purpose, so the existing pre-send
|
||||
timeout/overflow/no-send contract is unchanged. Only the *measurement*
|
||||
`sent_at` moved to after the send. A `WouldBlock` `try_send` (Tokio) never
|
||||
samples a `sent_at`, never calls `finalize_probe_commit`'s matching
|
||||
`commit_probe_sent`, and never advances the machine or schedule; each retry
|
||||
`commit_probe_sent`, and never advances the machine; the managed runner advances cadence only
|
||||
after an accepted send; each retry
|
||||
gets its own fresh pre-send anchor, and only a successful attempt's anchor
|
||||
and `sent_at` are ever retained.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "irtt-client"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
description = "IRTT-compatible Rust client library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -14,7 +14,7 @@ categories = ["network-programming"]
|
||||
[dependencies]
|
||||
irtt-proto.workspace = true
|
||||
socket2 = { version = "0.6.5", features = ["all"] }
|
||||
thiserror = "2.0.20"
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
@@ -25,6 +25,9 @@ default = []
|
||||
ancillary = ["dep:nix"]
|
||||
tokio = ["dep:tokio"]
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||
@@ -42,10 +45,3 @@ doc-scrape-examples = true
|
||||
name = "managed_client"
|
||||
required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Keep the packaged client testable against the latest published server while
|
||||
# workspace tests continue to exercise the local source through this path.
|
||||
irtt-server = { version = "0.5.1", path = "../irtt-server" }
|
||||
proptest = "1.11.0"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
@@ -22,11 +22,37 @@ binaries are built on.
|
||||
runtime, and `BlockingManagedClient` for synchronous callers, which owns its
|
||||
own dedicated current-thread runtime.
|
||||
|
||||
`Client` and `AsyncClient` send one probe whenever called. They expose the
|
||||
negotiated parameters, pending-probe status, and timeout deadlines; callers
|
||||
own cadence and run duration. The managed driver provides fixed cadence,
|
||||
missed-slot handling, finite runs, and staggered or burst pacing. Low-level
|
||||
`EchoSent` events leave `scheduled_at` and `timer_error` absent; the managed
|
||||
driver supplies those schedule measurements.
|
||||
|
||||
Tokio stays optional: the default build has no runtime and no Tokio
|
||||
dependency at all.
|
||||
|
||||
See `examples/` in the repository for runnable examples of each tier.
|
||||
|
||||
## Authentication
|
||||
|
||||
`ClientConfig.auth` is concrete: `Authentication::Unauthenticated` or
|
||||
`Authentication::Hmac(HmacKey::new(bytes))`. Keys share their bytes across
|
||||
clones and print only `HmacKey([REDACTED])` through `Debug`. Borrowing key
|
||||
material requires an explicit `as_bytes()` call. An empty key still selects
|
||||
HMAC authentication; textual key syntax belongs to the application.
|
||||
|
||||
For managed targets, `ManagedTargetConfig.auth` defaults to `TargetAuth::Inherit`.
|
||||
`TargetAuth::Override(Authentication::Unauthenticated)` disables shared
|
||||
authentication, and `TargetAuth::Override(Authentication::Hmac(key))` replaces
|
||||
the shared key. Initial construction and update planning resolve inheritance
|
||||
into concrete session authentication before execution. Switching between
|
||||
inheritance and an override replaces the target generation even when their
|
||||
resolved authentication is equal.
|
||||
|
||||
This replaces the former `ClientConfig.hmac_key` and `ClientAuthConfig` API;
|
||||
there are no compatibility aliases.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent};
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome};
|
||||
|
||||
const RUN_DURATION: Duration = Duration::from_secs(2);
|
||||
|
||||
@@ -45,32 +45,42 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
match client.open() {
|
||||
Ok(outcome) => println!("session opened: {outcome:?}"),
|
||||
let negotiated = match client.open() {
|
||||
Ok(OpenOutcome::Started {
|
||||
event, negotiated, ..
|
||||
}) => {
|
||||
println!("session opened: {event:?}");
|
||||
negotiated
|
||||
}
|
||||
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
|
||||
println!("{event:?}");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The run itself lasts `duration`, but a lost probe isn't classified as
|
||||
// `EchoLoss` until `probe_timeout()` after it was sent; wait at least
|
||||
// that much past the run's own end so a loss near the end can still be
|
||||
// observed and printed before this example gives up and closes.
|
||||
let deadline = Instant::now() + RUN_DURATION + client.probe_timeout();
|
||||
while Instant::now() < deadline && !client.is_run_complete() {
|
||||
// send_probe is caller-paced: only call it once its own schedule
|
||||
// says a probe is due, rather than as fast as the loop spins.
|
||||
if client
|
||||
.next_send_deadline()
|
||||
.is_some_and(|send_at| Instant::now() >= send_at)
|
||||
{
|
||||
// A failed send retains its prepared probe without advancing the
|
||||
// schedule, so a persistent error (e.g. the interface going
|
||||
// down) would otherwise make this loop retry silently until the
|
||||
// safety deadline instead of reporting it.
|
||||
// This loop owns cadence and duration. The transport sends whenever called.
|
||||
let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
|
||||
let deadline = end + client.probe_timeout();
|
||||
let mut next_send = start;
|
||||
while Instant::now() < deadline && !client.is_peer_closed() {
|
||||
let now = Instant::now();
|
||||
if now >= end && !client.has_pending_probes() {
|
||||
break;
|
||||
}
|
||||
if now < end && now >= next_send {
|
||||
match client.send_probe() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Ok(events) => {
|
||||
events.iter().for_each(print_event);
|
||||
while next_send <= Instant::now() {
|
||||
next_send += interval;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
@@ -93,6 +103,9 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
if client.is_peer_closed() {
|
||||
return;
|
||||
}
|
||||
match client.close() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{AsyncClient, ClientConfig, ClientEvent};
|
||||
use irtt_client::{AsyncClient, ClientConfig, ClientEvent, OpenOutcome};
|
||||
|
||||
/// Upper bound on how long the receive wait can go without also calling
|
||||
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
|
||||
@@ -58,24 +58,43 @@ async fn run() {
|
||||
}
|
||||
};
|
||||
|
||||
match client.open().await {
|
||||
Ok(outcome) => println!("session opened: {outcome:?}"),
|
||||
let negotiated = match client.open().await {
|
||||
Ok(OpenOutcome::Started {
|
||||
event, negotiated, ..
|
||||
}) => {
|
||||
println!("session opened: {event:?}");
|
||||
negotiated
|
||||
}
|
||||
Ok(OpenOutcome::NoTestCompleted { event, .. }) => {
|
||||
println!("{event:?}");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while !client.is_run_complete() {
|
||||
// This loop owns cadence and duration. The transport sends whenever called.
|
||||
let interval = Duration::from_nanos(negotiated.params.interval_ns as u64);
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_nanos(negotiated.params.duration_ns as u64);
|
||||
let deadline = end + client.probe_timeout();
|
||||
let mut next_send = start;
|
||||
while Instant::now() < deadline && !client.is_peer_closed() {
|
||||
if Instant::now() >= end && !client.has_pending_probes() {
|
||||
break;
|
||||
}
|
||||
// Bound the receive wait by the next send/timeout deadline, rather
|
||||
// than awaiting recv() unconditionally: a lost or rate-limited reply
|
||||
// must not stall pacing or timeout classification indefinitely.
|
||||
let wake_at = client
|
||||
.next_send_deadline()
|
||||
let wake_at = (next_send < end)
|
||||
.then_some(next_send)
|
||||
.into_iter()
|
||||
.chain(client.next_probe_timeout_deadline())
|
||||
.chain(std::iter::once(Instant::now() + TIMEOUT_POLL_INTERVAL))
|
||||
.min()
|
||||
.expect("the fixed poll-interval deadline is always present");
|
||||
.unwrap();
|
||||
|
||||
let recv_result = tokio::select! {
|
||||
events = client.recv() => Some(events),
|
||||
@@ -91,16 +110,14 @@ async fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
if client
|
||||
.next_send_deadline()
|
||||
.is_some_and(|send_at| Instant::now() >= send_at)
|
||||
{
|
||||
// A failed send retains its prepared probe without advancing the
|
||||
// schedule, so a persistent error (e.g. the interface going
|
||||
// down) would otherwise make this loop retry forever instead of
|
||||
// reporting it.
|
||||
if Instant::now() < end && Instant::now() >= next_send {
|
||||
match client.send_probe().await {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Ok(events) => {
|
||||
events.iter().for_each(print_event);
|
||||
while next_send <= Instant::now() {
|
||||
next_send += interval;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
@@ -116,6 +133,9 @@ async fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
if client.is_peer_closed() {
|
||||
return;
|
||||
}
|
||||
match client.close().await {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
|
||||
@@ -7,20 +7,10 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
collections::VecDeque,
|
||||
};
|
||||
|
||||
use tokio::time;
|
||||
|
||||
use crate::{
|
||||
client::{
|
||||
echo_sent_event,
|
||||
schedule::{instant_abs_diff, ProbeSchedule},
|
||||
validate_datagram_length,
|
||||
},
|
||||
client::{echo_sent_event, validate_datagram_length},
|
||||
config::ClientConfig,
|
||||
error::ClientError,
|
||||
event::{ClientEvent, OpenOutcome},
|
||||
@@ -54,13 +44,9 @@ const OPEN_POLL_WORK_BUDGET: usize = 64;
|
||||
/// this crate already bounds open-attempt work keeps that impossible.
|
||||
const RECV_POLL_WORK_BUDGET: usize = 64;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::client::ProbeSendTimestamps;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedAsyncOpen {
|
||||
machine: PreparedOpenAcceptance,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
recv_buffer_len: Option<usize>,
|
||||
negotiated_traffic_class: Option<u8>,
|
||||
}
|
||||
@@ -99,7 +85,7 @@ impl AsyncOpenCleanup {
|
||||
/// Owned state for one asynchronous open transaction.
|
||||
///
|
||||
/// This state deliberately contains no reference to [`AsyncClient`], allowing
|
||||
/// a future managed target to store the client and opening state side by side.
|
||||
/// a managed target to store the client and opening state side by side.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AsyncOpenState {
|
||||
attempt: usize,
|
||||
@@ -156,11 +142,6 @@ impl AsyncOpenState {
|
||||
self.stopped_after_current_attempt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_retained_cleanup(&self) -> bool {
|
||||
self.cleanup.is_some()
|
||||
}
|
||||
|
||||
fn deadline(&self) -> Instant {
|
||||
self.deadline
|
||||
.expect("an open attempt has an active deadline")
|
||||
@@ -173,70 +154,6 @@ impl AsyncOpenState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug)]
|
||||
enum InjectedSend {
|
||||
WouldBlock,
|
||||
Error,
|
||||
ReportedLength(usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
struct AsyncClientTestHooks {
|
||||
sends: RefCell<VecDeque<InjectedSend>>,
|
||||
send_attempts: Cell<usize>,
|
||||
receive_would_block: Cell<usize>,
|
||||
/// Number of subsequent receive attempts (`poll_open` or `poll_recv`)
|
||||
/// that should report `io::ErrorKind::Interrupted` instead of performing
|
||||
/// a real nonblocking receive, deterministically exercising interrupted-
|
||||
/// syscall retry behavior.
|
||||
inject_recv_interrupted: Cell<usize>,
|
||||
fail_open_dscp: Cell<bool>,
|
||||
fail_cleanup_send: Cell<bool>,
|
||||
fail_peer_close_dscp: Cell<bool>,
|
||||
fail_dscp_restore: Cell<bool>,
|
||||
pause_open_cleanup_before_send: Cell<bool>,
|
||||
pause_open_before_writable: Cell<bool>,
|
||||
pause_open_before_readable: Cell<bool>,
|
||||
pause_probe_before_writable: Cell<bool>,
|
||||
pause_close_before_writable: Cell<bool>,
|
||||
pause_close_after_would_block: Cell<bool>,
|
||||
probe_timestamps: RefCell<VecDeque<ProbeSendTimestamps>>,
|
||||
close_sent_at: Cell<Option<ClientTimestamp>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AsyncClientTestHooks {
|
||||
fn try_send(&self, socket: &tokio::net::UdpSocket, packet: &[u8]) -> io::Result<usize> {
|
||||
self.send_attempts.set(self.send_attempts.get() + 1);
|
||||
if let Some(injected) = self.sends.borrow_mut().pop_front() {
|
||||
return match injected {
|
||||
InjectedSend::WouldBlock => Err(io::Error::from(io::ErrorKind::WouldBlock)),
|
||||
InjectedSend::Error => Err(io::Error::other("injected send failure")),
|
||||
InjectedSend::ReportedLength(bytes) => Ok(bytes),
|
||||
};
|
||||
}
|
||||
socket.try_send(packet)
|
||||
}
|
||||
|
||||
fn take_probe_timestamps(&self) -> Option<ProbeSendTimestamps> {
|
||||
self.probe_timestamps.borrow_mut().pop_front()
|
||||
}
|
||||
|
||||
/// Consumes one pending injected interruption, if any, reporting whether
|
||||
/// the caller should report `io::ErrorKind::Interrupted` instead of
|
||||
/// attempting a real receive.
|
||||
fn take_injected_interrupted(&self) -> bool {
|
||||
let remaining = self.inject_recv_interrupted.get();
|
||||
if remaining == 0 {
|
||||
return false;
|
||||
}
|
||||
self.inject_recv_interrupted.set(remaining - 1);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-level Tokio IRTT client for one connected UDP target.
|
||||
///
|
||||
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
|
||||
@@ -271,7 +188,6 @@ impl AsyncClientTestHooks {
|
||||
pub struct AsyncClient {
|
||||
socket: tokio::net::UdpSocket,
|
||||
machine: SessionMachine,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
remote: SocketAddr,
|
||||
recv_buffer: Vec<u8>,
|
||||
applied_traffic_class: Option<u8>,
|
||||
@@ -280,8 +196,6 @@ pub struct AsyncClient {
|
||||
/// See `Client::tx_timestamping_enabled` in `client.rs` for the
|
||||
/// invariant this tracks; it is the same adapter-owned capability flag.
|
||||
tx_timestamping_enabled: bool,
|
||||
#[cfg(test)]
|
||||
test_hooks: AsyncClientTestHooks,
|
||||
}
|
||||
|
||||
impl AsyncClient {
|
||||
@@ -302,15 +216,12 @@ impl AsyncClient {
|
||||
Ok(Self {
|
||||
socket,
|
||||
machine,
|
||||
schedule: None,
|
||||
remote,
|
||||
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
||||
applied_traffic_class: None,
|
||||
prepared_open: Some(prepared_open),
|
||||
prepared_probe: None,
|
||||
tx_timestamping_enabled: false,
|
||||
#[cfg(test)]
|
||||
test_hooks: AsyncClientTestHooks::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -322,16 +233,16 @@ impl AsyncClient {
|
||||
/// ignored without resending the request.
|
||||
///
|
||||
/// Dropping this future before trusted acceptance leaves the local machine
|
||||
/// connected with no committed schedule or negotiated adapter state. If it
|
||||
/// is dropped during best-effort post-token cleanup, cleanup may remain
|
||||
/// incomplete because this low-level client never detaches cleanup work.
|
||||
/// connected with no negotiated adapter state. If it is dropped during
|
||||
/// best-effort post-token cleanup, cleanup may remain incomplete because
|
||||
/// this low-level client never detaches cleanup work.
|
||||
pub async fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||
let mut state = AsyncOpenState::new();
|
||||
poll_fn(|cx| self.poll_open(&mut state, cx)).await
|
||||
}
|
||||
|
||||
/// Send one caller-paced probe with transactional protocol and schedule
|
||||
/// commits.
|
||||
/// Send one echo probe now, committing protocol state only after acceptance.
|
||||
/// The caller owns pacing and run duration.
|
||||
///
|
||||
/// A prepared packet is retained across readiness false positives,
|
||||
/// cancellation, and socket errors so a later call retries the same logical
|
||||
@@ -412,10 +323,6 @@ impl AsyncClient {
|
||||
}
|
||||
|
||||
if !state.request_submitted {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_open_before_writable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
@@ -430,9 +337,6 @@ impl AsyncClient {
|
||||
.prepared_open
|
||||
.as_ref()
|
||||
.expect("connected clients retain their prepared open request");
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, &request.bytes);
|
||||
#[cfg(not(test))]
|
||||
let send_result = self.socket.try_send(&request.bytes);
|
||||
match send_result {
|
||||
Ok(bytes) => {
|
||||
@@ -446,10 +350,6 @@ impl AsyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_open_before_readable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
@@ -463,10 +363,6 @@ impl AsyncClient {
|
||||
Poll::Ready(Ok(true)) => {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.take_injected_interrupted() {
|
||||
continue;
|
||||
}
|
||||
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
|
||||
Ok(datagram) => datagram,
|
||||
// WouldBlock is a readiness false positive; Interrupted
|
||||
@@ -514,7 +410,7 @@ impl AsyncClient {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let prepared = match self.prepare_async_open(machine, datagram.received_at) {
|
||||
let prepared = match self.prepare_async_open(machine) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(failure) => {
|
||||
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
|
||||
@@ -539,19 +435,11 @@ impl AsyncClient {
|
||||
}
|
||||
if self.prepared_probe.is_none() {
|
||||
match self.machine.prepare_probe() {
|
||||
Ok(prepared) => self.prepared_probe = prepared,
|
||||
Ok(prepared) => self.prepared_probe = Some(prepared),
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
}
|
||||
}
|
||||
if self.prepared_probe.is_none() {
|
||||
return Poll::Ready(Ok(Vec::new()));
|
||||
}
|
||||
|
||||
loop {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_probe_before_writable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match self.socket.poll_send_ready(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {}
|
||||
@@ -560,23 +448,6 @@ impl AsyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let test_timestamps = self.test_hooks.take_probe_timestamps();
|
||||
#[cfg(not(test))]
|
||||
let permission_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let permission_at = test_timestamps
|
||||
.map(|timestamps| timestamps.permission_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
let schedule = self
|
||||
.schedule
|
||||
.as_mut()
|
||||
.expect("open sessions always have a probe schedule");
|
||||
if !schedule.permit_probe_at(permission_at) {
|
||||
self.prepared_probe = None;
|
||||
return Poll::Ready(Ok(Vec::new()));
|
||||
}
|
||||
let prepared = self
|
||||
.prepared_probe
|
||||
.as_ref()
|
||||
@@ -585,11 +456,6 @@ impl AsyncClient {
|
||||
Ok(preflight) => preflight,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
let schedule_commit =
|
||||
match schedule.preflight_caller_commit(machine_preflight.next_packets_sent) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
if let Err(source) = events.try_reserve(1) {
|
||||
return Poll::Ready(Err(ClientError::AllocationFailed {
|
||||
@@ -597,20 +463,14 @@ impl AsyncClient {
|
||||
source,
|
||||
}));
|
||||
}
|
||||
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
let scheduled_at = schedule_commit.scheduled_at;
|
||||
|
||||
// Private pre-send anchor: finalizes all fallible commit work
|
||||
// (timeout deadline arithmetic, kernel TX lower bound) before
|
||||
// the send attempt. A WouldBlock retry samples a fresh one on
|
||||
// the next loop iteration; only the successful attempt's anchor
|
||||
// is retained (via `machine_commit`/`commit_probe_sent` below).
|
||||
#[cfg(not(test))]
|
||||
let send_anchor = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let send_anchor = test_timestamps
|
||||
.map(|timestamps| timestamps.send_anchor)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let machine_commit = match self
|
||||
.machine
|
||||
.finalize_probe_commit(machine_preflight, send_anchor)
|
||||
@@ -618,20 +478,11 @@ impl AsyncClient {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let send_call_start = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_call_start = test_timestamps
|
||||
.map(|timestamps| timestamps.send_call_start)
|
||||
.unwrap_or_else(Instant::now);
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, &prepared.bytes);
|
||||
#[cfg(not(test))]
|
||||
let send_result = self.socket.try_send(&prepared.bytes);
|
||||
let bytes = match send_result {
|
||||
Ok(bytes) => bytes,
|
||||
// No sent_at measurement, no machine commit, no schedule
|
||||
// commit: the transaction is fully discarded and retried
|
||||
// No measurement or machine commit: retry the transaction
|
||||
// with a fresh send_anchor on the next loop iteration.
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
self.machine.invalidate_kernel_tx_correlation();
|
||||
@@ -642,43 +493,25 @@ impl AsyncClient {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
let send_finished_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_finished_at = test_timestamps
|
||||
.map(|timestamps| timestamps.send_finished_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
|
||||
// Public post-send measurement, captured only after the
|
||||
// successful send above. The subsequent machine/schedule commit
|
||||
// successful send above. The subsequent machine commit
|
||||
// is infallible.
|
||||
#[cfg(not(test))]
|
||||
let sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let sent_at = test_timestamps
|
||||
.map(|timestamps| timestamps.sent_at)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let sent = self
|
||||
.machine
|
||||
.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||
schedule.commit(schedule_commit);
|
||||
self.prepared_probe = None;
|
||||
|
||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
|
||||
|
||||
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
if let Err(error) = self.drain_tx_timestamps() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
events.push(echo_sent_event(
|
||||
self.remote,
|
||||
sent,
|
||||
send_call,
|
||||
scheduled_at,
|
||||
timer_error,
|
||||
));
|
||||
events.push(echo_sent_event(self.remote, sent, send_call));
|
||||
return Poll::Ready(Ok(events));
|
||||
}
|
||||
}
|
||||
@@ -699,17 +532,7 @@ impl AsyncClient {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.receive_would_block.get() > 0 {
|
||||
self.test_hooks
|
||||
.receive_would_block
|
||||
.set(self.test_hooks.receive_would_block.get() - 1);
|
||||
continue;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.take_injected_interrupted() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let datagram = match try_recv_tokio_datagram(&self.socket, &mut self.recv_buffer) {
|
||||
Ok(datagram) => datagram,
|
||||
// See `RECV_POLL_WORK_BUDGET`: both a readiness false
|
||||
@@ -743,9 +566,8 @@ impl AsyncClient {
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
if self.machine.is_peer_closed() {
|
||||
self.schedule = None;
|
||||
self.prepared_probe = None;
|
||||
if self.clear_peer_close_dscp().is_ok() {
|
||||
if clear_dscp_on_tokio_socket(&self.socket, self.remote).is_ok() {
|
||||
self.applied_traffic_class = None;
|
||||
}
|
||||
}
|
||||
@@ -778,10 +600,6 @@ impl AsyncClient {
|
||||
let previous_traffic_class = self.applied_traffic_class;
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_close_before_writable.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match self.socket.poll_send_ready(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {}
|
||||
@@ -789,19 +607,11 @@ impl AsyncClient {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
}
|
||||
if let Err(error) = self.clear_close_dscp() {
|
||||
if let Err(error) = clear_dscp_on_tokio_socket(&self.socket, self.remote) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
let mut rollback = DscpRollback::armed(
|
||||
&self.socket,
|
||||
self.remote,
|
||||
previous_traffic_class,
|
||||
#[cfg(test)]
|
||||
&self.test_hooks.fail_dscp_restore,
|
||||
);
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, prepared.bytes);
|
||||
#[cfg(not(test))]
|
||||
let mut rollback =
|
||||
DscpRollback::armed(&self.socket, self.remote, previous_traffic_class);
|
||||
let send_result = self.socket.try_send(prepared.bytes);
|
||||
let bytes = match send_result {
|
||||
Ok(bytes) => bytes,
|
||||
@@ -809,28 +619,18 @@ impl AsyncClient {
|
||||
if let Err(error) = rollback.restore() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.pause_close_after_would_block.replace(false) {
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||
};
|
||||
|
||||
#[cfg(not(test))]
|
||||
let close_sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let close_sent_at = self
|
||||
.test_hooks
|
||||
.close_sent_at
|
||||
.take()
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
|
||||
let event = self
|
||||
.machine
|
||||
.commit_local_close(prepared.commit, close_sent_at);
|
||||
rollback.disarm();
|
||||
self.schedule = None;
|
||||
self.prepared_probe = None;
|
||||
self.applied_traffic_class = None;
|
||||
|
||||
@@ -842,33 +642,19 @@ impl AsyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the next scheduled probe deadline.
|
||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
||||
if !self.machine.is_open() {
|
||||
return None;
|
||||
}
|
||||
self.schedule.as_ref()?.next_send_deadline()
|
||||
}
|
||||
|
||||
pub(crate) fn probe_interval(&self) -> Option<Duration> {
|
||||
if !self.machine.is_open() {
|
||||
return None;
|
||||
}
|
||||
self.schedule.as_ref().map(ProbeSchedule::interval)
|
||||
}
|
||||
|
||||
/// Return the configured local probe timeout.
|
||||
pub fn probe_timeout(&self) -> Duration {
|
||||
self.machine.probe_timeout()
|
||||
}
|
||||
|
||||
/// Return whether the current run has completed.
|
||||
pub fn is_run_complete(&self) -> bool {
|
||||
self.machine.is_terminal()
|
||||
|| self
|
||||
.schedule
|
||||
.as_ref()
|
||||
.is_some_and(|schedule| schedule.is_finished() && self.machine.pending_is_empty())
|
||||
/// Negotiated parameters of the currently open session.
|
||||
pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
|
||||
self.machine.negotiated_params()
|
||||
}
|
||||
|
||||
/// Whether any sent probes still await a reply or timeout.
|
||||
pub fn has_pending_probes(&self) -> bool {
|
||||
!self.machine.pending_is_empty()
|
||||
}
|
||||
|
||||
/// Return whether an authenticated peer close ended the session.
|
||||
@@ -884,7 +670,8 @@ impl AsyncClient {
|
||||
self.machine.packets_sent()
|
||||
}
|
||||
|
||||
pub(crate) fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
/// Earliest timeout deadline among probes still awaiting a reply.
|
||||
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.machine.next_probe_timeout_deadline()
|
||||
}
|
||||
|
||||
@@ -896,64 +683,17 @@ impl AsyncClient {
|
||||
self.prepared_probe = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn retain_open_cleanup_once(&self) {
|
||||
self.test_hooks.fail_open_dscp.set(true);
|
||||
self.test_hooks.pause_open_cleanup_before_send.set(true);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn poll_recv_ready_for_test(&self, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.socket.poll_recv_ready(cx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn remove_pending_for_test(
|
||||
&mut self,
|
||||
wire_seq: u32,
|
||||
) -> Option<crate::probe::PendingProbe> {
|
||||
self.machine.remove_pending_for_test(wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn replace_pending_for_test(&mut self, probe: crate::probe::PendingProbe) {
|
||||
self.machine.replace_pending_for_test(probe);
|
||||
}
|
||||
|
||||
pub(crate) fn skip_missed_probe_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
|
||||
let Some(schedule) = self.schedule.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(deadline) = schedule.next_send_deadline() else {
|
||||
return Ok(());
|
||||
};
|
||||
if deadline > now {
|
||||
return Ok(());
|
||||
}
|
||||
let commit = schedule.preflight_managed_commit(deadline, now)?;
|
||||
schedule.commit(commit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_async_open(
|
||||
&mut self,
|
||||
machine: PreparedOpenAcceptance,
|
||||
opened_at: ClientTimestamp,
|
||||
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
|
||||
let Some(negotiated) = machine.normal_negotiated() else {
|
||||
return Ok(PreparedAsyncOpen {
|
||||
machine,
|
||||
schedule: None,
|
||||
recv_buffer_len: None,
|
||||
negotiated_traffic_class: None,
|
||||
});
|
||||
};
|
||||
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(primary) => {
|
||||
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
||||
}
|
||||
};
|
||||
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
|
||||
Ok(size) => size,
|
||||
Err(primary) => {
|
||||
@@ -981,17 +721,7 @@ impl AsyncClient {
|
||||
machine,
|
||||
}));
|
||||
}
|
||||
#[cfg(test)]
|
||||
let dscp_result = if self.test_hooks.fail_open_dscp.replace(false) {
|
||||
Err(ClientError::SocketOption {
|
||||
operation: "set negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected negotiated DSCP failure"),
|
||||
})
|
||||
} else {
|
||||
apply_traffic_class_to_tokio_socket(&self.socket, self.remote, negotiated_traffic_class)
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
|
||||
let dscp_result = apply_traffic_class_to_tokio_socket(
|
||||
&self.socket,
|
||||
self.remote,
|
||||
@@ -1004,7 +734,6 @@ impl AsyncClient {
|
||||
|
||||
Ok(PreparedAsyncOpen {
|
||||
machine,
|
||||
schedule: Some(schedule),
|
||||
recv_buffer_len: Some(recv_buffer_len),
|
||||
negotiated_traffic_class: Some(negotiated_traffic_class),
|
||||
})
|
||||
@@ -1015,7 +744,7 @@ impl AsyncClient {
|
||||
self.recv_buffer.resize(recv_buffer_len, 0);
|
||||
}
|
||||
let outcome = self.machine.commit_open(prepared.machine);
|
||||
self.schedule = prepared.schedule;
|
||||
|
||||
self.applied_traffic_class = prepared.negotiated_traffic_class;
|
||||
self.prepared_open = None;
|
||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||
@@ -1048,23 +777,6 @@ impl AsyncClient {
|
||||
else {
|
||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
if self
|
||||
.test_hooks
|
||||
.pause_open_cleanup_before_send
|
||||
.replace(false)
|
||||
{
|
||||
return Poll::Pending;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_cleanup_send.replace(false) {
|
||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
||||
}
|
||||
#[cfg(test)]
|
||||
let send_result = self.test_hooks.try_send(&self.socket, packet);
|
||||
#[cfg(not(test))]
|
||||
let send_result = self.socket.try_send(packet);
|
||||
match send_result {
|
||||
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
||||
@@ -1086,22 +798,6 @@ impl AsyncClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_peer_close_dscp(&self) -> Result<(), ClientError> {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_peer_close_dscp.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "clear DSCP before close",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected peer-close DSCP cleanup failure"),
|
||||
});
|
||||
}
|
||||
clear_dscp_on_tokio_socket(&self.socket, self.remote)
|
||||
}
|
||||
|
||||
fn clear_close_dscp(&self) -> Result<(), ClientError> {
|
||||
clear_dscp_on_tokio_socket(&self.socket, self.remote)
|
||||
}
|
||||
|
||||
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
||||
let _ = match previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
@@ -1117,8 +813,6 @@ struct DscpRollback<'a> {
|
||||
remote: SocketAddr,
|
||||
previous_traffic_class: Option<u8>,
|
||||
armed: bool,
|
||||
#[cfg(test)]
|
||||
fail_restore: &'a Cell<bool>,
|
||||
}
|
||||
|
||||
impl<'a> DscpRollback<'a> {
|
||||
@@ -1126,15 +820,12 @@ impl<'a> DscpRollback<'a> {
|
||||
socket: &'a tokio::net::UdpSocket,
|
||||
remote: SocketAddr,
|
||||
previous_traffic_class: Option<u8>,
|
||||
#[cfg(test)] fail_restore: &'a Cell<bool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
remote,
|
||||
previous_traffic_class,
|
||||
armed: true,
|
||||
#[cfg(test)]
|
||||
fail_restore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1142,14 +833,7 @@ impl<'a> DscpRollback<'a> {
|
||||
if !self.armed {
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(test)]
|
||||
if self.fail_restore.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "restore negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected DSCP restore failure"),
|
||||
});
|
||||
}
|
||||
|
||||
match self.previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
|
||||
@@ -1202,6 +886,3 @@ fn poll_readable_until(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
/// Shared HMAC key bytes. Debug output never includes the bytes or a fingerprint.
|
||||
///
|
||||
/// An empty key is still an explicitly configured HMAC key.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct HmacKey(Arc<[u8]>);
|
||||
|
||||
impl HmacKey {
|
||||
/// Store key bytes without interpreting textual key syntax.
|
||||
pub fn new(bytes: impl Into<Arc<[u8]>>) -> Self {
|
||||
Self(bytes.into())
|
||||
}
|
||||
|
||||
/// Explicitly borrow the key material for authentication.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HmacKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("HmacKey([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete authentication for a low-level client session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Authentication {
|
||||
/// Send and accept packets without HMAC authentication.
|
||||
#[default]
|
||||
Unauthenticated,
|
||||
/// Authenticate packets with this key, including when it is empty.
|
||||
Hmac(HmacKey),
|
||||
}
|
||||
|
||||
impl Authentication {
|
||||
pub(crate) fn hmac_key(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
Self::Unauthenticated => None,
|
||||
Self::Hmac(key) => Some(key.as_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,6 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::cell::Cell;
|
||||
|
||||
#[cfg(test)]
|
||||
use irtt_proto::{flags, Params, TimestampFields, PROTOCOL_VERSION};
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, RecvBudget},
|
||||
error::ClientError,
|
||||
@@ -24,23 +18,9 @@ use crate::{
|
||||
timing::ClientTimestamp,
|
||||
};
|
||||
|
||||
pub(crate) mod schedule;
|
||||
|
||||
use schedule::{instant_abs_diff, ProbeSchedule};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ProbeScheduleMode {
|
||||
CallerPaced,
|
||||
#[cfg(test)]
|
||||
Managed {
|
||||
scheduled_at: Instant,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedClientOpen {
|
||||
machine: PreparedOpenAcceptance,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
recv_buffer_len: Option<usize>,
|
||||
negotiated_traffic_class: Option<u8>,
|
||||
previous_traffic_class: Option<u8>,
|
||||
@@ -53,50 +33,6 @@ struct PreparedClientOpenFailure {
|
||||
machine: PreparedOpenAcceptance,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Default)]
|
||||
struct ClientTestHooks {
|
||||
fail_open_dscp: Cell<bool>,
|
||||
fail_open_timeout_restore: Cell<bool>,
|
||||
fail_close_event_reserve: Cell<bool>,
|
||||
fail_close_dscp_clear: Cell<bool>,
|
||||
fail_close_send: Cell<bool>,
|
||||
close_reported_len: Cell<Option<usize>>,
|
||||
close_send_attempts: Cell<usize>,
|
||||
close_sent_at: Cell<Option<ClientTimestamp>>,
|
||||
fail_cleanup_send: Cell<bool>,
|
||||
recv_buffer_len_override: Cell<Option<usize>>,
|
||||
fail_dscp_restore: Cell<bool>,
|
||||
last_restored_read_timeout: Cell<Option<Duration>>,
|
||||
prepared_active_session_before_dscp: Cell<bool>,
|
||||
/// Number of subsequent receive attempts that should report
|
||||
/// `io::ErrorKind::Interrupted` before a real receive is attempted.
|
||||
/// Decremented on every injected receive; `0` means inject nothing.
|
||||
inject_recv_interrupted: Cell<usize>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct ProbeSendTimestamps {
|
||||
pub(crate) permission_at: Instant,
|
||||
/// Private pre-send anchor: timeout deadline / kernel TX lower bound.
|
||||
pub(crate) send_anchor: ClientTimestamp,
|
||||
pub(crate) send_call_start: Instant,
|
||||
pub(crate) send_finished_at: Instant,
|
||||
/// Public post-send measurement: RTT / OWD fallback / timer_error.
|
||||
pub(crate) sent_at: ClientTimestamp,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::{
|
||||
session::machine::{
|
||||
compute_one_way, compute_rtt, params_from_config, sequence_is_after, sequence_is_before,
|
||||
unix_epoch_ns_i64, update_highest_received,
|
||||
},
|
||||
session::negotiate_params,
|
||||
NegotiatedParams, RunMode, SignedDuration, WarningKind, MAX_UDP_PAYLOAD_LENGTH,
|
||||
};
|
||||
|
||||
/// Low-level synchronous IRTT client.
|
||||
///
|
||||
/// `Client` exposes the protocol steps directly: connect a UDP socket, open a
|
||||
@@ -131,7 +67,6 @@ use crate::{
|
||||
#[derive(Debug)]
|
||||
pub struct Client {
|
||||
runtime: SessionMachine,
|
||||
schedule: Option<ProbeSchedule>,
|
||||
socket: UdpSocket,
|
||||
remote: SocketAddr,
|
||||
recv_buffer: Vec<u8>,
|
||||
@@ -143,14 +78,6 @@ pub struct Client {
|
||||
/// unnecessary error-queue drains are skipped once it is known there is
|
||||
/// nothing to drain.
|
||||
tx_timestamping_enabled: bool,
|
||||
#[cfg(test)]
|
||||
test_hooks: ClientTestHooks,
|
||||
#[cfg(test)]
|
||||
probe_reported_len: Option<usize>,
|
||||
#[cfg(test)]
|
||||
probe_send_error: bool,
|
||||
#[cfg(test)]
|
||||
probe_send_timestamps: Option<ProbeSendTimestamps>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
@@ -167,20 +94,11 @@ impl Client {
|
||||
|
||||
Ok(Self {
|
||||
runtime,
|
||||
schedule: None,
|
||||
socket,
|
||||
remote,
|
||||
recv_buffer: vec![0_u8; recv_buffer_size(false, None)?],
|
||||
applied_traffic_class: None,
|
||||
tx_timestamping_enabled: false,
|
||||
#[cfg(test)]
|
||||
test_hooks: ClientTestHooks::default(),
|
||||
#[cfg(test)]
|
||||
probe_reported_len: None,
|
||||
#[cfg(test)]
|
||||
probe_send_error: false,
|
||||
#[cfg(test)]
|
||||
probe_send_timestamps: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,8 +121,9 @@ impl Client {
|
||||
pub fn open(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||
let result = self.open_transaction();
|
||||
if result.is_err() {
|
||||
let _ =
|
||||
self.restore_open_read_timeout(self.runtime.config().socket_config.recv_timeout);
|
||||
let _ = self
|
||||
.socket
|
||||
.set_read_timeout(self.runtime.config().socket_config.recv_timeout);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -214,19 +133,11 @@ impl Client {
|
||||
/// The close event means the client has sent its close packet and stopped
|
||||
/// tracking the session locally; it is not a server acknowledgement. If the
|
||||
/// send fails, the negotiated DSCP is restored best-effort and the session
|
||||
/// and probe schedule remain open.
|
||||
/// remains open.
|
||||
pub fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
let prepared = self.runtime.prepare_close()?;
|
||||
let mut events = Vec::new();
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_close_event_reserve.replace(false) {
|
||||
events
|
||||
.try_reserve(usize::MAX)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "close event result",
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
|
||||
events
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
@@ -237,94 +148,88 @@ impl Client {
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
self.clear_close_dscp()?;
|
||||
let bytes = match self.send_close_datagram(prepared.bytes) {
|
||||
let bytes = match self.socket.send(prepared.bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
self.restore_dscp_best_effort(previous_traffic_class);
|
||||
return Err(ClientError::Socket(err));
|
||||
}
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let sent_at = self
|
||||
.test_hooks
|
||||
.close_sent_at
|
||||
.take()
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
|
||||
self.schedule = None;
|
||||
self.applied_traffic_class = None;
|
||||
|
||||
let event = self.runtime.commit_local_close(prepared.commit, sent_at);
|
||||
|
||||
self.applied_traffic_class = None;
|
||||
validate_datagram_length(expected_bytes, bytes)?;
|
||||
events.push(event);
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Return the monotonic deadline for the next probe send, if another probe
|
||||
/// is scheduled.
|
||||
pub fn next_send_deadline(&self) -> Option<Instant> {
|
||||
if !self.runtime.is_open() {
|
||||
return None;
|
||||
}
|
||||
self.schedule.as_ref()?.next_send_deadline()
|
||||
}
|
||||
|
||||
/// Return the local timeout used to classify pending probes as lost.
|
||||
pub fn probe_timeout(&self) -> Duration {
|
||||
self.runtime.probe_timeout()
|
||||
}
|
||||
|
||||
/// Send one echo probe if the negotiated run is still active.
|
||||
/// Send one echo probe now. The caller owns pacing and run duration.
|
||||
///
|
||||
/// Returns an `EchoSent` event when a probe is sent. Returns an empty event
|
||||
/// list when the run duration has elapsed and no further probe should be
|
||||
/// sent.
|
||||
/// The negotiated interval and duration do not gate low-level sends.
|
||||
pub fn send_probe(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.send_probe_transaction(ProbeScheduleMode::CallerPaced)
|
||||
let remote = self.remote;
|
||||
let (runtime, socket) = (&mut self.runtime, &self.socket);
|
||||
let prepared = runtime.prepare_probe()?;
|
||||
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
|
||||
let mut events = Vec::new();
|
||||
events
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "probe event result",
|
||||
source,
|
||||
})?;
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
||||
// capacity/collision checks already done above) is finalized from
|
||||
// this PRIVATE pre-send anchor, before the socket send. It is not
|
||||
// the public measurement `sent_at` captured further below.
|
||||
let send_anchor = ClientTimestamp::now();
|
||||
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
|
||||
let send_call_start = Instant::now();
|
||||
|
||||
let bytes = match socket.send(&prepared.bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(ClientError::Socket(error));
|
||||
}
|
||||
};
|
||||
let send_finished_at = Instant::now();
|
||||
|
||||
// The public measurement timestamp: paired wall/monotonic sample
|
||||
// captured immediately after the successful socket send completed.
|
||||
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
|
||||
// endpoint. The infallible machine commit below never fails.
|
||||
let sent_at = ClientTimestamp::now();
|
||||
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||
validate_datagram_length(expected_bytes, bytes)?;
|
||||
self.drain_tx_timestamps()?;
|
||||
|
||||
events.push(echo_sent_event(remote, sent, send_call));
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Receive and classify at most one datagram from the socket.
|
||||
///
|
||||
/// Returns an empty event list when the socket read would block or times
|
||||
/// out. Malformed or unrelated datagrams are reported as warning events.
|
||||
/// Requires an open session; lifecycle errors are returned before socket I/O.
|
||||
pub fn recv_once(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.runtime.ensure_open()?;
|
||||
self.socket
|
||||
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
|
||||
self.recv_once_inner()
|
||||
}
|
||||
|
||||
/// Receive one datagram into `self.recv_buffer`. In test builds, a
|
||||
/// pending `inject_recv_interrupted` count reports
|
||||
/// `io::ErrorKind::Interrupted` instead of performing a real receive,
|
||||
/// deterministically exercising interrupted-syscall retry behavior.
|
||||
fn recv_datagram_once(&mut self) -> Result<ReceivedDatagram, io::Error> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let remaining = self.test_hooks.inject_recv_interrupted.get();
|
||||
if remaining > 0 {
|
||||
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
|
||||
return Err(io::Error::from(io::ErrorKind::Interrupted));
|
||||
}
|
||||
}
|
||||
recv_datagram(&self.socket, &mut self.recv_buffer)
|
||||
}
|
||||
|
||||
/// Same as [`Self::recv_datagram_once`], receiving into a caller-supplied
|
||||
/// buffer instead of `self.recv_buffer` (used before the negotiated
|
||||
/// receive buffer size is known, i.e. during Open).
|
||||
fn recv_datagram_once_into(&self, buf: &mut [u8]) -> Result<ReceivedDatagram, io::Error> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let remaining = self.test_hooks.inject_recv_interrupted.get();
|
||||
if remaining > 0 {
|
||||
self.test_hooks.inject_recv_interrupted.set(remaining - 1);
|
||||
return Err(io::Error::from(io::ErrorKind::Interrupted));
|
||||
}
|
||||
}
|
||||
recv_datagram(&self.socket, buf)
|
||||
}
|
||||
|
||||
fn recv_once_inner(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
let Some(datagram) = self.recv_datagram_retrying_interrupted()? else {
|
||||
return Ok(vec![]);
|
||||
@@ -340,11 +245,8 @@ impl Client {
|
||||
datagram.received_at,
|
||||
datagram.meta,
|
||||
)?;
|
||||
if self.runtime.is_peer_closed() {
|
||||
self.schedule = None;
|
||||
if self.clear_close_dscp().is_ok() {
|
||||
self.applied_traffic_class = None;
|
||||
}
|
||||
if self.runtime.is_peer_closed() && self.clear_close_dscp().is_ok() {
|
||||
self.applied_traffic_class = None;
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
@@ -377,7 +279,7 @@ impl Client {
|
||||
let mut timeout_narrowed = false;
|
||||
|
||||
let result = loop {
|
||||
match self.recv_datagram_once() {
|
||||
match recv_datagram(&self.socket, &mut self.recv_buffer) {
|
||||
Ok(datagram) => break Ok(Some(datagram)),
|
||||
Err(err)
|
||||
if matches!(
|
||||
@@ -416,7 +318,10 @@ impl Client {
|
||||
|
||||
/// Receive and classify datagrams until a receive produces no events or the
|
||||
/// receive budget is exhausted.
|
||||
///
|
||||
/// Requires an open session; lifecycle errors are returned before socket I/O.
|
||||
pub fn recv_available(&mut self, budget: RecvBudget) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.runtime.ensure_open()?;
|
||||
self.socket
|
||||
.set_read_timeout(self.runtime.config().socket_config.recv_timeout)?;
|
||||
let mut all_events = Vec::new();
|
||||
@@ -450,17 +355,19 @@ impl Client {
|
||||
self.runtime.poll_timeouts_at(now)
|
||||
}
|
||||
|
||||
/// Return whether the current run has completed.
|
||||
///
|
||||
/// A normal run is complete once no more probes will be sent and all
|
||||
/// pending probes have either replied or timed out. No-test and closed
|
||||
/// sessions are also considered complete.
|
||||
pub fn is_run_complete(&self) -> bool {
|
||||
self.runtime.is_terminal()
|
||||
|| self
|
||||
.schedule
|
||||
.as_ref()
|
||||
.is_some_and(|schedule| schedule.is_finished() && self.runtime.pending_is_empty())
|
||||
/// Negotiated parameters of the currently open session.
|
||||
pub fn negotiated_params(&self) -> Option<&crate::NegotiatedParams> {
|
||||
self.runtime.negotiated_params()
|
||||
}
|
||||
|
||||
/// Earliest timeout deadline among probes still awaiting a reply.
|
||||
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.runtime.next_probe_timeout_deadline()
|
||||
}
|
||||
|
||||
/// Whether any sent probes still await a reply or timeout.
|
||||
pub fn has_pending_probes(&self) -> bool {
|
||||
!self.runtime.pending_is_empty()
|
||||
}
|
||||
|
||||
/// Return whether the session was closed by a peer close-flagged reply.
|
||||
@@ -473,130 +380,6 @@ impl Client {
|
||||
self.runtime.is_peer_closed()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn send_managed_probe(
|
||||
&mut self,
|
||||
scheduled_at: Instant,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.send_probe_transaction(ProbeScheduleMode::Managed { scheduled_at })
|
||||
}
|
||||
|
||||
fn send_probe_transaction(
|
||||
&mut self,
|
||||
mode: ProbeScheduleMode,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.runtime.ensure_open()?;
|
||||
#[cfg(test)]
|
||||
let test_timestamps = self.probe_send_timestamps.take();
|
||||
#[cfg(test)]
|
||||
let fail_send = std::mem::take(&mut self.probe_send_error);
|
||||
let remote = self.remote;
|
||||
let (runtime, schedule, socket) = (&mut self.runtime, &mut self.schedule, &self.socket);
|
||||
let schedule = schedule
|
||||
.as_mut()
|
||||
.expect("open sessions always have a probe schedule");
|
||||
#[cfg(not(test))]
|
||||
let permission_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let permission_at = test_timestamps
|
||||
.map(|timestamps| timestamps.permission_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
if !schedule.permit_probe_at(permission_at) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let Some(prepared) = runtime.prepare_probe()? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let machine_preflight = runtime.preflight_probe_commit(&prepared)?;
|
||||
let schedule_commit = match mode {
|
||||
ProbeScheduleMode::CallerPaced => {
|
||||
schedule.preflight_caller_commit(machine_preflight.next_packets_sent)?
|
||||
}
|
||||
#[cfg(test)]
|
||||
ProbeScheduleMode::Managed { scheduled_at } => {
|
||||
schedule.preflight_managed_commit(scheduled_at, permission_at)?
|
||||
}
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
events
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "probe event result",
|
||||
source,
|
||||
})?;
|
||||
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
let scheduled_at = schedule_commit.scheduled_at;
|
||||
#[cfg(test)]
|
||||
let reported_bytes = self.probe_reported_len.take();
|
||||
// All fallible probe-commit preflight (timeout deadline arithmetic,
|
||||
// capacity/collision checks already done above) is finalized from
|
||||
// this PRIVATE pre-send anchor, before the socket send. It is not
|
||||
// the public measurement `sent_at` captured further below.
|
||||
#[cfg(not(test))]
|
||||
let send_anchor = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let send_anchor = test_timestamps
|
||||
.map(|timestamps| timestamps.send_anchor)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let machine_commit = runtime.finalize_probe_commit(machine_preflight, send_anchor)?;
|
||||
#[cfg(not(test))]
|
||||
let send_call_start = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_call_start = test_timestamps
|
||||
.map(|timestamps| timestamps.send_call_start)
|
||||
.unwrap_or_else(Instant::now);
|
||||
#[cfg(test)]
|
||||
if fail_send {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(ClientError::Socket(io::Error::other(
|
||||
"injected probe send failure",
|
||||
)));
|
||||
}
|
||||
let bytes = match socket.send(&prepared.bytes) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
runtime.invalidate_kernel_tx_correlation();
|
||||
return Err(ClientError::Socket(error));
|
||||
}
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let send_finished_at = Instant::now();
|
||||
#[cfg(test)]
|
||||
let send_finished_at = test_timestamps
|
||||
.map(|timestamps| timestamps.send_finished_at)
|
||||
.unwrap_or_else(Instant::now);
|
||||
// The public measurement timestamp: paired wall/monotonic sample
|
||||
// captured immediately after the successful socket send completed.
|
||||
// This, not `send_anchor` above, is the RTT/OWD-fallback/timer_error
|
||||
// endpoint. The infallible machine/schedule commit below never fails.
|
||||
#[cfg(not(test))]
|
||||
let sent_at = ClientTimestamp::now();
|
||||
#[cfg(test)]
|
||||
let sent_at = test_timestamps
|
||||
.map(|timestamps| timestamps.sent_at)
|
||||
.unwrap_or_else(ClientTimestamp::now);
|
||||
let sent = runtime.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||
schedule.commit(schedule_commit);
|
||||
|
||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||
let timer_error = instant_abs_diff(sent_at.mono, scheduled_at);
|
||||
#[cfg(test)]
|
||||
let bytes = reported_bytes.unwrap_or(bytes);
|
||||
validate_datagram_length(expected_bytes, bytes)?;
|
||||
self.drain_tx_timestamps()?;
|
||||
|
||||
events.push(echo_sent_event(
|
||||
remote,
|
||||
sent,
|
||||
send_call,
|
||||
scheduled_at,
|
||||
timer_error,
|
||||
));
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn open_transaction(&mut self) -> Result<OpenOutcome, ClientError> {
|
||||
let request = self.runtime.prepare_open_request()?;
|
||||
let mut buf = [0_u8; MAX_OPEN_PACKET_SIZE];
|
||||
@@ -620,7 +403,7 @@ impl Client {
|
||||
}
|
||||
self.socket.set_read_timeout(Some(remaining))?;
|
||||
|
||||
let datagram = match self.recv_datagram_once_into(&mut buf) {
|
||||
let datagram = match recv_datagram(&self.socket, &mut buf) {
|
||||
Ok(datagram) => datagram,
|
||||
// WouldBlock/TimedOut mean the attempt's own deadline
|
||||
// elapsed; Interrupted (EINTR) means the syscall was
|
||||
@@ -663,7 +446,7 @@ impl Client {
|
||||
return Err(failure.primary);
|
||||
}
|
||||
};
|
||||
let prepared = match self.prepare_client_open(machine, datagram.received_at) {
|
||||
let prepared = match self.prepare_client_open(machine) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(failure) => {
|
||||
self.send_cleanup_close_best_effort(failure.machine.cleanup_close_packet());
|
||||
@@ -680,32 +463,21 @@ impl Client {
|
||||
fn prepare_client_open(
|
||||
&self,
|
||||
machine: PreparedOpenAcceptance,
|
||||
opened_at: ClientTimestamp,
|
||||
) -> Result<PreparedClientOpen, Box<PreparedClientOpenFailure>> {
|
||||
let Some(negotiated) = machine.normal_negotiated() else {
|
||||
return Ok(PreparedClientOpen {
|
||||
machine,
|
||||
schedule: None,
|
||||
recv_buffer_len: None,
|
||||
negotiated_traffic_class: None,
|
||||
previous_traffic_class: self.applied_traffic_class,
|
||||
post_open_recv_timeout: self.runtime.config().socket_config.recv_timeout,
|
||||
});
|
||||
};
|
||||
let schedule = match ProbeSchedule::new(opened_at.mono, negotiated) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
|
||||
};
|
||||
let recv_buffer_len = match recv_buffer_size(self.runtime.has_hmac(), Some(negotiated)) {
|
||||
Ok(size) => size,
|
||||
Err(primary) => return Err(Box::new(PreparedClientOpenFailure { primary, machine })),
|
||||
};
|
||||
#[cfg(test)]
|
||||
let recv_buffer_len = self
|
||||
.test_hooks
|
||||
.recv_buffer_len_override
|
||||
.get()
|
||||
.unwrap_or(recv_buffer_len);
|
||||
|
||||
let negotiated_traffic_class = match u8::try_from(negotiated.params.dscp) {
|
||||
Ok(traffic_class) => traffic_class,
|
||||
Err(_) => {
|
||||
@@ -720,7 +492,6 @@ impl Client {
|
||||
|
||||
Ok(PreparedClientOpen {
|
||||
machine,
|
||||
schedule: Some(schedule),
|
||||
recv_buffer_len: Some(recv_buffer_len),
|
||||
negotiated_traffic_class: Some(negotiated_traffic_class),
|
||||
previous_traffic_class: self.applied_traffic_class,
|
||||
@@ -734,7 +505,6 @@ impl Client {
|
||||
) -> Result<OpenOutcome, ClientError> {
|
||||
let PreparedClientOpen {
|
||||
machine,
|
||||
schedule,
|
||||
recv_buffer_len,
|
||||
negotiated_traffic_class,
|
||||
previous_traffic_class,
|
||||
@@ -755,17 +525,15 @@ impl Client {
|
||||
}
|
||||
self.recv_buffer.resize(recv_buffer_len, 0);
|
||||
|
||||
#[cfg(test)]
|
||||
self.test_hooks
|
||||
.prepared_active_session_before_dscp
|
||||
.set(machine.has_prepared_active_session());
|
||||
if let Err(primary) = self.apply_open_dscp(negotiated_traffic_class) {
|
||||
if let Err(primary) =
|
||||
apply_traffic_class_to_socket(&self.socket, self.remote, negotiated_traffic_class)
|
||||
{
|
||||
self.recv_buffer.truncate(previous_len);
|
||||
self.restore_dscp_best_effort(previous_traffic_class);
|
||||
self.send_cleanup_close_best_effort(machine.cleanup_close_packet());
|
||||
return Err(primary);
|
||||
}
|
||||
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
|
||||
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
|
||||
let primary = ClientError::ReadTimeoutRestore { source };
|
||||
self.recv_buffer.truncate(previous_len);
|
||||
self.restore_dscp_best_effort(previous_traffic_class);
|
||||
@@ -774,17 +542,16 @@ impl Client {
|
||||
}
|
||||
|
||||
let outcome = self.runtime.commit_open(machine);
|
||||
self.schedule = schedule;
|
||||
|
||||
self.applied_traffic_class = Some(negotiated_traffic_class);
|
||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||
Ok(outcome)
|
||||
} else {
|
||||
debug_assert!(schedule.is_none());
|
||||
if let Err(source) = self.restore_open_read_timeout(post_open_recv_timeout) {
|
||||
if let Err(source) = self.socket.set_read_timeout(post_open_recv_timeout) {
|
||||
return Err(ClientError::ReadTimeoutRestore { source });
|
||||
}
|
||||
let outcome = self.runtime.commit_open(machine);
|
||||
self.schedule = None;
|
||||
|
||||
self.applied_traffic_class = None;
|
||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||
Ok(outcome)
|
||||
@@ -809,10 +576,6 @@ impl Client {
|
||||
}
|
||||
|
||||
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_dscp_restore.replace(false) {
|
||||
return;
|
||||
}
|
||||
let _ = match previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
|
||||
@@ -822,85 +585,29 @@ impl Client {
|
||||
}
|
||||
|
||||
fn clear_close_dscp(&self) -> Result<(), ClientError> {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_close_dscp_clear.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "clear negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected negotiated DSCP clear failure"),
|
||||
});
|
||||
}
|
||||
clear_dscp_on_socket(&self.socket, self.remote)
|
||||
}
|
||||
|
||||
fn send_cleanup_close_best_effort(&self, packet: Option<&[u8]>) {
|
||||
if let Some(packet) = packet {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_cleanup_send.replace(false) {
|
||||
return;
|
||||
}
|
||||
let _ = self.socket.send(packet);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_open_dscp(&self, traffic_class: u8) -> Result<(), ClientError> {
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_open_dscp.replace(false) {
|
||||
return Err(ClientError::SocketOption {
|
||||
operation: "set negotiated DSCP",
|
||||
remote: self.remote,
|
||||
source: io::Error::other("injected negotiated DSCP failure"),
|
||||
});
|
||||
}
|
||||
apply_traffic_class_to_socket(&self.socket, self.remote, traffic_class)
|
||||
}
|
||||
|
||||
fn restore_open_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
|
||||
#[cfg(test)]
|
||||
self.test_hooks.last_restored_read_timeout.set(timeout);
|
||||
#[cfg(test)]
|
||||
if self.test_hooks.fail_open_timeout_restore.replace(false) {
|
||||
return Err(io::Error::other(
|
||||
"injected configured read timeout restoration failure",
|
||||
));
|
||||
}
|
||||
self.socket.set_read_timeout(timeout)
|
||||
}
|
||||
|
||||
fn send_close_datagram(&self, packet: &[u8]) -> io::Result<usize> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.test_hooks
|
||||
.close_send_attempts
|
||||
.set(self.test_hooks.close_send_attempts.get() + 1);
|
||||
if self.test_hooks.fail_close_send.replace(false) {
|
||||
return Err(io::Error::other("injected close send failure"));
|
||||
}
|
||||
}
|
||||
let bytes = self.socket.send(packet)?;
|
||||
#[cfg(test)]
|
||||
if let Some(reported) = self.test_hooks.close_reported_len.take() {
|
||||
return Ok(reported);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn echo_sent_event(
|
||||
remote: SocketAddr,
|
||||
sent: ProbeSent,
|
||||
send_call: Duration,
|
||||
scheduled_at: Instant,
|
||||
timer_error: Duration,
|
||||
) -> ClientEvent {
|
||||
ClientEvent::EchoSent {
|
||||
seq: sent.seq,
|
||||
remote,
|
||||
scheduled_at,
|
||||
scheduled_at: None,
|
||||
sent_at: sent.sent_at,
|
||||
bytes: sent.bytes,
|
||||
send_call,
|
||||
timer_error,
|
||||
timer_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,47 +618,3 @@ pub(crate) fn validate_datagram_length(expected: usize, actual: usize) -> Result
|
||||
Err(ClientError::DatagramLengthMismatch { expected, actual })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Client {
|
||||
fn send_probe_at(
|
||||
&mut self,
|
||||
timestamps: ProbeSendTimestamps,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.probe_send_timestamps = Some(timestamps);
|
||||
self.send_probe()
|
||||
}
|
||||
|
||||
fn send_managed_probe_at(
|
||||
&mut self,
|
||||
scheduled_at: Instant,
|
||||
timestamps: ProbeSendTimestamps,
|
||||
) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.probe_send_timestamps = Some(timestamps);
|
||||
self.send_managed_probe(scheduled_at)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bounded_receive_timeout(
|
||||
deadline: Option<Instant>,
|
||||
configured_timeout: Option<Duration>,
|
||||
max_wait: Duration,
|
||||
now: Instant,
|
||||
) -> Option<Duration> {
|
||||
if max_wait.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let mut timeout = configured_timeout.unwrap_or(max_wait).min(max_wait);
|
||||
if let Some(deadline) = deadline {
|
||||
let remaining = deadline.checked_duration_since(now)?;
|
||||
if remaining.is_zero() {
|
||||
return None;
|
||||
}
|
||||
timeout = timeout.min(remaining);
|
||||
}
|
||||
(!timeout.is_zero()).then_some(timeout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,318 +0,0 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{error::ClientError, session::NegotiatedParams};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProbeSchedule {
|
||||
start_at: Instant,
|
||||
end_at: Option<Instant>,
|
||||
interval: Duration,
|
||||
next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScheduleCommit {
|
||||
pub(crate) scheduled_at: Instant,
|
||||
pub(crate) next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ProbeSchedule {
|
||||
pub(crate) fn new(
|
||||
start_at: Instant,
|
||||
negotiated: &NegotiatedParams,
|
||||
) -> Result<Self, ClientError> {
|
||||
let interval_ns = u64::try_from(negotiated.params.interval_ns)
|
||||
.expect("validated positive negotiated interval");
|
||||
let interval = Duration::from_nanos(interval_ns);
|
||||
let end_at = if negotiated.params.duration_ns > 0 {
|
||||
let duration_ns = u64::try_from(negotiated.params.duration_ns)
|
||||
.expect("validated positive negotiated duration");
|
||||
Some(
|
||||
start_at
|
||||
.checked_add(Duration::from_nanos(duration_ns))
|
||||
.ok_or_else(|| ClientError::NegotiationRejected {
|
||||
reason: "duration is too large to schedule".to_owned(),
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
start_at,
|
||||
end_at,
|
||||
interval,
|
||||
next_send_at: Some(start_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
||||
self.next_send_at
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub(crate) fn interval(&self) -> Duration {
|
||||
self.interval
|
||||
}
|
||||
|
||||
pub(crate) fn permit_probe_at(&mut self, now: Instant) -> bool {
|
||||
if self.next_send_at.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.end_at.is_some_and(|end| now >= end) {
|
||||
self.next_send_at = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_caller_commit(
|
||||
&self,
|
||||
next_packets_sent: u64,
|
||||
) -> Result<ScheduleCommit, ClientError> {
|
||||
let scheduled_at = self
|
||||
.next_send_at
|
||||
.expect("a caller schedule commit requires a permitted probe");
|
||||
let interval_ns =
|
||||
u64::try_from(self.interval.as_nanos()).map_err(|_| ClientError::DurationOverflow)?;
|
||||
let next_send_at = next_probe_deadline(self.start_at, interval_ns, next_packets_sent)?;
|
||||
Ok(self.finish_commit(scheduled_at, next_send_at))
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
pub(crate) fn preflight_managed_commit(
|
||||
&self,
|
||||
scheduled_at: Instant,
|
||||
permission_at: Instant,
|
||||
) -> Result<ScheduleCommit, ClientError> {
|
||||
let (scheduled_at, next_send_at, _) =
|
||||
advance_cadence(scheduled_at, self.interval, permission_at)?;
|
||||
Ok(self.finish_commit(scheduled_at, next_send_at))
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&mut self, commit: ScheduleCommit) {
|
||||
self.next_send_at = commit.next_send_at;
|
||||
}
|
||||
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
self.next_send_at.is_none()
|
||||
}
|
||||
|
||||
fn finish_commit(&self, scheduled_at: Instant, next_send_at: Instant) -> ScheduleCommit {
|
||||
ScheduleCommit {
|
||||
scheduled_at,
|
||||
next_send_at: if self.end_at.is_some_and(|end| next_send_at >= end) {
|
||||
None
|
||||
} else {
|
||||
Some(next_send_at)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_probe_deadline(
|
||||
start: Instant,
|
||||
interval_ns: u64,
|
||||
packets_sent: u64,
|
||||
) -> Result<Instant, ClientError> {
|
||||
let offset_ns = interval_ns
|
||||
.checked_mul(packets_sent)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
start
|
||||
.checked_add(Duration::from_nanos(offset_ns))
|
||||
.ok_or(ClientError::DurationOverflow)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
pub(crate) fn advance_cadence(
|
||||
deadline: Instant,
|
||||
interval: Duration,
|
||||
now: Instant,
|
||||
) -> Result<(Instant, Instant, u128), ClientError> {
|
||||
if interval.is_zero() {
|
||||
return Err(ClientError::InvalidConfig {
|
||||
reason: "probe interval must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed_slots = now
|
||||
.checked_duration_since(deadline)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos() / interval.as_nanos());
|
||||
let scheduled_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(elapsed_slots)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let next_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(
|
||||
elapsed_slots
|
||||
.checked_add(1)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let scheduled_at = deadline
|
||||
.checked_add(scheduled_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
let next_at = deadline
|
||||
.checked_add(next_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
Ok((scheduled_at, next_at, elapsed_slots))
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
|
||||
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
||||
let seconds =
|
||||
u64::try_from(nanos / NANOS_PER_SECOND).map_err(|_| ClientError::DurationOverflow)?;
|
||||
let subsec_nanos = u32::try_from(nanos % NANOS_PER_SECOND)
|
||||
.expect("nanosecond remainder is always less than one second");
|
||||
Ok(Duration::new(seconds, subsec_nanos))
|
||||
}
|
||||
|
||||
pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||
left.checked_duration_since(right)
|
||||
.or_else(|| right.checked_duration_since(left))
|
||||
.unwrap_or(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_proto::Params;
|
||||
|
||||
fn negotiated(interval_ns: i64, duration_ns: i64) -> NegotiatedParams {
|
||||
NegotiatedParams {
|
||||
params: Params {
|
||||
interval_ns,
|
||||
duration_ns,
|
||||
..Params::default()
|
||||
},
|
||||
restrictions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_deadline_is_the_opening_time() {
|
||||
let opened_at = Instant::now();
|
||||
|
||||
let schedule = ProbeSchedule::new(opened_at, &negotiated(10_000_000, 0)).unwrap();
|
||||
|
||||
assert_eq!(schedule.next_send_deadline(), Some(opened_at));
|
||||
assert!(!schedule.is_finished());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_preflight_keeps_absolute_cadence_without_mutation() {
|
||||
let start = Instant::now();
|
||||
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
|
||||
let commit = schedule.preflight_caller_commit(3).unwrap();
|
||||
|
||||
assert_eq!(commit.scheduled_at, start);
|
||||
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(30)));
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_commit_advances_exactly_one_cadence_slot() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
|
||||
let commit = schedule.preflight_caller_commit(1).unwrap();
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
|
||||
schedule.commit(commit);
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(10))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_duration_uses_an_exclusive_end_boundary() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
|
||||
|
||||
let first = schedule.preflight_caller_commit(1).unwrap();
|
||||
schedule.commit(first);
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(10))
|
||||
);
|
||||
|
||||
let second = schedule.preflight_caller_commit(2).unwrap();
|
||||
schedule.commit(second);
|
||||
|
||||
assert!(schedule.is_finished());
|
||||
assert_eq!(schedule.next_send_deadline(), None);
|
||||
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permit_at_exclusive_end_finishes_schedule_without_a_send() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 20_000_000)).unwrap();
|
||||
|
||||
assert!(!schedule.permit_probe_at(start + Duration::from_millis(20)));
|
||||
assert!(schedule.is_finished());
|
||||
assert_eq!(schedule.next_send_deadline(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_preflight_skips_missed_slots_without_mutation() {
|
||||
let start = Instant::now();
|
||||
let schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
let sent_at = start + Duration::from_millis(45);
|
||||
|
||||
let commit = schedule.preflight_managed_commit(start, sent_at).unwrap();
|
||||
|
||||
assert_eq!(commit.scheduled_at, start + Duration::from_millis(40));
|
||||
assert_eq!(commit.next_send_at, Some(start + Duration::from_millis(50)));
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_managed_commit_does_not_replay_catch_up_slots() {
|
||||
let start = Instant::now();
|
||||
let mut schedule = ProbeSchedule::new(start, &negotiated(10_000_000, 0)).unwrap();
|
||||
let delayed_send = start + Duration::from_millis(45);
|
||||
|
||||
let commit = schedule
|
||||
.preflight_managed_commit(start, delayed_send)
|
||||
.unwrap();
|
||||
schedule.commit(commit);
|
||||
|
||||
assert_eq!(
|
||||
schedule.next_send_deadline(),
|
||||
Some(start + Duration::from_millis(50))
|
||||
);
|
||||
assert!(schedule.next_send_deadline().unwrap() > delayed_send);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadline_arithmetic_overflow_is_detected_before_commit() {
|
||||
let start = Instant::now();
|
||||
let schedule = ProbeSchedule::new(start, &negotiated(2, 0)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
schedule.preflight_caller_commit(u64::MAX),
|
||||
Err(ClientError::DurationOverflow)
|
||||
));
|
||||
assert_eq!(schedule.next_send_deadline(), Some(start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_error_is_adapter_owned() {
|
||||
let scheduled_at = Instant::now();
|
||||
let sent_at = scheduled_at + Duration::from_millis(7);
|
||||
|
||||
assert_eq!(
|
||||
instant_abs_diff(sent_at, scheduled_at),
|
||||
Duration::from_millis(7)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
use super::*;
|
||||
use crate::socket_options::socket_traffic_class;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn normal_open_applies_negotiated_traffic_class_after_open_and_close_clears_it() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF): a
|
||||
// compliant server echoes back the raw byte it received, unchanged.
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
0
|
||||
);
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
184
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
||||
0
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn normal_open_uses_negotiated_traffic_class_not_requested_dscp() {
|
||||
let mut returned = default_params();
|
||||
returned.dscp = 0;
|
||||
let server = open_success_server(returned);
|
||||
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
let negotiated = assert_open_started(client.open().unwrap());
|
||||
assert_eq!(negotiated.params.dscp, 0);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![crate::NegotiationRestriction::DscpChanged {
|
||||
requested: 46,
|
||||
negotiated: 0,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
||||
0
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn failed_close_send_restores_negotiated_traffic_class_and_keeps_session_open() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_send.set(true);
|
||||
|
||||
let error = client.close().unwrap_err();
|
||||
|
||||
assert!(matches!(error, ClientError::Socket(_)));
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(184));
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
184
|
||||
);
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_close_keeps_send_error_primary_when_dscp_restoration_also_fails() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_send.set(true);
|
||||
client.test_hooks.fail_dscp_restore.set(true);
|
||||
|
||||
let error = client.close().unwrap_err();
|
||||
|
||||
assert!(matches!(error, ClientError::Socket(_)));
|
||||
assert!(error.to_string().contains("injected close send failure"));
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn authenticated_peer_close_clears_schedule_and_negotiated_traffic_class() {
|
||||
let mut params = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
params.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
socket
|
||||
.send_to(
|
||||
&echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[
|
||||
ClientEvent::EchoReply { .. },
|
||||
ClientEvent::SessionClosed { .. }
|
||||
]
|
||||
));
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
||||
0
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hmac_open_success() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (request, peer) = recv_request(&socket, &tx);
|
||||
verify_packet_hmac(&key, &request).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&missing, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&bad, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let mut returned = params_from_config(&config).unwrap();
|
||||
returned.interval_ns += 1;
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let cleanup = &packets[1];
|
||||
assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, cleanup).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_close_packet_includes_valid_hmac() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let close = &packets[1];
|
||||
assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, close).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(close[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_hmac_reply_is_dropped() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
let seq = u32::from_le_bytes(
|
||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&ts,
|
||||
Some(&wrong_key),
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
hmac_key: Some(key),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], ClientEvent::Warning { .. }));
|
||||
assert!(!client.is_peer_closed());
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_echo_request_reply_works() {
|
||||
let key = b"testkey".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
verify_packet_hmac(&server_key, &buf[..size]).unwrap();
|
||||
let seq = u32::from_le_bytes(
|
||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields {
|
||||
recv_mono: Some(100),
|
||||
send_mono: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, Some(&server_key));
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
hmac_key: Some(key),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], ClientEvent::EchoReply { .. }));
|
||||
server.join();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
|
||||
use irtt_proto::{
|
||||
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock,
|
||||
ReceivedStats, StampAt, HMAC_SIZE, MAGIC,
|
||||
};
|
||||
use std::{thread, time::SystemTime};
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
mod close;
|
||||
mod config;
|
||||
mod dscp;
|
||||
mod hmac;
|
||||
mod negotiation;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod probes_replies;
|
||||
mod sequence;
|
||||
mod ttl;
|
||||
@@ -1,289 +0,0 @@
|
||||
use super::*;
|
||||
use crate::NegotiationRestriction;
|
||||
|
||||
fn assert_negotiates(
|
||||
requested: &Params,
|
||||
returned: &Params,
|
||||
policy: NegotiationPolicy,
|
||||
) -> NegotiatedParams {
|
||||
negotiate_params(requested, returned.clone(), policy)
|
||||
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
|
||||
}
|
||||
|
||||
fn rejection_reason(requested: &Params, returned: &Params, policy: NegotiationPolicy) -> String {
|
||||
match negotiate_params(requested, returned.clone(), policy) {
|
||||
Err(ClientError::NegotiationRejected { reason }) => reason,
|
||||
other => panic!("expected negotiation rejection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_accepts_identical_params() {
|
||||
let config = ClientConfig::default();
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let negotiated = assert_negotiates(¶ms, ¶ms, NegotiationPolicy::Strict);
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert!(negotiated.restrictions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_rejects_changed_params() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
assert_eq!(
|
||||
requested.dscp, 184,
|
||||
"codepoint 46 must become raw wire byte 184"
|
||||
);
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 0;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::DscpChanged {
|
||||
requested: 46,
|
||||
negotiated: 0,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_accepts_server_restricted_params() {
|
||||
let config = ClientConfig::default();
|
||||
let mut requested = params_from_config(&config).unwrap();
|
||||
requested.length = 128;
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns /= 2;
|
||||
returned.length = 0;
|
||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![
|
||||
NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested.duration_ns,
|
||||
negotiated_ns: returned.duration_ns,
|
||||
},
|
||||
NegotiationRestriction::LengthReduced {
|
||||
requested: requested.length,
|
||||
negotiated: returned.length,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_non_positive_returned_interval() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
|
||||
for interval_ns in [0, -1] {
|
||||
let mut returned = requested.clone();
|
||||
returned.interval_ns = interval_ns;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"interval must be positive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_accepts_documented_duration_combinations() {
|
||||
let finite_config = ClientConfig::default();
|
||||
let requested_finite = params_from_config(&finite_config).unwrap();
|
||||
|
||||
let returned_same_finite = requested_finite.clone();
|
||||
assert!(assert_negotiates(
|
||||
&requested_finite,
|
||||
&returned_same_finite,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.restrictions
|
||||
.is_empty());
|
||||
|
||||
let mut returned_shorter_finite = requested_finite.clone();
|
||||
returned_shorter_finite.duration_ns /= 2;
|
||||
assert_eq!(
|
||||
assert_negotiates(
|
||||
&requested_finite,
|
||||
&returned_shorter_finite,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.restrictions,
|
||||
vec![NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested_finite.duration_ns,
|
||||
negotiated_ns: returned_shorter_finite.duration_ns,
|
||||
}]
|
||||
);
|
||||
|
||||
let continuous_config = ClientConfig {
|
||||
duration: None,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested_continuous = params_from_config(&continuous_config).unwrap();
|
||||
|
||||
let returned_continuous = requested_continuous.clone();
|
||||
assert!(assert_negotiates(
|
||||
&requested_continuous,
|
||||
&returned_continuous,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.restrictions
|
||||
.is_empty());
|
||||
|
||||
let mut returned_finite = requested_continuous.clone();
|
||||
returned_finite.duration_ns = 1_000_000_000;
|
||||
assert_eq!(
|
||||
assert_negotiates(
|
||||
&requested_continuous,
|
||||
&returned_finite,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.restrictions,
|
||||
vec![NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested_continuous.duration_ns,
|
||||
negotiated_ns: returned_finite.duration_ns,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_finite_request_returned_continuous() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = 0;
|
||||
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned continuous duration for finite request"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_negative_returned_length() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.length = -1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"length must be non-negative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_rejects_negative_returned_length() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.length = -1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
"length must be non-negative"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
assert_eq!(
|
||||
requested.dscp, 184,
|
||||
"codepoint 46 must become raw wire byte 184"
|
||||
);
|
||||
|
||||
for dscp in [-1, 256] {
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = dscp;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"dscp must be in range 0..=255"
|
||||
);
|
||||
}
|
||||
|
||||
// A raw returned value of 184 must be accepted even though it exceeds the
|
||||
// 0..=63 codepoint range; it is the correctly negotiated wire value for
|
||||
// codepoint 46 and is not subject to the codepoint bound at all.
|
||||
let returned = requested.clone();
|
||||
assert!(
|
||||
assert_negotiates(&requested, &returned, NegotiationPolicy::Loose)
|
||||
.restrictions
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_records_dscp_disabled_by_server() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 0;
|
||||
|
||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
||||
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![NegotiationRestriction::DscpChanged {
|
||||
requested: 46,
|
||||
negotiated: 0,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_rejects_dscp_disabled_by_server_as_specific_restriction() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 0;
|
||||
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::DscpChanged {
|
||||
requested: 46,
|
||||
negotiated: 0,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negotiation_rejects_unsupported_dscp_changes() {
|
||||
let config = ClientConfig {
|
||||
dscp: 46,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 8;
|
||||
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned unsupported DSCP change"
|
||||
);
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
"server returned unsupported DSCP change"
|
||||
);
|
||||
|
||||
let zero_config = ClientConfig::default();
|
||||
let zero_requested = params_from_config(&zero_config).unwrap();
|
||||
let mut returned = zero_requested.clone();
|
||||
returned.dscp = 46;
|
||||
|
||||
assert_eq!(
|
||||
rejection_reason(&zero_requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned unsupported DSCP change"
|
||||
);
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn successful_open_handshake() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params.clone());
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
let negotiated = assert_open_started(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_open_restores_configured_receive_timeout() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params);
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.recv_timeout = Some(Duration::from_millis(75));
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
client.test_hooks.last_restored_read_timeout.get(),
|
||||
Some(Duration::from_millis(75))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_when_already_open() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_retries_after_first_timeout() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let (first, _) = recv_request(&socket, &tx);
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let params = Params::decode(&first[4..]).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
assert_open_started(outcome);
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_survives_interrupted_receive_within_same_attempt() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let (first, peer) = recv_request(&socket, &tx);
|
||||
let params = Params::decode(&first[4..]).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(500)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
// Several interrupted receive attempts before the real reply arrives
|
||||
// must not be treated as a timeout, must not retransmit the open
|
||||
// request, and must not advance to a next configured attempt.
|
||||
client.test_hooks.inject_recv_interrupted.set(5);
|
||||
|
||||
let outcome = client.open().unwrap();
|
||||
assert_open_started(outcome);
|
||||
// Exactly one request reached the server: interruptions were absorbed
|
||||
// inside the single configured attempt.
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_interrupted_receive_does_not_extend_the_attempt_deadline() {
|
||||
// A fake server that never replies: with a short attempt timeout, the
|
||||
// real question is whether interruptions can push the effective wait
|
||||
// past the configured deadline. They must not: the deadline stays
|
||||
// absolute across any number of interruptions.
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
// Each interruption carries no timing information of its own, so the
|
||||
// loop must keep re-checking the real deadline rather than treating an
|
||||
// interruption as "more time available" or a reason to fail outright.
|
||||
client.test_hooks.inject_recv_interrupted.set(20);
|
||||
|
||||
let started = Instant::now();
|
||||
let result = client.open();
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(matches!(result, Err(ClientError::OpenTimeout)));
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(800),
|
||||
"interrupted receives must not extend the attempt deadline, took {elapsed:?}"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_timeout_after_all_timeouts() {
|
||||
let server = timeout_server(Duration::from_millis(700));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert_eq!(
|
||||
client.test_hooks.last_restored_read_timeout.get(),
|
||||
Some(Duration::from_millis(50))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_version_mismatch_fails() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let mut params = params_from_config(&config).unwrap();
|
||||
params.protocol_version = 2;
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_rejection_fails_in_normal_mode() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let mut wrong_type = valid.clone();
|
||||
wrong_type[3] = FLAG_REPLY;
|
||||
let mut reserved_flags = valid.clone();
|
||||
reserved_flags[3] |= 0x10;
|
||||
let mut bad_magic = valid.clone();
|
||||
bad_magic[0] ^= 0xff;
|
||||
let unexpected_hmac =
|
||||
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(b"unexpected"));
|
||||
|
||||
for packet in [
|
||||
vec![0_u8],
|
||||
bad_magic,
|
||||
wrong_type,
|
||||
reserved_flags,
|
||||
unexpected_hmac,
|
||||
valid,
|
||||
] {
|
||||
socket.send_to(&packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
assert!(client.schedule.is_some());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(250), Duration::from_millis(250)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let started = Instant::now();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
let requests: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(700),
|
||||
"ignored datagrams restarted an attempt deadline"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ignored_open_datagrams_eventually_time_out() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
for _ in 0..2 {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
socket.send_to(&MAGIC, peer).unwrap();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
client.test_hooks.last_restored_read_timeout.get(),
|
||||
Some(Duration::from_millis(50))
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_zero_token_normal_reply_is_terminal() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
|
||||
// deliberately non-compliant reply is built by encoding a normal
|
||||
// reply with a placeholder token and then zeroing the token field.
|
||||
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_deadline_overflow_occurs_before_send() {
|
||||
let server = timeout_server(Duration::from_millis(250));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::MAX],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::DurationOverflow)));
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_duration_overflow_cannot_partially_open() {
|
||||
let server = start_fake_server(|_socket, _tx| {});
|
||||
let client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
let reply = irtt_proto::OpenReply {
|
||||
flags: FLAG_OPEN | FLAG_REPLY,
|
||||
token: TOKEN,
|
||||
params: params_from_config(client.runtime.config()).unwrap(),
|
||||
};
|
||||
let mut latest = Instant::now();
|
||||
let mut step = Duration::MAX;
|
||||
while !step.is_zero() {
|
||||
if let Some(candidate) = latest.checked_add(step) {
|
||||
latest = candidate;
|
||||
}
|
||||
step /= 2;
|
||||
}
|
||||
let opened_at = ClientTimestamp {
|
||||
mono: latest,
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let machine = client
|
||||
.runtime
|
||||
.prepare_open_acceptance(reply, opened_at)
|
||||
.unwrap();
|
||||
let previous_recv_len = client.recv_buffer.len();
|
||||
|
||||
let failure = client.prepare_client_open(machine, opened_at).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
failure.primary,
|
||||
ClientError::NegotiationRejected { .. }
|
||||
));
|
||||
assert!(failure.machine.cleanup_close_packet().is_some());
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
assert_eq!(client.recv_buffer.len(), previous_recv_len);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_negotiation_failure_sends_cleanup_close() {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_send_failure_does_not_replace_primary_open_error() {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_cleanup_send.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_buffer_reservation_failure_precedes_dscp_application() {
|
||||
let mut returned = default_params();
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF), matching
|
||||
// what the client below requests so negotiation succeeds unchanged.
|
||||
returned.dscp = 184;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
client
|
||||
.test_hooks
|
||||
.recv_buffer_len_override
|
||||
.set(Some(usize::MAX));
|
||||
client.test_hooks.fail_open_dscp.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::AllocationFailed {
|
||||
operation: "negotiated receive buffer",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(client.test_hooks.fail_open_dscp.get());
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dscp_application_failure_leaves_machine_connected() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_open_dscp.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::SocketOption {
|
||||
operation: "set negotiated DSCP",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(client.test_hooks.prepared_active_session_before_dscp.get());
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_timeout_restoration_failure_leaves_machine_connected() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_open_timeout_restore.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::ReadTimeoutRestore { .. })
|
||||
));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dscp_rollback_failure_keeps_timeout_restoration_primary() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.test_hooks.fail_open_timeout_restore.set(true);
|
||||
client.test_hooks.fail_dscp_restore.set(true);
|
||||
|
||||
let error = client.open().unwrap_err();
|
||||
|
||||
assert!(matches!(error, ClientError::ReadTimeoutRestore { .. }));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// An interrupted receive (`io::ErrorKind::Interrupted`, e.g. `EINTR`) must be
|
||||
/// invisible to the caller: retried transparently, with the eventual reply
|
||||
/// classified exactly as if no interruption had occurred.
|
||||
#[test]
|
||||
fn interrupted_receive_is_retried_and_the_reply_is_still_classified() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
let mut buf = [0_u8; 2048];
|
||||
let (size, _) = socket.recv_from(&mut buf).unwrap();
|
||||
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
|
||||
let _ = size;
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, None);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_secs(2)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
client.test_hooks.inject_recv_interrupted.set(10);
|
||||
let events = client.recv_once().unwrap();
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(
|
||||
matches!(&events[0], ClientEvent::EchoReply { seq: 0, .. }),
|
||||
"expected a normal EchoReply despite interrupted receive attempts, got {:?}",
|
||||
events[0]
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
/// One logical receive keeps one timeout budget: repeated interruptions must
|
||||
/// not extend a `recv_once` call past its configured receive timeout.
|
||||
#[test]
|
||||
fn interrupted_receive_does_not_extend_the_configured_timeout() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(100)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
// No reply is ever coming; only interrupted attempts occur before the
|
||||
// real socket takes over and legitimately times out.
|
||||
client.test_hooks.inject_recv_interrupted.set(20);
|
||||
let started = Instant::now();
|
||||
let events = client.recv_once().unwrap();
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(events.is_empty());
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(500),
|
||||
"interrupted receives must not extend the configured receive timeout, took {elapsed:?}"
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn probe_timestamps(permission_at: Instant, sent_at: ClientTimestamp) -> ProbeSendTimestamps {
|
||||
ProbeSendTimestamps {
|
||||
permission_at,
|
||||
send_anchor: sent_at,
|
||||
sent_at,
|
||||
send_call_start: sent_at.mono,
|
||||
send_finished_at: sent_at.mono,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_fails_before_open() {
|
||||
let server = start_fake_server(|_socket, _tx| {});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(client.send_probe(), Err(ClientError::NotOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_sends_valid_echo_request() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
assert_open_started(client.open().unwrap());
|
||||
let events = client.send_probe().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
ClientEvent::EchoSent {
|
||||
seq, remote, bytes, ..
|
||||
} => {
|
||||
assert_eq!(*seq, 0);
|
||||
assert_eq!(*remote, server.addr);
|
||||
assert_eq!(*bytes, echo_packet_len(false, ¶ms));
|
||||
}
|
||||
other => panic!("expected EchoSent, got {other:?}"),
|
||||
}
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let packets: Vec<_> = server.rx.try_iter().collect();
|
||||
let echo_reqs: Vec<_> = packets
|
||||
.iter()
|
||||
.filter(|p| p.len() >= 4 && p[3] & FLAG_OPEN == 0)
|
||||
.collect();
|
||||
let echo_req = echo_reqs.first().unwrap();
|
||||
assert_eq!(&echo_req[..3], &MAGIC);
|
||||
assert_eq!(echo_req[3], 0x00);
|
||||
let req_token = u64::from_le_bytes(echo_req[4..12].try_into().unwrap());
|
||||
assert_eq!(req_token, TOKEN);
|
||||
let seq = u32::from_le_bytes(echo_req[12..16].try_into().unwrap());
|
||||
assert_eq!(seq, 0);
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_short_probe_commits_before_presentation_length_error() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params.clone());
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let expected = echo_packet_len(false, ¶ms);
|
||||
let initial_deadline = client.next_send_deadline().unwrap();
|
||||
client.probe_reported_len = Some(expected - 1);
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::DatagramLengthMismatch {
|
||||
expected: error_expected,
|
||||
actual,
|
||||
}) if error_expected == expected && actual + 1 == expected
|
||||
));
|
||||
assert_eq!(client.runtime.packets_sent(), 1);
|
||||
assert_eq!(
|
||||
client.next_send_deadline(),
|
||||
Some(initial_deadline + client.runtime.config().interval)
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_failed_probe_send_preserves_machine_and_schedule() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let initial_deadline = client.next_send_deadline().unwrap();
|
||||
client.probe_send_error = true;
|
||||
|
||||
assert!(matches!(client.send_probe(), Err(ClientError::Socket(_))));
|
||||
assert_eq!(client.runtime.packets_sent(), 0);
|
||||
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe().unwrap().as_slice(),
|
||||
[ClientEvent::EchoSent { seq: 0, .. }]
|
||||
));
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_timeout_finalization_overflow_does_not_send_or_commit() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::MAX,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let initial_deadline = client.next_send_deadline().unwrap();
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe_at(probe_timestamps(initial_deadline, sent_at)),
|
||||
Err(ClientError::DurationOverflow)
|
||||
));
|
||||
assert_eq!(client.runtime.packets_sent(), 0);
|
||||
assert_eq!(client.next_send_deadline(), Some(initial_deadline));
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
assert!(server
|
||||
.rx
|
||||
.try_iter()
|
||||
.all(|packet| packet.len() >= 4 && packet[3] & FLAG_OPEN != 0));
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_probe_separates_permission_committed_send_and_send_call_timing() {
|
||||
let duration = Duration::from_secs(1);
|
||||
let interval = Duration::from_millis(100);
|
||||
let params = Params {
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
..default_params()
|
||||
};
|
||||
let server = silent_open_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
duration: Some(duration),
|
||||
interval,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let opened_mono = Instant::now()
|
||||
.checked_sub(Duration::from_secs(2))
|
||||
.expect("test host monotonic clock has at least two seconds of history");
|
||||
client.schedule = Some(
|
||||
ProbeSchedule::new(
|
||||
opened_mono,
|
||||
&NegotiatedParams {
|
||||
params,
|
||||
restrictions: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let permission_at = opened_mono + Duration::from_millis(500);
|
||||
// Distinct pre-send anchor and post-send `sent_at`: the anchor drives
|
||||
// the operational timeout deadline, while `sent_at` (captured after the
|
||||
// send call) is the public measurement/timer_error endpoint. They are
|
||||
// deliberately decoupled by this PR.
|
||||
let send_anchor = ClientTimestamp {
|
||||
wall: SystemTime::now(),
|
||||
mono: opened_mono + Duration::from_millis(525),
|
||||
};
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::now(),
|
||||
mono: opened_mono + Duration::from_millis(534),
|
||||
};
|
||||
let timestamps = ProbeSendTimestamps {
|
||||
permission_at,
|
||||
send_anchor,
|
||||
sent_at,
|
||||
send_call_start: opened_mono + Duration::from_millis(526),
|
||||
send_finished_at: opened_mono + Duration::from_millis(533),
|
||||
};
|
||||
|
||||
let events = client.send_probe_at(timestamps).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ClientEvent::EchoSent {
|
||||
scheduled_at,
|
||||
sent_at: event_sent_at,
|
||||
send_call,
|
||||
timer_error,
|
||||
..
|
||||
}] if *scheduled_at == opened_mono
|
||||
&& *event_sent_at == sent_at
|
||||
&& *send_call == Duration::from_millis(7)
|
||||
&& *timer_error == Duration::from_millis(534)
|
||||
));
|
||||
assert_eq!(client.next_send_deadline(), Some(opened_mono + interval));
|
||||
assert_eq!(client.runtime.packets_sent(), 1);
|
||||
// The timeout deadline is anchored to the pre-send `send_anchor`, not
|
||||
// to the post-send `sent_at` captured 9ms later.
|
||||
let timeout_at = send_anchor.mono + client.probe_timeout();
|
||||
assert_ne!(timeout_at, sent_at.mono + client.probe_timeout());
|
||||
assert!(client
|
||||
.poll_timeouts_at(timeout_at - Duration::from_nanos(1))
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
assert!(matches!(
|
||||
client.poll_timeouts_at(timeout_at).unwrap().as_slice(),
|
||||
[ClientEvent::EchoLoss {
|
||||
sent_at: loss_sent_at,
|
||||
..
|
||||
}] if *loss_sent_at == sent_at
|
||||
));
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_send_call_excludes_wrapped_history_cleanup() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let scheduled_at = client.next_send_deadline().unwrap();
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::now(),
|
||||
mono: scheduled_at + Duration::from_millis(5),
|
||||
};
|
||||
client.runtime.seed_wrapped_probe_history_for_test(sent_at);
|
||||
let timestamps = ProbeSendTimestamps {
|
||||
permission_at: scheduled_at,
|
||||
send_anchor: sent_at,
|
||||
sent_at,
|
||||
send_call_start: scheduled_at + Duration::from_millis(6),
|
||||
send_finished_at: scheduled_at + Duration::from_millis(13),
|
||||
};
|
||||
|
||||
let events = client.send_probe_at(timestamps).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ClientEvent::EchoSent {
|
||||
seq: 0,
|
||||
send_call,
|
||||
..
|
||||
}] if *send_call == Duration::from_millis(7)
|
||||
));
|
||||
assert!(!client.runtime.has_timed_out_metadata());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_respects_finite_duration_exclusive_end() {
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 500_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = silent_open_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_secs(1)),
|
||||
interval: Duration::from_millis(500),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let start = client.next_send_deadline().unwrap();
|
||||
let interval = Duration::from_millis(500);
|
||||
|
||||
let now0 = ClientTimestamp {
|
||||
mono: start,
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
assert!(client
|
||||
.send_probe_at(probe_timestamps(now0.mono, now0))
|
||||
.is_ok());
|
||||
|
||||
let now1 = ClientTimestamp {
|
||||
mono: start + interval,
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
assert!(client
|
||||
.send_probe_at(probe_timestamps(now1.mono, now1))
|
||||
.is_ok());
|
||||
|
||||
let now2 = ClientTimestamp {
|
||||
mono: start + Duration::from_secs(1),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let events = client
|
||||
.send_probe_at(probe_timestamps(now2.mono, now2))
|
||||
.unwrap();
|
||||
assert!(events.is_empty());
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_probe_skips_missed_schedule_slots() {
|
||||
let interval = Duration::from_millis(10);
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 0,
|
||||
interval_ns: 10_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = echo_server(params);
|
||||
let config = ClientConfig {
|
||||
duration: None,
|
||||
interval,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let first_deadline = client.next_send_deadline().unwrap();
|
||||
let permission_at = first_deadline + Duration::from_millis(45);
|
||||
let delayed_send = ClientTimestamp {
|
||||
mono: first_deadline + Duration::from_millis(48),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let timestamps = ProbeSendTimestamps {
|
||||
permission_at,
|
||||
send_anchor: delayed_send,
|
||||
sent_at: delayed_send,
|
||||
send_call_start: first_deadline + Duration::from_millis(49),
|
||||
send_finished_at: first_deadline + Duration::from_millis(55),
|
||||
};
|
||||
let events = client
|
||||
.send_managed_probe_at(first_deadline, timestamps)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(
|
||||
events[0],
|
||||
ClientEvent::EchoSent {
|
||||
seq: 0,
|
||||
scheduled_at,
|
||||
sent_at,
|
||||
send_call,
|
||||
timer_error,
|
||||
..
|
||||
} if scheduled_at == first_deadline + Duration::from_millis(40)
|
||||
&& sent_at == delayed_send
|
||||
&& send_call == Duration::from_millis(6)
|
||||
&& timer_error == Duration::from_millis(8)
|
||||
));
|
||||
assert_eq!(
|
||||
client.next_send_deadline(),
|
||||
Some(first_deadline + Duration::from_millis(50))
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_probe_skip_preserves_finite_run_end() {
|
||||
let interval = Duration::from_millis(10);
|
||||
let duration = Duration::from_millis(50);
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 50_000_000,
|
||||
interval_ns: 10_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = echo_server(params);
|
||||
let config = ClientConfig {
|
||||
duration: Some(duration),
|
||||
interval,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
|
||||
let first_deadline = client.next_send_deadline().unwrap();
|
||||
let delayed_send = ClientTimestamp {
|
||||
mono: first_deadline + Duration::from_millis(45),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let first = client
|
||||
.send_managed_probe_at(
|
||||
first_deadline,
|
||||
probe_timestamps(delayed_send.mono, delayed_send),
|
||||
)
|
||||
.unwrap();
|
||||
let second_sent_at = ClientTimestamp {
|
||||
mono: first_deadline + Duration::from_millis(46),
|
||||
wall: SystemTime::now(),
|
||||
};
|
||||
let second = client
|
||||
.send_managed_probe_at(
|
||||
first_deadline + duration,
|
||||
probe_timestamps(second_sent_at.mono, second_sent_at),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.len(), 1);
|
||||
assert!(second.is_empty());
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_wait_is_bounded_by_next_ten_millisecond_deadline() {
|
||||
let now = Instant::now();
|
||||
let deadline = now + Duration::from_millis(10);
|
||||
|
||||
assert_eq!(
|
||||
bounded_receive_timeout(
|
||||
Some(deadline),
|
||||
Some(Duration::from_millis(20)),
|
||||
Duration::from_millis(20),
|
||||
now,
|
||||
),
|
||||
Some(Duration::from_millis(10))
|
||||
);
|
||||
assert_eq!(
|
||||
bounded_receive_timeout(
|
||||
Some(now),
|
||||
Some(Duration::from_millis(20)),
|
||||
Duration::from_millis(20),
|
||||
now,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_probe_limits() {
|
||||
for config in [
|
||||
ClientConfig {
|
||||
max_pending_probes: 0,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
ClientConfig {
|
||||
probe_timeout: Duration::ZERO,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
use super::*;
|
||||
use crate::metadata::ReceiveMeta;
|
||||
|
||||
#[test]
|
||||
fn server_processing_greater_than_raw_does_not_underflow() {
|
||||
let base = Instant::now();
|
||||
let rtt = compute_rtt(
|
||||
&ClientTimestamp {
|
||||
mono: base,
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&ClientTimestamp {
|
||||
mono: base + Duration::from_nanos(1),
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&TimestampFields {
|
||||
recv_mono: Some(0),
|
||||
send_mono: Some(1_000_000_000),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(rtt.adjusted, Some(SignedDuration::from_nanos(-999_999_999)));
|
||||
assert_eq!(rtt.effective, SignedDuration::from_nanos(-999_999_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_rtt_uses_raw_when_adjusted_rtt_is_unavailable() {
|
||||
let base = Instant::now();
|
||||
let rtt = compute_rtt(
|
||||
&ClientTimestamp {
|
||||
mono: base,
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&ClientTimestamp {
|
||||
mono: base + Duration::from_nanos(42),
|
||||
wall: SystemTime::now(),
|
||||
},
|
||||
&TimestampFields::default(),
|
||||
);
|
||||
|
||||
assert_eq!(rtt.adjusted, None);
|
||||
assert_eq!(rtt.effective, SignedDuration::from_duration(rtt.raw));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_epoch_ns_i64_rejects_out_of_range_wall_time() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let max = SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns);
|
||||
let overflow = max + Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(unix_epoch_ns_i64(max), Some(i64::MAX));
|
||||
assert_eq!(unix_epoch_ns_i64(overflow), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_omits_samples_when_client_wall_time_overflows_i64() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let overflow_wall =
|
||||
SystemTime::UNIX_EPOCH + Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: overflow_wall,
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: overflow_wall + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(i64::MAX),
|
||||
send_wall: Some(i64::MAX),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_returns_available_direction_samples() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_preserves_negative_client_to_server_delay() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 - 5_000_000),
|
||||
send_wall: Some(10_000_000_000 + 25_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(-5_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_one_way_preserves_negative_server_to_client_delay() {
|
||||
let sent_at = ClientTimestamp {
|
||||
mono: Instant::now(),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10),
|
||||
};
|
||||
let received_at = ClientTimestamp {
|
||||
mono: sent_at.mono + Duration::from_millis(40),
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(10) + Duration::from_millis(40),
|
||||
};
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(10_000_000_000 + 15_000_000),
|
||||
send_wall: Some(10_000_000_000 + 45_000_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sample = compute_one_way(
|
||||
&sent_at,
|
||||
sent_at.wall,
|
||||
&received_at,
|
||||
None,
|
||||
&ReceiveMeta::default(),
|
||||
&ts,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sample.client_to_server,
|
||||
Some(SignedDuration::from_nanos(15_000_000))
|
||||
);
|
||||
assert_eq!(
|
||||
sample.server_to_client,
|
||||
Some(SignedDuration::from_nanos(-5_000_000))
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sequence_ordering_handles_normal_and_wrapped_values() {
|
||||
assert!(sequence_is_after(11, 10));
|
||||
assert!(sequence_is_before(9, 10));
|
||||
assert!(!sequence_is_after(10, 10));
|
||||
assert!(!sequence_is_before(10, 10));
|
||||
|
||||
assert!(sequence_is_after(0, u32::MAX - 1));
|
||||
assert!(!sequence_is_before(0, u32::MAX - 1));
|
||||
assert!(sequence_is_after(1, u32::MAX));
|
||||
assert!(!sequence_is_before(1, u32::MAX));
|
||||
|
||||
assert!(sequence_is_before(u32::MAX, 1));
|
||||
assert!(!sequence_is_after(u32::MAX, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_received_seq_updates_across_wrap() {
|
||||
let mut highest = Some(u32::MAX);
|
||||
|
||||
update_highest_received(&mut highest, 0);
|
||||
assert_eq!(highest, Some(0));
|
||||
update_highest_received(&mut highest, u32::MAX);
|
||||
assert_eq!(highest, Some(0));
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use crate::socket_options::socket_ttl;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_accepts_valid_ttl_config_values() {
|
||||
for ttl in [None, Some(1), Some(255)] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = ttl;
|
||||
Client::connect(config).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
|
||||
let params = Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = open_success_server(params.clone());
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.ttl = Some(64);
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
||||
|
||||
client.open().unwrap();
|
||||
server.join();
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_ttl_values() {
|
||||
for ttl in [0, 256] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = Some(ttl);
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use crate::Authentication;
|
||||
|
||||
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
|
||||
/// bytes.
|
||||
///
|
||||
@@ -85,12 +87,18 @@ pub struct ClientConfig {
|
||||
/// `None` requests continuous mode and is encoded on the wire as a zero
|
||||
/// duration. Use [`RunMode::NoTest`] when the caller wants negotiation only
|
||||
/// without sending probes.
|
||||
///
|
||||
/// The managed driver stops sending after the negotiated duration.
|
||||
/// Low-level adapters leave run duration to the caller.
|
||||
pub duration: Option<Duration>,
|
||||
/// Requested spacing between probe sends.
|
||||
///
|
||||
/// The interval is encoded as nanoseconds in the open request and must be
|
||||
/// greater than zero. The server may return a different interval depending
|
||||
/// on the negotiated policy and server restrictions.
|
||||
///
|
||||
/// The managed driver uses the negotiated interval for cadence.
|
||||
/// Low-level adapters send whenever called.
|
||||
pub interval: Duration,
|
||||
/// Requested echo packet payload length, in bytes.
|
||||
///
|
||||
@@ -112,11 +120,10 @@ pub struct ClientConfig {
|
||||
/// This is the six-bit DSCP value, not the full traffic-class byte, and
|
||||
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
|
||||
pub dscp: u8,
|
||||
/// Optional HMAC key used to authenticate IRTT packets.
|
||||
/// Concrete authentication for open, echo, and close packets.
|
||||
///
|
||||
/// When present, open, echo, and close packets are encoded and decoded with
|
||||
/// HMAC authentication. The peer must be configured with the same key.
|
||||
pub hmac_key: Option<Vec<u8>>,
|
||||
/// HMAC peers must use the same key. An empty HMAC key remains authenticated.
|
||||
pub auth: Authentication,
|
||||
/// Optional server payload fill request.
|
||||
///
|
||||
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
|
||||
@@ -149,30 +156,6 @@ pub struct ClientConfig {
|
||||
pub max_pending_probes: usize,
|
||||
}
|
||||
|
||||
/// Authentication settings used by client sessions.
|
||||
///
|
||||
/// `ClientConfig` keeps its existing top-level HMAC field for source
|
||||
/// compatibility. Managed multi-target callers can use this smaller type for
|
||||
/// per-target auth overrides without carrying a full independent
|
||||
/// [`ClientConfig`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ClientAuthConfig {
|
||||
/// Optional HMAC key used to authenticate IRTT packets.
|
||||
pub hmac_key: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl From<Option<Vec<u8>>> for ClientAuthConfig {
|
||||
fn from(hmac_key: Option<Vec<u8>>) -> Self {
|
||||
Self { hmac_key }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientAuthConfig> for Option<Vec<u8>> {
|
||||
fn from(auth: ClientAuthConfig) -> Self {
|
||||
auth.hmac_key
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -184,7 +167,7 @@ impl Default for ClientConfig {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 0,
|
||||
hmac_key: None,
|
||||
auth: Authentication::Unauthenticated,
|
||||
server_fill: None,
|
||||
open_timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
|
||||
run_mode: RunMode::Normal,
|
||||
@@ -231,6 +214,15 @@ pub struct SocketConfig {
|
||||
/// `None` binds to an unspecified address with an ephemeral port matching
|
||||
/// the selected remote address family.
|
||||
pub bind_addr: Option<SocketAddr>,
|
||||
/// Network interface to bind this socket to (`SO_BINDTODEVICE`).
|
||||
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||
pub bind_to_device: Option<String>,
|
||||
/// Firewall mark applied to outgoing packets (`SO_MARK`).
|
||||
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||
pub mark: Option<u32>,
|
||||
/// FreeBSD FIB used for routing this socket (`SO_SETFIB`).
|
||||
#[cfg(target_os = "freebsd")]
|
||||
pub fib: Option<u32>,
|
||||
/// Optional IPv4 TTL or IPv6 hop limit applied to sent packets.
|
||||
///
|
||||
/// `None` leaves the platform default unchanged. Values must fit in the
|
||||
@@ -264,7 +256,8 @@ pub enum NegotiationPolicy {
|
||||
/// Mode requested during the IRTT open exchange.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RunMode {
|
||||
/// Open a session and send echo probes for the negotiated run duration.
|
||||
/// Open a probe session. The managed driver runs for the negotiated duration;
|
||||
/// low-level adapters leave sending and stopping to the caller.
|
||||
Normal,
|
||||
/// Complete negotiation without running the echo probe test.
|
||||
NoTest,
|
||||
|
||||
@@ -92,8 +92,9 @@ pub enum ClientEvent {
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Monotonic deadline at which the probe was scheduled to be sent.
|
||||
scheduled_at: Instant,
|
||||
/// Intended send deadline, supplied by the managed runner.
|
||||
/// Low-level adapters leave this absent.
|
||||
scheduled_at: Option<Instant>,
|
||||
/// Paired client wall/monotonic timestamp captured immediately after
|
||||
/// the successful UDP send completed. This is the RTT and userspace
|
||||
/// upstream one-way-delay fallback endpoint. The operational probe
|
||||
@@ -106,8 +107,8 @@ pub enum ClientEvent {
|
||||
/// scheduler commit work.
|
||||
send_call: Duration,
|
||||
/// Absolute difference between the scheduled send time and the
|
||||
/// post-send `sent_at` timestamp above.
|
||||
timer_error: Duration,
|
||||
/// post-send `sent_at` timestamp above; absent for low-level sends.
|
||||
timer_error: Option<Duration>,
|
||||
},
|
||||
|
||||
/// First in-window reply for a pending echo request.
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
//! wants full control over its own event loop, should start from. With the
|
||||
//! `tokio` feature, `AsyncClient` provides the corresponding low-level Tokio
|
||||
//! adapter for a caller that owns a runtime and drives socket readiness
|
||||
//! itself. [`managed`] sits above both: `ManagedClientTask` /
|
||||
//! itself. Both adapters send one probe whenever called; callers own cadence
|
||||
//! and run duration, using `negotiated_params()` after Open. [`managed`] owns
|
||||
//! negotiated-interval scheduling, finite runs, and staggered/burst pacing:
|
||||
//! `ManagedClientTask` /
|
||||
//! `ManagedClientHandle` (`tokio` feature) run and control one or more
|
||||
//! targets under a Tokio runtime, and `BlockingManagedClient` (`tokio`
|
||||
//! feature) wraps that in a synchronous owner backed by its own dedicated
|
||||
@@ -31,6 +34,7 @@
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
mod async_client;
|
||||
mod auth;
|
||||
mod client;
|
||||
mod config;
|
||||
mod error;
|
||||
@@ -46,10 +50,11 @@ mod timing;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use async_client::AsyncClient;
|
||||
pub use auth::{Authentication, HmacKey};
|
||||
pub use client::Client;
|
||||
pub use config::{
|
||||
ClientAuthConfig, ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig,
|
||||
MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
|
||||
ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT,
|
||||
MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
|
||||
};
|
||||
pub use error::ClientError;
|
||||
pub use event::{
|
||||
|
||||
@@ -157,22 +157,14 @@ impl BlockingManagedClient {
|
||||
}
|
||||
|
||||
fn join_worker(&mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
||||
join_worker(
|
||||
self.worker
|
||||
.take()
|
||||
.expect("blocking managed worker is joined at most once"),
|
||||
)
|
||||
self.worker
|
||||
.take()
|
||||
.expect("blocking managed worker is joined at most once")
|
||||
.join()
|
||||
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn join_worker(
|
||||
worker: JoinHandle<ManagedOutcome>,
|
||||
) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
||||
worker
|
||||
.join()
|
||||
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
|
||||
}
|
||||
|
||||
impl Drop for BlockingManagedClient {
|
||||
fn drop(&mut self) {
|
||||
if self.worker.is_some() {
|
||||
|
||||
@@ -16,5 +16,5 @@ pub use model::*;
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use task::*;
|
||||
|
||||
#[cfg(all(feature = "tokio", test))]
|
||||
mod blocking_tests;
|
||||
#[cfg(feature = "tokio")]
|
||||
mod schedule;
|
||||
@@ -2,7 +2,7 @@ use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{ClientAuthConfig, ClientConfig, ClientError, ClientEvent};
|
||||
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
|
||||
|
||||
use super::TargetId;
|
||||
|
||||
@@ -26,12 +26,34 @@ pub struct TargetInstance {
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Endpoint and optional authentication override for one managed target.
|
||||
/// Authentication specification for a managed target.
|
||||
///
|
||||
/// Equality preserves the specification: inheritance and an explicit override
|
||||
/// remain distinct even when they currently resolve to the same authentication.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub enum TargetAuth {
|
||||
/// Use the shared client configuration's authentication.
|
||||
#[default]
|
||||
Inherit,
|
||||
/// Replace shared authentication. `Unauthenticated` explicitly disables it.
|
||||
Override(Authentication),
|
||||
}
|
||||
|
||||
impl TargetAuth {
|
||||
pub(crate) fn resolve(&self, shared: &Authentication) -> Authentication {
|
||||
match self {
|
||||
Self::Inherit => shared.clone(),
|
||||
Self::Override(auth) => auth.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Endpoint and authentication specification for one managed target.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedTargetConfig {
|
||||
pub id: TargetId,
|
||||
pub server_addr: String,
|
||||
pub auth: Option<ClientAuthConfig>,
|
||||
pub auth: TargetAuth,
|
||||
}
|
||||
|
||||
impl ManagedTargetConfig {
|
||||
@@ -39,7 +61,7 @@ impl ManagedTargetConfig {
|
||||
Self {
|
||||
id: id.into(),
|
||||
server_addr: server_addr.into(),
|
||||
auth: None,
|
||||
auth: TargetAuth::Inherit,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,199 +471,3 @@ pub(crate) fn classify_client_error(
|
||||
message: Arc::from(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::TryReserveError, io};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn io_error() -> io::Error {
|
||||
io::Error::other("boom")
|
||||
}
|
||||
|
||||
fn try_reserve_error() -> TryReserveError {
|
||||
Vec::<u8>::new().try_reserve(usize::MAX).unwrap_err()
|
||||
}
|
||||
|
||||
fn classification_cases() -> Vec<(ClientError, ManagedTargetFailureKind)> {
|
||||
vec![
|
||||
(
|
||||
ClientError::Resolve {
|
||||
addr: "example.invalid:2112".to_owned(),
|
||||
},
|
||||
ManagedTargetFailureKind::Resolve,
|
||||
),
|
||||
(
|
||||
ClientError::Socket(io_error()),
|
||||
ManagedTargetFailureKind::Socket,
|
||||
),
|
||||
(
|
||||
ClientError::DatagramLengthMismatch {
|
||||
expected: 64,
|
||||
actual: 32,
|
||||
},
|
||||
ManagedTargetFailureKind::Socket,
|
||||
),
|
||||
(
|
||||
ClientError::SocketOption {
|
||||
operation: "set read timeout",
|
||||
remote: "127.0.0.1:2112".parse().unwrap(),
|
||||
source: io_error(),
|
||||
},
|
||||
ManagedTargetFailureKind::SocketOption,
|
||||
),
|
||||
(
|
||||
ClientError::ReadTimeoutRestore { source: io_error() },
|
||||
ManagedTargetFailureKind::SocketOption,
|
||||
),
|
||||
(
|
||||
ClientError::Protocol(irtt_proto::ProtoError::BadMagic),
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::ProtocolVersionMismatch {
|
||||
requested: 1,
|
||||
received: 2,
|
||||
},
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(ClientError::ZeroToken, ManagedTargetFailureKind::Protocol),
|
||||
(
|
||||
ClientError::UnexpectedNoTestReply,
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::NonZeroNoTestToken { token: 7 },
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::ServerRejected,
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(
|
||||
ClientError::NegotiationRejected {
|
||||
reason: "duration".to_owned(),
|
||||
},
|
||||
ManagedTargetFailureKind::Protocol,
|
||||
),
|
||||
(ClientError::OpenTimeout, ManagedTargetFailureKind::Timeout),
|
||||
(
|
||||
ClientError::InvalidConfig {
|
||||
reason: "interval".to_owned(),
|
||||
},
|
||||
ManagedTargetFailureKind::Configuration,
|
||||
),
|
||||
(
|
||||
ClientError::OpenTimeoutTooSmall {
|
||||
timeout: Duration::from_millis(1),
|
||||
minimum: Duration::from_millis(10),
|
||||
},
|
||||
ManagedTargetFailureKind::Configuration,
|
||||
),
|
||||
(
|
||||
ClientError::NoOpenTimeouts,
|
||||
ManagedTargetFailureKind::Configuration,
|
||||
),
|
||||
(
|
||||
ClientError::AllocationFailed {
|
||||
operation: "pending probes",
|
||||
source: try_reserve_error(),
|
||||
},
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::CounterOverflow {
|
||||
counter: "packets_sent",
|
||||
},
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::DurationOverflow,
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::PendingLimitExceeded { limit: 8 },
|
||||
ManagedTargetFailureKind::ResourceExhausted,
|
||||
),
|
||||
(
|
||||
ClientError::NoTokioRuntime,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(ClientError::NotOpen, ManagedTargetFailureKind::InvalidState),
|
||||
(
|
||||
ClientError::AlreadyOpen,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::AlreadyCompleted,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::AlreadyClosed,
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::StalePreparedProbe {
|
||||
prepared_seq: 3,
|
||||
next_wire_seq: 4,
|
||||
},
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
(
|
||||
ClientError::PendingSequenceCollision { seq: 9 },
|
||||
ManagedTargetFailureKind::InvalidState,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_client_error_classifies_to_its_documented_kind() {
|
||||
for (error, expected) in classification_cases() {
|
||||
let failure = classify_client_error(ManagedTargetFailurePhase::Opening, &error);
|
||||
assert_eq!(
|
||||
failure.kind, expected,
|
||||
"unexpected classification for {error:?}"
|
||||
);
|
||||
assert_eq!(failure.phase, ManagedTargetFailurePhase::Opening);
|
||||
assert_eq!(&*failure.message, error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_preserves_the_reported_phase() {
|
||||
for phase in [
|
||||
ManagedTargetFailurePhase::Connecting,
|
||||
ManagedTargetFailurePhase::Opening,
|
||||
ManagedTargetFailurePhase::Sending,
|
||||
ManagedTargetFailurePhase::Receiving,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
ManagedTargetFailurePhase::Closing,
|
||||
] {
|
||||
let failure = classify_client_error(phase, &ClientError::OpenTimeout);
|
||||
assert_eq!(failure.phase, phase);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_and_kind_display_without_debug_spelling() {
|
||||
assert_eq!(
|
||||
ManagedTargetFailurePhase::Connecting.to_string(),
|
||||
"connecting"
|
||||
);
|
||||
assert_eq!(ManagedTargetFailurePhase::Closing.to_string(), "closing");
|
||||
assert_eq!(
|
||||
ManagedTargetFailureKind::SocketOption.to_string(),
|
||||
"socket option"
|
||||
);
|
||||
assert_eq!(
|
||||
ManagedTargetFailureKind::ResourceExhausted.to_string(),
|
||||
"resource exhausted"
|
||||
);
|
||||
assert_eq!(
|
||||
ManagedTargetFailureKind::InvalidState.to_string(),
|
||||
"invalid state"
|
||||
);
|
||||
assert_eq!(ManagedTargetFailureKind::Other.to_string(), "other");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{error::ClientError, session::NegotiatedParams};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProbeSchedule {
|
||||
end_at: Option<Instant>,
|
||||
interval: Duration,
|
||||
next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ScheduleCommit {
|
||||
pub(crate) scheduled_at: Instant,
|
||||
pub(crate) next_send_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ProbeSchedule {
|
||||
pub(crate) fn new(
|
||||
start_at: Instant,
|
||||
negotiated: &NegotiatedParams,
|
||||
) -> Result<Self, ClientError> {
|
||||
let interval_ns = u64::try_from(negotiated.params.interval_ns)
|
||||
.expect("validated positive negotiated interval");
|
||||
let interval = Duration::from_nanos(interval_ns);
|
||||
let end_at = if negotiated.params.duration_ns > 0 {
|
||||
let duration_ns = u64::try_from(negotiated.params.duration_ns)
|
||||
.expect("validated positive negotiated duration");
|
||||
Some(
|
||||
start_at
|
||||
.checked_add(Duration::from_nanos(duration_ns))
|
||||
.ok_or_else(|| ClientError::NegotiationRejected {
|
||||
reason: "duration is too large to schedule".to_owned(),
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
end_at,
|
||||
interval,
|
||||
next_send_at: Some(start_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
||||
self.next_send_at
|
||||
}
|
||||
|
||||
/// Wake for finite expiry independently of send readiness until finished.
|
||||
pub(crate) fn end_deadline(&self) -> Option<Instant> {
|
||||
self.end_at.filter(|_| !self.is_finished())
|
||||
}
|
||||
|
||||
pub(crate) fn interval(&self) -> Duration {
|
||||
self.interval
|
||||
}
|
||||
|
||||
pub(crate) fn permit_probe_at(&mut self, now: Instant) -> bool {
|
||||
if self.next_send_at.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.end_at.is_some_and(|end| now >= end) {
|
||||
self.next_send_at = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_managed_commit(
|
||||
&self,
|
||||
scheduled_at: Instant,
|
||||
permission_at: Instant,
|
||||
) -> Result<ScheduleCommit, ClientError> {
|
||||
let (scheduled_at, next_send_at) =
|
||||
advance_cadence(scheduled_at, self.interval, permission_at)?;
|
||||
Ok(ScheduleCommit {
|
||||
scheduled_at,
|
||||
next_send_at: if self.end_at.is_some_and(|end| next_send_at >= end) {
|
||||
None
|
||||
} else {
|
||||
Some(next_send_at)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&mut self, commit: ScheduleCommit) {
|
||||
self.next_send_at = commit.next_send_at;
|
||||
}
|
||||
|
||||
/// Reclaim slots crossed while a successful socket submission was in flight.
|
||||
/// This follows the send commit; it never advances a failed submission.
|
||||
pub(crate) fn skip_missed_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
|
||||
if let Some(deadline) = self.next_send_at.filter(|deadline| *deadline <= now) {
|
||||
let commit = self.preflight_managed_commit(deadline, now)?;
|
||||
self.commit(commit);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
self.next_send_at.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn advance_cadence(
|
||||
deadline: Instant,
|
||||
interval: Duration,
|
||||
now: Instant,
|
||||
) -> Result<(Instant, Instant), ClientError> {
|
||||
if interval.is_zero() {
|
||||
return Err(ClientError::InvalidConfig {
|
||||
reason: "probe interval must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed_slots = now
|
||||
.checked_duration_since(deadline)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos() / interval.as_nanos());
|
||||
let scheduled_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(elapsed_slots)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let next_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(
|
||||
elapsed_slots
|
||||
.checked_add(1)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let scheduled_at = deadline
|
||||
.checked_add(scheduled_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
let next_at = deadline
|
||||
.checked_add(next_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
Ok((scheduled_at, next_at))
|
||||
}
|
||||
|
||||
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
|
||||
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
||||
let seconds =
|
||||
u64::try_from(nanos / NANOS_PER_SECOND).map_err(|_| ClientError::DurationOverflow)?;
|
||||
let subsec_nanos = u32::try_from(nanos % NANOS_PER_SECOND)
|
||||
.expect("nanosecond remainder is always less than one second");
|
||||
Ok(Duration::new(seconds, subsec_nanos))
|
||||
}
|
||||
|
||||
pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||
left.checked_duration_since(right)
|
||||
.or_else(|| right.checked_duration_since(left))
|
||||
.unwrap_or(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lifetime_deadline_exists_only_for_an_unfinished_finite_schedule() {
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_millis(500);
|
||||
let mut negotiated = NegotiatedParams {
|
||||
params: irtt_proto::Params {
|
||||
duration_ns: 500_000_000,
|
||||
interval_ns: 1_000_000,
|
||||
..irtt_proto::Params::default()
|
||||
},
|
||||
restrictions: Vec::new(),
|
||||
};
|
||||
let mut finite = ProbeSchedule::new(start, &negotiated).unwrap();
|
||||
assert_eq!(finite.end_deadline(), Some(end));
|
||||
assert!(finite.permit_probe_at(end - Duration::from_nanos(1)));
|
||||
assert_eq!(finite.end_deadline(), Some(end));
|
||||
assert!(!finite.permit_probe_at(end));
|
||||
assert!(finite.is_finished());
|
||||
assert_eq!(finite.end_deadline(), None);
|
||||
|
||||
let mut finite = ProbeSchedule::new(start, &negotiated).unwrap();
|
||||
let commit = finite
|
||||
.preflight_managed_commit(start, end - Duration::from_nanos(1))
|
||||
.unwrap();
|
||||
finite.commit(commit);
|
||||
assert!(finite.is_finished());
|
||||
assert_eq!(finite.end_deadline(), None);
|
||||
|
||||
negotiated.params.duration_ns = 0;
|
||||
let mut continuous = ProbeSchedule::new(start, &negotiated).unwrap();
|
||||
assert!(continuous.permit_probe_at(end));
|
||||
assert_eq!(continuous.end_deadline(), None);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ use crate::{
|
||||
ClientConfig, ClientError, ClientEvent, OpenOutcome,
|
||||
};
|
||||
|
||||
use super::schedule::{instant_abs_diff, ProbeSchedule};
|
||||
|
||||
use super::{
|
||||
classify_client_error, ManagedClientConfig, ManagedCommandAcknowledgement,
|
||||
ManagedCommandApplyError, ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError,
|
||||
@@ -175,73 +177,6 @@ pub struct ManagedClientHandle {
|
||||
max_live_target_generations: usize,
|
||||
status: watch::Receiver<Arc<ManagedStatus>>,
|
||||
events: broadcast::WeakSender<ManagedEvent>,
|
||||
#[cfg(test)]
|
||||
update_submission_hook: Option<Arc<UpdateSubmissionTestHook>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct UpdateSubmissionTestHook {
|
||||
state: std::sync::Mutex<UpdateSubmissionTestState>,
|
||||
arrived: std::sync::Condvar,
|
||||
released: std::sync::Condvar,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct UpdateSubmissionTestState {
|
||||
armed: bool,
|
||||
arrived: bool,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl UpdateSubmissionTestHook {
|
||||
fn arm(&self) {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.armed = true;
|
||||
}
|
||||
|
||||
fn pause_after_admission(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !state.armed {
|
||||
return;
|
||||
}
|
||||
state.arrived = true;
|
||||
self.arrived.notify_all();
|
||||
while !state.released {
|
||||
state = self
|
||||
.released
|
||||
.wait(state)
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until_arrived(&self) {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (state, timeout) = self
|
||||
.arrived
|
||||
.wait_timeout_while(state, Duration::from_secs(2), |state| !state.arrived)
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert!(state.arrived && !timeout.timed_out());
|
||||
}
|
||||
|
||||
fn release(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.released = true;
|
||||
self.released.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ManagedClientHandle {
|
||||
@@ -303,11 +238,6 @@ impl ManagedClientHandle {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(hook) = &self.update_submission_hook {
|
||||
hook.pause_after_admission();
|
||||
}
|
||||
|
||||
// Observing `Open` only permits attempting submission. A successful
|
||||
// `try_send` transfers command ownership to the driver. A concurrent
|
||||
// stop may move admission to `Stopping` before this send, allowing the
|
||||
@@ -400,10 +330,6 @@ impl Future for ManagedStopReceipt {
|
||||
type ConnectFuture =
|
||||
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
|
||||
type WakeFuture = Pin<Box<dyn Future<Output = Option<watch::Receiver<()>>> + Send + 'static>>;
|
||||
#[cfg(test)]
|
||||
type EventObservations = Arc<std::sync::Mutex<Vec<(ManagedEvent, Arc<ManagedStatus>)>>>;
|
||||
#[cfg(test)]
|
||||
type StaggerObservations = Arc<std::sync::Mutex<Vec<(usize, Duration)>>>;
|
||||
|
||||
fn arm_wake(mut receiver: watch::Receiver<()>) -> WakeFuture {
|
||||
Box::pin(async move {
|
||||
@@ -504,6 +430,7 @@ impl TargetCounters {
|
||||
}
|
||||
|
||||
struct TargetRuntime {
|
||||
schedule: Option<ProbeSchedule>,
|
||||
instance: TargetInstance,
|
||||
config: ManagedTargetConfig,
|
||||
desired: bool,
|
||||
@@ -522,17 +449,7 @@ impl TargetRuntime {
|
||||
/// The client owns `packets_sent`; this runtime only mirrors it so a
|
||||
/// terminal outcome can report it after the client is gone.
|
||||
fn sync_packets_sent(&mut self, client: &AsyncClient) {
|
||||
self.record_packets_sent(client.packets_sent());
|
||||
}
|
||||
|
||||
/// Adopt an already-read authoritative sent count.
|
||||
fn record_packets_sent(&mut self, packets_sent: u64) {
|
||||
self.counters.packets_sent = packets_sent;
|
||||
}
|
||||
|
||||
/// Account for one client event against this target's durable counters.
|
||||
fn observe(&mut self, event: &ClientEvent) {
|
||||
self.counters.observe(event);
|
||||
self.counters.packets_sent = client.packets_sent();
|
||||
}
|
||||
|
||||
/// Build this target's durable outcome.
|
||||
@@ -695,6 +612,8 @@ enum OpenSessionFailureCleanup {
|
||||
/// The sole authoritative zero-or-many target driver.
|
||||
#[must_use = "ManagedClientTask must be awaited or deliberately dropped"]
|
||||
pub struct ManagedClientTask {
|
||||
#[cfg(test)]
|
||||
timeout_inspections: std::cell::RefCell<Vec<usize>>,
|
||||
state: DriverState,
|
||||
lifecycle: ManagedLifecycle,
|
||||
config: ManagedClientConfig,
|
||||
@@ -717,24 +636,6 @@ pub struct ManagedClientTask {
|
||||
final_outcome: Option<Arc<ManagedOutcome>>,
|
||||
next_generation: u64,
|
||||
applied_command_sequence: u64,
|
||||
#[cfg(test)]
|
||||
event_observations: Option<EventObservations>,
|
||||
#[cfg(test)]
|
||||
stagger_observations: Option<StaggerObservations>,
|
||||
#[cfg(test)]
|
||||
drain_test_hook: DrainTestHook,
|
||||
#[cfg(test)]
|
||||
timeout_inspections: usize,
|
||||
#[cfg(test)]
|
||||
deadline_inspections: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct DrainTestHook {
|
||||
defer_work_until_deadline: bool,
|
||||
fail_receive: bool,
|
||||
fail_close: bool,
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
@@ -745,13 +646,6 @@ impl ManagedClientTask {
|
||||
}
|
||||
|
||||
fn publish_event(&self, event: ManagedEvent) {
|
||||
#[cfg(test)]
|
||||
if let Some(observations) = &self.event_observations {
|
||||
observations
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push((event.clone(), Arc::clone(&self.resources().status.borrow())));
|
||||
}
|
||||
if let Some(events) = &self.resources().events {
|
||||
let _ = events.send(event);
|
||||
}
|
||||
@@ -834,7 +728,7 @@ impl ManagedClientTask {
|
||||
|
||||
fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
|
||||
for event in events {
|
||||
self.targets[index].observe(&event);
|
||||
self.targets[index].counters.observe(&event);
|
||||
self.publish_event(ManagedEvent::Client {
|
||||
target: self.targets[index].instance.clone(),
|
||||
event,
|
||||
@@ -991,6 +885,7 @@ impl ManagedClientTask {
|
||||
};
|
||||
created_instances.push(instance.clone());
|
||||
self.targets.push(TargetRuntime {
|
||||
schedule: None,
|
||||
instance,
|
||||
server_addr: Arc::from(planned.target.server_addr.clone()),
|
||||
config: planned.target,
|
||||
@@ -1050,9 +945,7 @@ impl ManagedClientTask {
|
||||
}
|
||||
let mut client_config = self.config.client.clone();
|
||||
client_config.server_addr.clone_from(&target.server_addr);
|
||||
if let Some(auth) = &target.auth {
|
||||
client_config.hmac_key.clone_from(&auth.hmac_key);
|
||||
}
|
||||
client_config.auth = target.auth.resolve(&self.config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedCommandApplyError::InvalidTarget {
|
||||
id: target.id.clone(),
|
||||
@@ -1243,7 +1136,25 @@ impl ManagedClientTask {
|
||||
self.targets[index].state = TargetState::Opening { client, open };
|
||||
false
|
||||
}
|
||||
Poll::Ready(Ok(OpenOutcome::Started { event, .. })) => {
|
||||
Poll::Ready(Ok(OpenOutcome::Started {
|
||||
event, negotiated, ..
|
||||
})) => {
|
||||
let ClientEvent::SessionStarted { at: opened_at, .. } = &event else {
|
||||
unreachable!("Started carries SessionStarted");
|
||||
};
|
||||
match ProbeSchedule::new(opened_at.mono, &negotiated) {
|
||||
Ok(schedule) => self.targets[index].schedule = Some(schedule),
|
||||
Err(error) => {
|
||||
return self.begin_open_session_failure(
|
||||
index,
|
||||
client,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Close,
|
||||
)
|
||||
}
|
||||
}
|
||||
self.publish_client_events(index, vec![event]);
|
||||
if self.state == DriverState::Stopping
|
||||
|| self.effective_retirement(index).is_some()
|
||||
@@ -1340,7 +1251,15 @@ impl ManagedClientTask {
|
||||
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
|
||||
return false;
|
||||
}
|
||||
if client.is_run_complete() {
|
||||
if self.targets[index]
|
||||
.schedule
|
||||
.as_mut()
|
||||
.is_some_and(|schedule| {
|
||||
schedule.permit_probe_at(now);
|
||||
schedule.is_finished()
|
||||
})
|
||||
&& !client.has_pending_probes()
|
||||
{
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
return self.begin_drain(
|
||||
index,
|
||||
@@ -1360,22 +1279,6 @@ impl ManagedClientTask {
|
||||
mut cleanup_failure,
|
||||
mut post_deadline_receives_remaining,
|
||||
} => {
|
||||
#[cfg(test)]
|
||||
let defer_work = self.drain_test_hook.defer_work_until_deadline && now < deadline;
|
||||
#[cfg(not(test))]
|
||||
let defer_work = false;
|
||||
if defer_work {
|
||||
self.targets[index].state = TargetState::Draining {
|
||||
client,
|
||||
drain_started_at,
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
post_deadline_receives_remaining,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now)
|
||||
@@ -1391,10 +1294,6 @@ impl ManagedClientTask {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
let injected_receive_failure = self.take_drain_receive_failure();
|
||||
#[cfg(not(test))]
|
||||
let injected_receive_failure = None;
|
||||
let mut retained_state_changed = false;
|
||||
let received = match client.poll_recv(cx) {
|
||||
Poll::Pending => false,
|
||||
@@ -1411,13 +1310,7 @@ impl ManagedClientTask {
|
||||
return self.begin_close(index, client, primary_end, cleanup_failure, now);
|
||||
}
|
||||
};
|
||||
if let Some(error) = injected_receive_failure {
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
cleanup_failure.get_or_insert_with(|| {
|
||||
classify_client_error(ManagedTargetFailurePhase::Receiving, &error)
|
||||
});
|
||||
return self.begin_close(index, client, primary_end, cleanup_failure, now);
|
||||
}
|
||||
|
||||
if client.is_peer_closed() {
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
self.finish_target(index, ManagedTargetEndReason::PeerClosed, cleanup_failure);
|
||||
@@ -1495,17 +1388,7 @@ impl ManagedClientTask {
|
||||
Poll::Ready(Ok(events)) => {
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
self.publish_client_events(index, events);
|
||||
#[cfg(test)]
|
||||
let injected_close_failure = self.take_drain_close_failure().map(|error| {
|
||||
classify_client_error(ManagedTargetFailurePhase::Closing, &error)
|
||||
});
|
||||
#[cfg(not(test))]
|
||||
let injected_close_failure = None;
|
||||
self.finish_target(
|
||||
index,
|
||||
primary_end,
|
||||
cleanup_failure.or(injected_close_failure),
|
||||
);
|
||||
self.finish_target(index, primary_end, cleanup_failure);
|
||||
false
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
@@ -1586,26 +1469,6 @@ impl ManagedClientTask {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn take_drain_receive_failure(&mut self) -> Option<ClientError> {
|
||||
if !mem::take(&mut self.drain_test_hook.fail_receive) {
|
||||
return None;
|
||||
}
|
||||
Some(ClientError::Socket(std::io::Error::other(
|
||||
"injected drain receive failure",
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn take_drain_close_failure(&mut self) -> Option<ClientError> {
|
||||
if !mem::take(&mut self.drain_test_hook.fail_close) {
|
||||
return None;
|
||||
}
|
||||
Some(ClientError::Socket(std::io::Error::other(
|
||||
"injected drain close failure",
|
||||
)))
|
||||
}
|
||||
|
||||
fn active_count(&self) -> usize {
|
||||
self.targets
|
||||
.iter()
|
||||
@@ -1613,23 +1476,25 @@ impl ManagedClientTask {
|
||||
.count()
|
||||
}
|
||||
|
||||
fn active_stagger_spacing(&self) -> Option<(usize, Duration)> {
|
||||
fn active_stagger_spacing(&self) -> Option<Duration> {
|
||||
let mut active = 0;
|
||||
let mut minimum: Option<Duration> = None;
|
||||
for target in &self.targets {
|
||||
if !target.desired || target.retirement.is_some() {
|
||||
continue;
|
||||
}
|
||||
let TargetState::Active { client } = &target.state else {
|
||||
let TargetState::Active { .. } = &target.state else {
|
||||
continue;
|
||||
};
|
||||
active += 1;
|
||||
let interval = client
|
||||
.probe_interval()
|
||||
.expect("active managed targets have a committed probe schedule");
|
||||
let interval = target
|
||||
.schedule
|
||||
.as_ref()
|
||||
.expect("active managed targets have a probe schedule")
|
||||
.interval();
|
||||
minimum = minimum.into_iter().chain(Some(interval)).min();
|
||||
}
|
||||
minimum.map(|interval| (active, stagger_spacing(interval, active)))
|
||||
minimum.map(|interval| stagger_spacing(interval, active))
|
||||
}
|
||||
|
||||
fn stagger_target_added(&mut self, now: Instant) {
|
||||
@@ -1640,7 +1505,7 @@ impl ManagedClientTask {
|
||||
let candidate = self
|
||||
.last_stagger_send
|
||||
.zip(self.active_stagger_spacing())
|
||||
.and_then(|(last, (_, spacing))| last.checked_add(spacing))
|
||||
.and_then(|(last, spacing)| last.checked_add(spacing))
|
||||
.filter(|gate| *gate > now);
|
||||
self.send_gate = candidate.map(|candidate| existing.min(candidate));
|
||||
}
|
||||
@@ -1676,21 +1541,17 @@ impl ManagedClientTask {
|
||||
fn record_stagger_acceptance(
|
||||
&mut self,
|
||||
result: SendResult,
|
||||
stagger_spacing: Option<(usize, Duration)>,
|
||||
stagger_spacing: Option<Duration>,
|
||||
accepted_at: Instant,
|
||||
) {
|
||||
if !result.accepted() {
|
||||
return;
|
||||
}
|
||||
let Some((_active, spacing)) = stagger_spacing else {
|
||||
let Some(spacing) = stagger_spacing else {
|
||||
return;
|
||||
};
|
||||
self.last_stagger_send = Some(accepted_at);
|
||||
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
|
||||
#[cfg(test)]
|
||||
if let Some(observations) = &self.stagger_observations {
|
||||
observations.lock().unwrap().push((_active, spacing));
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_one_send(
|
||||
@@ -1698,7 +1559,7 @@ impl ManagedClientTask {
|
||||
index: usize,
|
||||
cx: &mut Context<'_>,
|
||||
now: Instant,
|
||||
stagger_spacing: Option<(usize, Duration)>,
|
||||
stagger_spacing: Option<Duration>,
|
||||
) -> SendResult {
|
||||
if !self.targets[index].desired || self.targets[index].retirement.is_some() {
|
||||
self.targets[index].send_waiting = false;
|
||||
@@ -1709,6 +1570,9 @@ impl ManagedClientTask {
|
||||
self.targets[index].state = state;
|
||||
return SendResult::NotAttempted;
|
||||
};
|
||||
// A burst pass may span several slots; validate this target against
|
||||
// fresh time rather than the pass's earlier scheduling snapshot.
|
||||
let now = now.max(Instant::now());
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
@@ -1717,7 +1581,21 @@ impl ManagedClientTask {
|
||||
self.targets[index].state = TargetState::Active { client };
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
if client
|
||||
let schedule = self.targets[index]
|
||||
.schedule
|
||||
.as_mut()
|
||||
.expect("active managed targets have a probe schedule");
|
||||
if !schedule.permit_probe_at(now) {
|
||||
self.targets[index].send_waiting = false;
|
||||
if client.has_pending_probes() {
|
||||
self.targets[index].state = TargetState::Active { client };
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
self.begin_drain(index, client, ManagedTargetEndReason::TestComplete, now);
|
||||
return SendResult::Ready { accepted: false };
|
||||
}
|
||||
if schedule
|
||||
.next_send_deadline()
|
||||
.is_none_or(|deadline| deadline > now)
|
||||
{
|
||||
@@ -1725,8 +1603,25 @@ impl ManagedClientTask {
|
||||
self.targets[index].state = TargetState::Active { client };
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
let schedule = self.targets[index].schedule.as_ref().unwrap();
|
||||
let commit =
|
||||
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => {
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
client,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Drain,
|
||||
);
|
||||
return SendResult::Failed { accepted: false };
|
||||
}
|
||||
};
|
||||
let scheduled_at = commit.scheduled_at;
|
||||
let before = client.packets_sent();
|
||||
let result = client.poll_send_probe(cx);
|
||||
let mut result = client.poll_send_probe(cx);
|
||||
let after = client.packets_sent();
|
||||
let accepted = after > before;
|
||||
let send_result = match &result {
|
||||
@@ -1735,11 +1630,38 @@ impl ManagedClientTask {
|
||||
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
|
||||
};
|
||||
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
|
||||
let schedule_error = accepted
|
||||
.then(|| client.skip_missed_probe_slots_at(Instant::now()))
|
||||
.transpose()
|
||||
.err();
|
||||
self.targets[index].record_packets_sent(after);
|
||||
if accepted {
|
||||
self.targets[index]
|
||||
.schedule
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.commit(commit);
|
||||
if let Poll::Ready(Ok(events)) = &mut result {
|
||||
for event in events {
|
||||
if let ClientEvent::EchoSent {
|
||||
scheduled_at: intended,
|
||||
sent_at,
|
||||
timer_error,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
*intended = Some(scheduled_at);
|
||||
*timer_error = Some(instant_abs_diff(sent_at.mono, scheduled_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let schedule_error = if accepted {
|
||||
self.targets[index]
|
||||
.schedule
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.skip_missed_slots_at(Instant::now())
|
||||
.err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.targets[index].counters.packets_sent = after;
|
||||
match result {
|
||||
Poll::Pending => {
|
||||
self.targets[index].send_waiting = true;
|
||||
@@ -1754,7 +1676,7 @@ impl ManagedClientTask {
|
||||
let TargetState::Active { client } =
|
||||
mem::replace(&mut self.targets[index].state, TargetState::Terminal)
|
||||
else {
|
||||
unreachable!("probe sender remained active while publishing send events");
|
||||
unreachable!("sender remained active")
|
||||
};
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
@@ -1769,6 +1691,7 @@ impl ManagedClientTask {
|
||||
SendResult::Ready { accepted }
|
||||
}
|
||||
}
|
||||
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.targets[index].send_waiting = false;
|
||||
self.begin_open_session_failure(
|
||||
@@ -1863,10 +1786,6 @@ impl ManagedClientTask {
|
||||
}
|
||||
let index = self.timeout_cursor;
|
||||
self.timeout_cursor = (self.timeout_cursor + 1) % target_len;
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.timeout_inspections += 1;
|
||||
}
|
||||
if self.target_has_due_timeout(index, now) {
|
||||
let generation = self.targets[index].instance.generation;
|
||||
let step = self.poll_target_timeout(index, now);
|
||||
@@ -1914,6 +1833,8 @@ impl ManagedClientTask {
|
||||
}
|
||||
|
||||
fn target_has_due_timeout(&self, index: usize, now: Instant) -> bool {
|
||||
#[cfg(test)]
|
||||
self.timeout_inspections.borrow_mut().push(index);
|
||||
match &self.targets[index].state {
|
||||
TargetState::Active { client } => {
|
||||
self.state != DriverState::Stopping
|
||||
@@ -1922,20 +1843,9 @@ impl ManagedClientTask {
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
}
|
||||
TargetState::Draining {
|
||||
client,
|
||||
deadline: _deadline,
|
||||
..
|
||||
} => {
|
||||
#[cfg(test)]
|
||||
let defer_work = self.drain_test_hook.defer_work_until_deadline && now < *_deadline;
|
||||
#[cfg(not(test))]
|
||||
let defer_work = false;
|
||||
!defer_work
|
||||
&& client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now)
|
||||
}
|
||||
TargetState::Draining { client, .. } => client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -2015,11 +1925,7 @@ impl ManagedClientTask {
|
||||
.all(|target| matches!(target.state, TargetState::Terminal))
|
||||
}
|
||||
|
||||
fn next_deadline(&mut self) -> Option<Instant> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.deadline_inspections += self.targets.len();
|
||||
}
|
||||
fn next_deadline(&self) -> Option<Instant> {
|
||||
let mut non_send_deadline = None;
|
||||
let mut send_deadline = None;
|
||||
for target in &self.targets {
|
||||
@@ -2028,28 +1934,31 @@ impl ManagedClientTask {
|
||||
non_send_deadline = non_send_deadline
|
||||
.into_iter()
|
||||
.chain(client.next_probe_timeout_deadline())
|
||||
.chain(
|
||||
target
|
||||
.schedule
|
||||
.as_ref()
|
||||
.and_then(ProbeSchedule::end_deadline),
|
||||
)
|
||||
.min();
|
||||
if target.desired && target.retirement.is_none() && !target.send_waiting {
|
||||
send_deadline = send_deadline
|
||||
.into_iter()
|
||||
.chain(client.next_send_deadline())
|
||||
.chain(
|
||||
target
|
||||
.schedule
|
||||
.as_ref()
|
||||
.and_then(ProbeSchedule::next_send_deadline),
|
||||
)
|
||||
.min();
|
||||
}
|
||||
}
|
||||
TargetState::Draining {
|
||||
client, deadline, ..
|
||||
} => {
|
||||
#[cfg(test)]
|
||||
let defer_work = self.drain_test_hook.defer_work_until_deadline;
|
||||
#[cfg(not(test))]
|
||||
let defer_work = false;
|
||||
non_send_deadline = non_send_deadline
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!defer_work)
|
||||
.then(|| client.next_probe_timeout_deadline())
|
||||
.flatten(),
|
||||
)
|
||||
.chain(client.next_probe_timeout_deadline())
|
||||
.chain(Some(*deadline))
|
||||
.min();
|
||||
}
|
||||
@@ -2063,11 +1972,7 @@ impl ManagedClientTask {
|
||||
&& self.config.pacing == ManagedPacing::Staggered
|
||||
&& self.active_count() > 0
|
||||
{
|
||||
send_deadline = match (send_deadline, self.send_gate) {
|
||||
(Some(target), Some(gate)) => Some(target.max(gate)),
|
||||
(None, gate) => gate,
|
||||
(target, None) => target,
|
||||
};
|
||||
send_deadline = gated_send_deadline(send_deadline, self.send_gate);
|
||||
}
|
||||
non_send_deadline.into_iter().chain(send_deadline).min()
|
||||
}
|
||||
@@ -2165,6 +2070,11 @@ impl ManagedClientTask {
|
||||
}
|
||||
}
|
||||
|
||||
// A release gate delays a send opportunity; it cannot create one.
|
||||
fn gated_send_deadline(cadence: Option<Instant>, gate: Option<Instant>) -> Option<Instant> {
|
||||
cadence.map(|deadline| gate.map_or(deadline, |gate| deadline.max(gate)))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum SendResult {
|
||||
NotAttempted,
|
||||
@@ -2339,9 +2249,7 @@ fn build_task(
|
||||
.ok_or(ManagedConfigError::GenerationExhausted)?;
|
||||
let mut client_config = config.client.clone();
|
||||
client_config.server_addr.clone_from(&target.server_addr);
|
||||
if let Some(auth) = &target.auth {
|
||||
client_config.hmac_key.clone_from(&auth.hmac_key);
|
||||
}
|
||||
client_config.auth = target.auth.resolve(&config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedConfigError::InvalidTarget {
|
||||
id: target.id.clone(),
|
||||
@@ -2349,6 +2257,7 @@ fn build_task(
|
||||
}
|
||||
})?;
|
||||
runtimes.push(TargetRuntime {
|
||||
schedule: None,
|
||||
instance: TargetInstance {
|
||||
id: target.id.clone(),
|
||||
generation,
|
||||
@@ -2416,6 +2325,8 @@ fn build_task(
|
||||
stop: Arc::clone(&stop),
|
||||
};
|
||||
let task = ManagedClientTask {
|
||||
#[cfg(test)]
|
||||
timeout_inspections: std::cell::RefCell::new(Vec::new()),
|
||||
state: DriverState::NotStarted,
|
||||
lifecycle: ManagedLifecycle::NotStarted,
|
||||
config,
|
||||
@@ -2438,16 +2349,6 @@ fn build_task(
|
||||
final_outcome: None,
|
||||
next_generation,
|
||||
applied_command_sequence: 0,
|
||||
#[cfg(test)]
|
||||
event_observations: None,
|
||||
#[cfg(test)]
|
||||
stagger_observations: None,
|
||||
#[cfg(test)]
|
||||
drain_test_hook: DrainTestHook::default(),
|
||||
#[cfg(test)]
|
||||
timeout_inspections: 0,
|
||||
#[cfg(test)]
|
||||
deadline_inspections: 0,
|
||||
};
|
||||
let handle = ManagedClientHandle {
|
||||
stop,
|
||||
@@ -2455,8 +2356,6 @@ fn build_task(
|
||||
max_live_target_generations,
|
||||
status: status_receiver,
|
||||
events: weak_events,
|
||||
#[cfg(test)]
|
||||
update_submission_hook: None,
|
||||
};
|
||||
Ok((task, handle))
|
||||
}
|
||||
@@ -2499,5 +2398,121 @@ fn close_timeout_failure() -> ManagedTargetFailure {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Real EAGAIN needs privileged queue shaping. Inspect only the owning
|
||||
// deadline selection here, using a production socket without sending.
|
||||
#[test]
|
||||
fn finite_lifetime_deadline_survives_send_waiting_and_stagger_gating() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_millis(500);
|
||||
let negotiated = crate::NegotiatedParams {
|
||||
params: irtt_proto::Params {
|
||||
duration_ns: 500_000_000,
|
||||
interval_ns: 1_000_000,
|
||||
..irtt_proto::Params::default()
|
||||
},
|
||||
restrictions: Vec::new(),
|
||||
};
|
||||
for pacing in [ManagedPacing::Burst, ManagedPacing::Staggered] {
|
||||
let (mut task, _handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
pacing,
|
||||
..ManagedClientConfig::default()
|
||||
},
|
||||
vec![ManagedTargetConfig::new("target", "127.0.0.1:9")],
|
||||
)
|
||||
.unwrap();
|
||||
let client = AsyncClient::connect(ClientConfig {
|
||||
server_addr: "127.0.0.1:9".into(),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
task.state = DriverState::Running;
|
||||
task.install_target_state(0, TargetState::Active { client });
|
||||
task.send_gate = Some(end + Duration::from_secs(1));
|
||||
|
||||
for send_waiting in [false, true] {
|
||||
task.targets[0].send_waiting = send_waiting;
|
||||
task.targets[0].schedule =
|
||||
Some(ProbeSchedule::new(start, &negotiated).unwrap());
|
||||
let expected = if pacing == ManagedPacing::Burst && !send_waiting {
|
||||
start
|
||||
} else {
|
||||
end
|
||||
};
|
||||
assert_eq!(task.next_deadline(), Some(expected));
|
||||
|
||||
task.targets[0]
|
||||
.schedule
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.permit_probe_at(end);
|
||||
assert_eq!(task.next_deadline(), None);
|
||||
}
|
||||
|
||||
let mut continuous = negotiated.clone();
|
||||
continuous.params.duration_ns = 0;
|
||||
task.targets[0].schedule = Some(ProbeSchedule::new(start, &continuous).unwrap());
|
||||
assert_eq!(task.next_deadline(), None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_gate_only_delays_an_existing_send_deadline() {
|
||||
let earlier = Instant::now();
|
||||
let later = earlier + Duration::from_secs(1);
|
||||
for (cadence, gate, expected) in [
|
||||
(None, None, None),
|
||||
(None, Some(earlier), None),
|
||||
(None, Some(later), None),
|
||||
(Some(earlier), None, Some(earlier)),
|
||||
(Some(later), Some(earlier), Some(later)),
|
||||
(Some(earlier), Some(earlier), Some(earlier)),
|
||||
(Some(earlier), Some(later), Some(later)),
|
||||
] {
|
||||
assert_eq!(gated_send_deadline(cadence, gate), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll latency through public events cannot distinguish an O(n) discovery
|
||||
// scan from a bounded scan reliably. This tiny inspection trace measures
|
||||
// work directly, including empty targets, and checks eventual coverage
|
||||
// without asserting a cursor value or a particular visitation order.
|
||||
#[test]
|
||||
fn timeout_discovery_has_bounded_work_and_eventually_visits_every_target() {
|
||||
let count = TIMEOUT_WORK_BUDGET * 2 + 1;
|
||||
let targets = (0..count)
|
||||
.map(|i| ManagedTargetConfig::new(i.to_string(), "127.0.0.1:2112"))
|
||||
.collect();
|
||||
let (mut task, _handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
max_live_target_generations: count,
|
||||
..ManagedClientConfig::default()
|
||||
},
|
||||
targets,
|
||||
)
|
||||
.unwrap();
|
||||
let mut visited = HashSet::new();
|
||||
for _ in 0..count.div_ceil(TIMEOUT_WORK_BUDGET) {
|
||||
task.timeout_inspections.borrow_mut().clear();
|
||||
task.poll_timeout_pass(Instant::now());
|
||||
let inspected = task.timeout_inspections.borrow();
|
||||
assert!(inspected.len() <= TIMEOUT_WORK_BUDGET);
|
||||
visited.extend(inspected.iter().copied());
|
||||
}
|
||||
assert_eq!(
|
||||
visited.len(),
|
||||
count,
|
||||
"timeout discovery must reach all targets"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -56,180 +56,3 @@ impl From<ReceiveMeta> for PacketMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{ReceiveMeta, MAX_KERNEL_RX_LAG};
|
||||
use crate::event::PacketMeta;
|
||||
|
||||
fn userspace_wall() -> SystemTime {
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
|
||||
}
|
||||
|
||||
fn meta_with_kernel_rx(kernel_rx_timestamp: SystemTime) -> ReceiveMeta {
|
||||
ReceiveMeta {
|
||||
traffic_class: None,
|
||||
kernel_rx_timestamp: Some(kernel_rx_timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_without_kernel_timestamp_uses_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
|
||||
assert_eq!(
|
||||
ReceiveMeta::default().preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_uses_plausible_earlier_kernel_timestamp() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = userspace - Duration::from_micros(80);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
kernel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_accepts_kernel_timestamp_equal_to_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(userspace).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_rejects_kernel_timestamp_later_than_userspace_sample() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = userspace + Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_accepts_kernel_timestamp_exactly_at_the_lag_bound() {
|
||||
// The bound is inclusive: a lag of exactly MAX_KERNEL_RX_LAG is
|
||||
// still accepted, one nanosecond more is not.
|
||||
let userspace = userspace_wall();
|
||||
let at_bound = userspace - MAX_KERNEL_RX_LAG;
|
||||
let past_bound = userspace - MAX_KERNEL_RX_LAG - Duration::from_nanos(1);
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(at_bound).preferred_receive_wall(userspace),
|
||||
at_bound
|
||||
);
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(past_bound).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_rejects_implausibly_old_kernel_timestamp() {
|
||||
let userspace = userspace_wall();
|
||||
let kernel = SystemTime::UNIX_EPOCH;
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(kernel).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_receive_wall_handles_extreme_timestamps_without_panicking() {
|
||||
let userspace = userspace_wall();
|
||||
let far_future = SystemTime::UNIX_EPOCH + Duration::from_secs(u64::from(u32::MAX)) * 4;
|
||||
let before_epoch = SystemTime::UNIX_EPOCH - Duration::from_secs(u64::from(u32::MAX));
|
||||
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(far_future).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
assert_eq!(
|
||||
meta_with_kernel_rx(before_epoch).preferred_receive_wall(userspace),
|
||||
userspace
|
||||
);
|
||||
assert_eq!(
|
||||
ReceiveMeta::default().preferred_receive_wall(before_epoch),
|
||||
before_epoch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_preserves_kernel_timestamp_rejected_for_measurement() {
|
||||
let userspace = userspace_wall();
|
||||
let rejected = userspace + Duration::from_secs(30);
|
||||
let meta = meta_with_kernel_rx(rejected);
|
||||
|
||||
assert_eq!(meta.preferred_receive_wall(userspace), userspace);
|
||||
assert_eq!(
|
||||
PacketMeta::from(meta).kernel_rx_timestamp,
|
||||
Some(rejected),
|
||||
"observed metadata must survive rejection as a measurement endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_observed_traffic_class_zero_preserves_observed_zero() {
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(0),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(0));
|
||||
assert_eq!(packet_meta.dscp, Some(0));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_observed_traffic_class_derives_dscp_and_ecn() {
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(184),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(184));
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
|
||||
let packet_meta = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(186),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(packet_meta.traffic_class, Some(186));
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(2));
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_mapping_preserves_none_vs_observed_zero() {
|
||||
let unavailable = PacketMeta::from(ReceiveMeta::default());
|
||||
let observed_zero = PacketMeta::from(ReceiveMeta {
|
||||
traffic_class: Some(0),
|
||||
kernel_rx_timestamp: None,
|
||||
});
|
||||
|
||||
assert_eq!(unavailable.traffic_class, None);
|
||||
assert_eq!(unavailable.dscp, None);
|
||||
assert_eq!(unavailable.ecn, None);
|
||||
|
||||
assert_eq!(observed_zero.traffic_class, Some(0));
|
||||
assert_eq!(observed_zero.dscp, Some(0));
|
||||
assert_eq!(observed_zero.ecn, Some(0));
|
||||
}
|
||||
}
|
||||
+155
-324
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, TryReserveError, VecDeque},
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
time::{Instant, SystemTime},
|
||||
};
|
||||
|
||||
@@ -85,7 +85,12 @@ impl PendingMap {
|
||||
});
|
||||
}
|
||||
if self.map.len() == self.map.capacity() {
|
||||
self.map.try_reserve(1).map_err(pending_allocation_failed)?;
|
||||
self.map
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "pending probe storage",
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -186,8 +191,8 @@ impl PendingMap {
|
||||
ExpiredBatch { probes, more_due }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
/// Mutable access to a still-pending probe by wire sequence, without
|
||||
@@ -198,14 +203,13 @@ impl PendingMap {
|
||||
self.map.get_mut(&wire_seq).map(|entry| &mut entry.probe)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
pub fn next_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.first
|
||||
.and_then(|wire_seq| self.map.get(&wire_seq))
|
||||
.map(|entry| entry.probe.timeout_at)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", test))]
|
||||
#[cfg(feature = "tokio")]
|
||||
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.last
|
||||
.and_then(|wire_seq| self.map.get(&wire_seq))
|
||||
@@ -234,116 +238,65 @@ impl PendingMap {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, wire_seq: u32) -> bool {
|
||||
self.map.contains_key(&wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn capacity(&self) -> usize {
|
||||
self.map.capacity()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn linkage(&self) -> (Option<u32>, Option<u32>) {
|
||||
(self.first, self.last)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn entry_links(&self, wire_seq: u32) -> Option<(Option<u32>, Option<u32>)> {
|
||||
self.map
|
||||
.get(&wire_seq)
|
||||
.map(|entry| (entry.previous, entry.next))
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_allocation_failed(source: TryReserveError) -> ClientError {
|
||||
ClientError::AllocationFailed {
|
||||
operation: "pending probe storage",
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TimedOutMap {
|
||||
map: HashMap<u32, PendingProbe>,
|
||||
insertion_order: VecDeque<u32>,
|
||||
max_capacity: usize,
|
||||
// Timeout transitions consume PendingMap's head in deadline order. Reuse
|
||||
// its linked storage in a separate map: the head owns FIFO eviction and
|
||||
// the tail owns the latest retained timeout, even after arbitrary replies.
|
||||
probes: PendingMap,
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
deadline_inspections: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl TimedOutMap {
|
||||
pub fn new(max_capacity: usize) -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
insertion_order: VecDeque::new(),
|
||||
max_capacity,
|
||||
probes: PendingMap::new(max_capacity),
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
deadline_inspections: std::cell::Cell::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, probe: PendingProbe) {
|
||||
if self.max_capacity == 0 {
|
||||
if self.probes.max_capacity == 0 {
|
||||
return;
|
||||
}
|
||||
if let std::collections::hash_map::Entry::Occupied(mut entry) =
|
||||
self.map.entry(probe.wire_seq)
|
||||
{
|
||||
entry.insert(probe);
|
||||
return;
|
||||
// A sequence cannot be both pending and retained: committing its next
|
||||
// generation removes the old retention before adding the pending probe.
|
||||
debug_assert!(!self.probes.map.contains_key(&probe.wire_seq));
|
||||
if self.probes.map.len() == self.probes.max_capacity {
|
||||
self.probes
|
||||
.remove(self.probes.first.expect("full retention has a head"));
|
||||
}
|
||||
while self.map.len() >= self.max_capacity {
|
||||
self.evict_oldest();
|
||||
}
|
||||
self.insertion_order.push_back(probe.wire_seq);
|
||||
self.map.insert(probe.wire_seq, probe);
|
||||
// Unlike pending admission, retention always makes room by eviction.
|
||||
// Allocation here remains infallible as it was for the previous map.
|
||||
self.probes.commit_insert(probe);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, wire_seq: u32) -> Option<PendingProbe> {
|
||||
let removed = self.map.remove(&wire_seq);
|
||||
if removed.is_some() {
|
||||
self.insertion_order.retain(|seq| *seq != wire_seq);
|
||||
}
|
||||
removed
|
||||
self.probes.remove(wire_seq)
|
||||
}
|
||||
|
||||
/// Mutable access to a still-retained timed-out probe by wire sequence,
|
||||
/// without disturbing eviction order. Used to attach an observed kernel
|
||||
/// TX timestamp that arrives after the probe has already timed out.
|
||||
pub fn get_mut(&mut self, wire_seq: u32) -> Option<&mut PendingProbe> {
|
||||
self.map.get_mut(&wire_seq)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.insertion_order.clear();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
self.probes.get_mut(wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.map.values().map(|probe| probe.timeout_at).max()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, wire_seq: u32) -> bool {
|
||||
self.map.contains_key(&wire_seq)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insertion_order_len(&self) -> usize {
|
||||
self.insertion_order.len()
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
while let Some(oldest_key) = self.insertion_order.pop_front() {
|
||||
if self.map.remove(&oldest_key).is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.probes
|
||||
.last
|
||||
.and_then(|wire_seq| self.probes.map.get(&wire_seq))
|
||||
.map(|entry| {
|
||||
#[cfg(test)]
|
||||
self.deadline_inspections
|
||||
.set(self.deadline_inspections.get() + 1);
|
||||
entry.probe.timeout_at
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,263 +373,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(map: &mut PendingMap, probe: PendingProbe) {
|
||||
map.preflight_insert(probe.wire_seq).unwrap();
|
||||
map.commit_insert(probe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_rejects_over_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(0).unwrap();
|
||||
map.commit_insert(pending(0, now + Duration::from_secs(4)));
|
||||
map.preflight_insert(1).unwrap();
|
||||
map.commit_insert(pending(1, now + Duration::from_secs(4)));
|
||||
assert!(matches!(
|
||||
map.preflight_insert(2),
|
||||
Err(ClientError::PendingLimitExceeded { limit: 2 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_preflight_reserves_without_changing_contents() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let initial_capacity = map.capacity();
|
||||
|
||||
map.preflight_insert(7).unwrap();
|
||||
let reserved_capacity = map.capacity();
|
||||
assert!(reserved_capacity >= initial_capacity);
|
||||
assert_eq!(map.len(), 0);
|
||||
assert!(!map.contains(7));
|
||||
|
||||
map.preflight_insert(7).unwrap();
|
||||
assert_eq!(map.capacity(), reserved_capacity);
|
||||
assert_eq!(map.len(), 0);
|
||||
assert!(!map.contains(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_preflight_rejects_sequence_collision() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(9).unwrap();
|
||||
map.commit_insert(pending(9, now));
|
||||
|
||||
assert!(matches!(
|
||||
map.preflight_insert(9),
|
||||
Err(ClientError::PendingSequenceCollision { seq: 9 })
|
||||
));
|
||||
assert_eq!(map.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_allocation_failure_maps_to_dedicated_error() {
|
||||
let mut map = HashMap::<u32, PendingProbe>::new();
|
||||
let source = map.try_reserve(usize::MAX).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
pending_allocation_failed(source),
|
||||
ClientError::AllocationFailed {
|
||||
operation: "pending probe storage",
|
||||
..
|
||||
fn pending_removal_and_sequence_reuse_preserve_expiry_and_capacity() {
|
||||
let start = Instant::now();
|
||||
// Remove the head, middle or tail, then reuse that sequence at a later
|
||||
// deadline. Observe expiry and capacity, not private list links.
|
||||
for removed in 0..3 {
|
||||
let mut pending_map = PendingMap::new(3);
|
||||
for seq in 0..3 {
|
||||
pending_map.preflight_insert(seq).unwrap();
|
||||
pending_map
|
||||
.commit_insert(pending(seq, start + Duration::from_secs(u64::from(seq))));
|
||||
}
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
pending_map.preflight_insert(3),
|
||||
Err(ClientError::PendingLimitExceeded { .. })
|
||||
));
|
||||
assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed);
|
||||
pending_map.preflight_insert(removed).unwrap();
|
||||
let reused_deadline = start + Duration::from_secs(4);
|
||||
pending_map.commit_insert(pending(removed, reused_deadline));
|
||||
|
||||
#[test]
|
||||
fn pending_map_unlinks_head_middle_and_tail_and_updates_extrema() {
|
||||
let mut map = PendingMap::new(4);
|
||||
let now = Instant::now();
|
||||
insert(&mut map, pending(1, now + Duration::from_secs(1)));
|
||||
insert(&mut map, pending(2, now + Duration::from_secs(2)));
|
||||
insert(&mut map, pending(3, now + Duration::from_secs(3)));
|
||||
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(1), Some(3)));
|
||||
assert_eq!(map.entry_links(1), Some((None, Some(2))));
|
||||
assert_eq!(map.entry_links(2), Some((Some(1), Some(3))));
|
||||
assert_eq!(map.entry_links(3), Some((Some(2), None)));
|
||||
|
||||
assert!(map.remove(2).is_some());
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(1), Some(3)));
|
||||
assert_eq!(map.entry_links(1), Some((None, Some(3))));
|
||||
assert_eq!(map.entry_links(3), Some((Some(1), None)));
|
||||
|
||||
assert!(map.remove(1).is_some());
|
||||
assert_eq!(
|
||||
map.next_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(
|
||||
map.latest_timeout_deadline(),
|
||||
Some(now + Duration::from_secs(3))
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(3), Some(3)));
|
||||
assert_eq!(map.entry_links(3), Some((None, None)));
|
||||
|
||||
assert!(map.remove(3).is_some());
|
||||
assert_eq!(map.next_timeout_deadline(), None);
|
||||
assert_eq!(map.latest_timeout_deadline(), None);
|
||||
assert_eq!(map.linkage(), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_bounded_expiration_matches_exhaustive_order() {
|
||||
let now = Instant::now();
|
||||
let probes = [
|
||||
pending(1, now + Duration::from_secs(1)),
|
||||
pending(2, now + Duration::from_secs(2)),
|
||||
pending(3, now + Duration::from_secs(3)),
|
||||
pending(4, now + Duration::from_secs(4)),
|
||||
];
|
||||
let mut bounded = PendingMap::new(probes.len());
|
||||
let mut exhaustive = PendingMap::new(probes.len());
|
||||
for probe in probes.iter().cloned() {
|
||||
insert(&mut bounded, probe.clone());
|
||||
insert(&mut exhaustive, probe);
|
||||
}
|
||||
|
||||
let empty = bounded.drain_expired_bounded(now + Duration::from_secs(4), 0);
|
||||
assert!(empty.probes.is_empty());
|
||||
assert!(empty.more_due);
|
||||
assert_eq!(bounded.linkage(), (Some(1), Some(4)));
|
||||
|
||||
let mut actual = Vec::new();
|
||||
loop {
|
||||
let batch = bounded.drain_expired_bounded(now + Duration::from_secs(4), 2);
|
||||
actual.extend(batch.probes.into_iter().map(|probe| probe.wire_seq));
|
||||
if !batch.more_due {
|
||||
break;
|
||||
let remaining = (0..3).filter(|seq| *seq != removed).collect::<Vec<_>>();
|
||||
for (index, seq) in remaining.iter().enumerate() {
|
||||
assert_eq!(
|
||||
pending_map.next_timeout_deadline(),
|
||||
Some(start + Duration::from_secs(u64::from(*seq)))
|
||||
);
|
||||
let expired = pending_map.drain_expired_bounded(start + Duration::from_secs(3), 1);
|
||||
assert_eq!(
|
||||
expired
|
||||
.probes
|
||||
.iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![*seq]
|
||||
);
|
||||
assert_eq!(expired.more_due, index == 0);
|
||||
}
|
||||
assert_eq!(pending_map.next_timeout_deadline(), Some(reused_deadline));
|
||||
let expired = pending_map.drain_expired_bounded(reused_deadline, 3);
|
||||
assert_eq!(expired.probes.len(), 1);
|
||||
assert_eq!(expired.probes[0].wire_seq, removed);
|
||||
assert!(!expired.more_due);
|
||||
assert!(pending_map.is_empty());
|
||||
assert_eq!(pending_map.next_timeout_deadline(), None);
|
||||
pending_map.preflight_insert(removed).unwrap();
|
||||
pending_map.commit_insert(pending(removed, reused_deadline));
|
||||
assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed);
|
||||
assert!(pending_map.is_empty());
|
||||
}
|
||||
let expected = exhaustive
|
||||
.drain_expired_bounded(now + Duration::from_secs(4), usize::MAX)
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual, vec![1, 2, 3, 4]);
|
||||
assert_eq!(bounded.linkage(), (None, None));
|
||||
}
|
||||
|
||||
// Measure deadline inspection work directly: wall-clock thresholds cannot
|
||||
// reliably distinguish a bounded lookup from a retention scan.
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn pending_map_sequence_reuse_leaves_no_stale_link() {
|
||||
let mut map = PendingMap::new(1);
|
||||
let now = Instant::now();
|
||||
insert(&mut map, pending(7, now));
|
||||
assert!(map.remove(7).is_some());
|
||||
insert(&mut map, pending(7, now + Duration::from_secs(1)));
|
||||
|
||||
assert!(map.drain_expired_bounded(now, usize::MAX).probes.is_empty());
|
||||
assert_eq!(map.linkage(), (Some(7), Some(7)));
|
||||
assert_eq!(map.entry_links(7), Some((None, None)));
|
||||
assert_eq!(
|
||||
map.drain_expired_bounded(now + Duration::from_secs(1), usize::MAX)
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![7]
|
||||
);
|
||||
assert_eq!(map.linkage(), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_commit_uses_only_preflighted_map_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.preflight_insert(7).unwrap();
|
||||
let capacity = map.capacity();
|
||||
|
||||
map.commit_insert(pending(7, now));
|
||||
|
||||
assert_eq!(map.capacity(), capacity);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert_eq!(map.linkage(), (Some(7), Some(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_mixed_removals_preserve_links() {
|
||||
let mut map = PendingMap::new(4);
|
||||
let now = Instant::now();
|
||||
for seq in 1..=4 {
|
||||
insert(
|
||||
&mut map,
|
||||
pending(seq, now + Duration::from_secs(u64::from(seq))),
|
||||
fn timeout_retention_deadlines_have_bounded_lookup_work() {
|
||||
const COUNT: u32 = 4096;
|
||||
let start = Instant::now();
|
||||
let deadline = |seq| start + Duration::from_micros(u64::from(seq / 2));
|
||||
let mut pending_map = PendingMap::new(COUNT as usize);
|
||||
let mut timed_out = TimedOutMap::new(COUNT as usize);
|
||||
for seq in 0..COUNT {
|
||||
pending_map.preflight_insert(seq).unwrap();
|
||||
pending_map.commit_insert(pending(seq, deadline(seq)));
|
||||
}
|
||||
let last_deadline = deadline(COUNT - 1);
|
||||
for seq in 0..COUNT {
|
||||
let batch = pending_map.drain_expired_bounded(last_deadline, 1);
|
||||
assert_eq!(batch.probes.len(), 1);
|
||||
assert_eq!(batch.probes[0].wire_seq, seq);
|
||||
assert_eq!(batch.more_due, seq + 1 < COUNT);
|
||||
timed_out.insert(batch.probes.into_iter().next().unwrap());
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
|
||||
assert_eq!(
|
||||
pending_map
|
||||
.latest_timeout_deadline()
|
||||
.into_iter()
|
||||
.chain(timed_out.latest_timeout_deadline())
|
||||
.max(),
|
||||
Some(last_deadline)
|
||||
);
|
||||
}
|
||||
|
||||
assert!(map.remove(2).is_some());
|
||||
assert_eq!(map.entry_links(1), Some((None, Some(3))));
|
||||
assert_eq!(map.entry_links(3), Some((Some(1), Some(4))));
|
||||
let expired = map.drain_expired_bounded(now + Duration::from_secs(3), usize::MAX);
|
||||
assert_eq!(
|
||||
expired
|
||||
.probes
|
||||
.into_iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
assert!(
|
||||
timed_out.deadline_inspections.replace(0) <= 2 * COUNT as usize,
|
||||
"draining must not scan growing timeout retention after each transition"
|
||||
);
|
||||
assert_eq!(map.linkage(), (Some(4), Some(4)));
|
||||
assert!(map.remove(4).is_some());
|
||||
assert_eq!(map.linkage(), (None, None));
|
||||
// A middle/head removal leaves the latest deadline intact. Removing
|
||||
// the newest survivors must walk back through equal deadlines too.
|
||||
assert!(timed_out.remove(COUNT / 2).is_some());
|
||||
assert!(timed_out.remove(0).is_some());
|
||||
for seq in (1..COUNT).rev().filter(|seq| *seq != COUNT / 2) {
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
|
||||
assert!(timed_out.remove(seq).is_some());
|
||||
}
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), None);
|
||||
assert!(timed_out.deadline_inspections.get() <= COUNT as usize);
|
||||
}
|
||||
|
||||
// Public loss/reply events cannot reveal leaked retention bookkeeping.
|
||||
#[test]
|
||||
fn bounded_probe_tracking_evicts_oldest_entries() {
|
||||
let mut set = CompletedSet::new(3);
|
||||
set.insert(0);
|
||||
set.insert(1);
|
||||
set.insert(2);
|
||||
assert!(set.contains(0));
|
||||
assert!(set.contains(1));
|
||||
assert!(set.contains(2));
|
||||
set.insert(3);
|
||||
assert_eq!(set.set.len(), 3);
|
||||
assert!(!set.contains(0));
|
||||
assert!(set.contains(3));
|
||||
|
||||
let mut map = TimedOutMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.insert(pending(0, now));
|
||||
map.insert(pending(1, now));
|
||||
map.insert(pending(2, now));
|
||||
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.remove(0).is_none());
|
||||
assert!(map.remove(1).is_some());
|
||||
assert!(map.remove(2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_map_remove_prunes_insertion_order() {
|
||||
fn timed_out_retention_preserves_fifo_eviction_and_stays_bounded() {
|
||||
let mut map = TimedOutMap::new(4);
|
||||
let now = Instant::now();
|
||||
|
||||
for i in 0..20 {
|
||||
map.insert(pending(i, now));
|
||||
assert!(map.remove(i).is_some());
|
||||
assert_eq!(map.len(), 0);
|
||||
assert_eq!(map.insertion_order_len(), 0);
|
||||
assert!(map.probes.map.len() <= 4);
|
||||
}
|
||||
|
||||
// Equal deadlines across wire-sequence wrap must still evict by
|
||||
// insertion order. Removal at any position frees capacity immediately.
|
||||
let sequences = [u32::MAX - 1, u32::MAX, 0, 1];
|
||||
for removed in sequences {
|
||||
let mut map = TimedOutMap::new(4);
|
||||
for seq in sequences {
|
||||
map.insert(pending(seq, now));
|
||||
}
|
||||
assert!(map.remove(removed).is_some());
|
||||
map.insert(pending(2, now));
|
||||
let oldest = sequences.into_iter().find(|seq| *seq != removed).unwrap();
|
||||
map.insert(pending(3, now));
|
||||
assert!(map.remove(oldest).is_none(), "oldest survivor is evicted");
|
||||
for seq in sequences
|
||||
.into_iter()
|
||||
.filter(|seq| *seq != removed && *seq != oldest)
|
||||
{
|
||||
assert!(map.remove(seq).is_some());
|
||||
}
|
||||
assert!(map.remove(2).is_some());
|
||||
assert!(map.remove(3).is_some());
|
||||
assert!(map.probes.map.is_empty());
|
||||
#[cfg(feature = "tokio")]
|
||||
assert_eq!(map.latest_timeout_deadline(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,80 +111,3 @@ pub(crate) fn drain_tx_timestamps<S: std::os::fd::AsRawFd>(
|
||||
) -> io::Result<()> {
|
||||
linux::drain_tx_timestamps(socket, on_timestamp)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::UdpSocket;
|
||||
|
||||
use crate::{metadata::ReceiveMeta, receive::recv_datagram, timing::ClientTimestamp};
|
||||
|
||||
fn connected_loopback_pair() -> (UdpSocket, UdpSocket) {
|
||||
let a = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let b = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
a.connect(b.local_addr().unwrap()).unwrap();
|
||||
b.connect(a.local_addr().unwrap()).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_receive_returns_length() {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
sender.send(b"hello").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_receive_returns_default_metadata() {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
sender.send(b"meta").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
|
||||
assert_eq!(datagram.meta, ReceiveMeta::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_receive_captures_timestamp_after_successful_receive() {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
sender.send(b"time").unwrap();
|
||||
|
||||
let before = ClientTimestamp::now();
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let after = ClientTimestamp::now();
|
||||
|
||||
assert!(datagram.received_at.mono >= before.mono);
|
||||
assert!(datagram.received_at.mono <= after.mono);
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_try_receive_preserves_fallback_metadata_and_would_block() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let (sender, receiver) = connected_loopback_pair();
|
||||
receiver.set_nonblocking(true).unwrap();
|
||||
let receiver = tokio::net::UdpSocket::from_std(receiver).unwrap();
|
||||
sender.send(b"tokio").unwrap();
|
||||
receiver.readable().await.unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..datagram.len], b"tokio");
|
||||
assert_eq!(datagram.meta, ReceiveMeta::default());
|
||||
|
||||
let error = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@
|
||||
//! session so later IDs cannot be misattributed. It performs a small bounded,
|
||||
//! nonblocking read of
|
||||
//! `MSG_ERRQUEUE` so the adapter can opportunistically collect timestamps
|
||||
//! without ever waiting for one. The observed kernel TX wall time is retained
|
||||
//! as metadata only; it is not consumed by any measurement in this change.
|
||||
//! without ever waiting for one. A plausible kernel TX wall time is used only
|
||||
//! for upstream one-way delay; RTT retains the userspace send instant.
|
||||
|
||||
use std::{
|
||||
io::{self, IoSliceMut},
|
||||
@@ -161,12 +161,12 @@ fn recv_datagram_fd(
|
||||
buf: &mut [u8],
|
||||
flags: MsgFlags,
|
||||
) -> Result<ReceivedDatagram, io::Error> {
|
||||
let mut control = ControlBuffer::new();
|
||||
let mut control = ControlBuffer([0; CONTROL_LEN]);
|
||||
let mut iov = [IoSliceMut::new(buf)];
|
||||
|
||||
// The socket is connected, so the source address is not needed; `()` skips
|
||||
// copying it out.
|
||||
let msg = recvmsg::<()>(socket_fd, &mut iov, Some(control.as_mut_slice()), flags)?;
|
||||
let msg = recvmsg::<()>(socket_fd, &mut iov, Some(&mut control.0), flags)?;
|
||||
let received_at = ClientTimestamp::now();
|
||||
|
||||
Ok(ReceivedDatagram {
|
||||
@@ -215,343 +215,3 @@ fn system_time_from_timespec(timespec: TimeSpec) -> Option<SystemTime> {
|
||||
/// `cmsghdr` alignment, which 8-byte alignment covers on supported targets.
|
||||
#[repr(align(8))]
|
||||
struct ControlBuffer([u8; CONTROL_LEN]);
|
||||
|
||||
impl ControlBuffer {
|
||||
fn new() -> Self {
|
||||
Self([0; CONTROL_LEN])
|
||||
}
|
||||
|
||||
fn as_mut_slice(&mut self) -> &mut [u8] {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
time::{Duration, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nix::sys::time::TimeSpec;
|
||||
|
||||
use crate::{
|
||||
event::PacketMeta,
|
||||
receive::{configure_receive_metadata, recv_datagram},
|
||||
socket_options::apply_traffic_class_to_socket,
|
||||
timing::ClientTimestamp,
|
||||
};
|
||||
|
||||
fn connected_ipv4_loopback_pair() -> (UdpSocket, UdpSocket) {
|
||||
let a = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let b = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
a.connect(b.local_addr().unwrap()).unwrap();
|
||||
b.connect(a.local_addr().unwrap()).unwrap();
|
||||
a.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
b.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
fn connected_ipv6_loopback_pair() -> Option<(UdpSocket, UdpSocket)> {
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0));
|
||||
let a = match UdpSocket::bind(bind_addr) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
let b = match UdpSocket::bind(bind_addr) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
match a.connect(b.local_addr().unwrap()) {
|
||||
Ok(()) => {}
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
match b.connect(a.local_addr().unwrap()) {
|
||||
Ok(()) => {}
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary receive test: IPv6 loopback unavailable: {error}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
a.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
b.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
Some((a, b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recvmsg_receive_returns_length_payload_and_timestamp() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"hello").unwrap();
|
||||
|
||||
let before = ClientTimestamp::now();
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let after = ClientTimestamp::now();
|
||||
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"hello");
|
||||
assert!(datagram.received_at.mono >= before.mono);
|
||||
assert!(datagram.received_at.mono <= after.mono);
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_recvmsg_would_block_clears_readiness() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
receiver.set_nonblocking(true).unwrap();
|
||||
let receiver = tokio::net::UdpSocket::from_std(receiver).unwrap();
|
||||
let mut buf = [0_u8; 16];
|
||||
|
||||
sender.send(b"first").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), receiver.readable())
|
||||
.await
|
||||
.expect("initial readability timed out")
|
||||
.unwrap();
|
||||
|
||||
let first = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..first.len], b"first");
|
||||
|
||||
let error = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), receiver.readable())
|
||||
.await
|
||||
.is_err(),
|
||||
"readable completed from stale readiness after recvmsg WouldBlock"
|
||||
);
|
||||
|
||||
sender.send(b"second").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(1), receiver.readable())
|
||||
.await
|
||||
.expect("second readability timed out")
|
||||
.unwrap();
|
||||
|
||||
let second = super::try_recv_tokio_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..second.len], b"second");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_rx_timestamp_metadata_is_observed_when_kernel_provides_it() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"stamp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(datagram.len, 5);
|
||||
assert_eq!(&buf[..datagram.len], b"stamp");
|
||||
let Some(timestamp) = datagram.meta.kernel_rx_timestamp else {
|
||||
eprintln!(
|
||||
"skipping kernel timestamp assertion: kernel did not provide SCM_TIMESTAMPING"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let duration = timestamp.duration_since(UNIX_EPOCH).unwrap();
|
||||
assert!(duration.as_nanos() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_rx_timestamp_and_traffic_class_coexist() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"both").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
assert_eq!(&buf[..datagram.len], b"both");
|
||||
|
||||
// Both cmsgs are requested on this socket; the parser must not
|
||||
// depend on the order the kernel emits them in to observe either.
|
||||
if datagram.meta.kernel_rx_timestamp.is_none() {
|
||||
eprintln!("skipping combined assertion: kernel did not provide SCM_TIMESTAMPING");
|
||||
return;
|
||||
}
|
||||
if datagram.meta.traffic_class.is_none() {
|
||||
eprintln!("skipping combined assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
}
|
||||
assert!(datagram.meta.kernel_rx_timestamp.is_some());
|
||||
assert_eq!(datagram.meta.traffic_class.unwrap() & 0xfc, 184);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_traffic_class_metadata_is_observed_when_kernel_provides_it() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"dscp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!("skipping IPv4 ancillary metadata assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class & 0xfc, 184);
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_traffic_class_metadata_is_observed_when_kernel_provides_it() {
|
||||
let Some((sender, receiver)) = connected_ipv6_loopback_pair() else {
|
||||
return;
|
||||
};
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
apply_traffic_class_to_socket(&sender, receiver.local_addr().unwrap(), 184).unwrap();
|
||||
sender.send(b"dscp").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!(
|
||||
"skipping IPv6 ancillary metadata assertion: kernel did not provide IPV6_TCLASS"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class & 0xfc, 184);
|
||||
assert_eq!(packet_meta.dscp, Some(46));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_zero_traffic_class_preserves_some_zero() {
|
||||
let (sender, receiver) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&receiver, sender.local_addr().unwrap()).unwrap();
|
||||
sender.send(b"zero").unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let datagram = recv_datagram(&receiver, &mut buf).unwrap();
|
||||
let Some(traffic_class) = datagram.meta.traffic_class else {
|
||||
eprintln!("skipping observed-zero assertion: kernel did not provide IP_TOS");
|
||||
return;
|
||||
};
|
||||
|
||||
let packet_meta = PacketMeta::from(datagram.meta);
|
||||
assert_eq!(traffic_class, 0);
|
||||
assert_eq!(packet_meta.traffic_class, Some(0));
|
||||
assert_eq!(packet_meta.dscp, Some(0));
|
||||
assert_eq!(packet_meta.ecn, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timespec_conversion_accepts_valid_unix_timestamp() {
|
||||
let timestamp = super::system_time_from_timespec(TimeSpec::new(1, 2)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
timestamp.duration_since(UNIX_EPOCH).unwrap(),
|
||||
Duration::new(1, 2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timespec_conversion_rejects_negative_or_invalid_values() {
|
||||
assert_eq!(super::system_time_from_timespec(TimeSpec::new(-1, 0)), None);
|
||||
assert_eq!(super::system_time_from_timespec(TimeSpec::new(0, -1)), None);
|
||||
assert_eq!(
|
||||
super::system_time_from_timespec(TimeSpec::new(0, 1_000_000_000)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn is_unavailable_ipv6_loopback(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_enable_tx_timestamping_then_drain_reports_ids_through_the_adapter_api() {
|
||||
let (socket, _peer) = connected_ipv4_loopback_pair();
|
||||
// Mirrors production: RX-only first, then a best-effort upgrade.
|
||||
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
|
||||
|
||||
if !super::try_enable_tx_timestamping(&socket) {
|
||||
eprintln!(
|
||||
"skipping adapter-level TX timestamping smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = socket.send(b"one") {
|
||||
eprintln!("skipping adapter-level TX timestamping smoke test: send failed: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
let mut observed = None;
|
||||
while observed.is_none() && std::time::Instant::now() < deadline {
|
||||
super::drain_tx_timestamps(&socket, |id, timestamp| {
|
||||
observed = Some((id, timestamp));
|
||||
})
|
||||
.unwrap();
|
||||
std::thread::yield_now();
|
||||
}
|
||||
|
||||
let Some((id, timestamp)) = observed else {
|
||||
eprintln!(
|
||||
"skipping adapter-level TX timestamping smoke test: no TX timestamp record \
|
||||
arrived within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert_eq!(id, 0);
|
||||
assert!(timestamp.duration_since(UNIX_EPOCH).unwrap() > Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_tx_timestamps_is_a_bounded_nonblocking_no_op_on_an_untouched_socket() {
|
||||
let (socket, _peer) = connected_ipv4_loopback_pair();
|
||||
configure_receive_metadata(&socket, socket.peer_addr().unwrap()).unwrap();
|
||||
|
||||
let mut calls = 0_usize;
|
||||
super::drain_tx_timestamps(&socket, |_, _| calls += 1).unwrap();
|
||||
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,7 @@ pub(crate) fn classify(cmsgs: impl Iterator<Item = ControlMessageOwned>) -> Erro
|
||||
|
||||
for cmsg in cmsgs {
|
||||
match cmsg {
|
||||
ControlMessageOwned::Ipv4RecvErr(err, _) => {
|
||||
extended_error = Some((err.ee_errno, err.ee_origin, err.ee_info, err.ee_data));
|
||||
}
|
||||
ControlMessageOwned::Ipv6RecvErr(err, _) => {
|
||||
ControlMessageOwned::Ipv4RecvErr(err, _) | ControlMessageOwned::Ipv6RecvErr(err, _) => {
|
||||
extended_error = Some((err.ee_errno, err.ee_origin, err.ee_info, err.ee_data));
|
||||
}
|
||||
ControlMessageOwned::ScmTimestampsns(observed) => {
|
||||
@@ -145,12 +142,6 @@ pub(crate) fn classify(cmsgs: impl Iterator<Item = ControlMessageOwned>) -> Erro
|
||||
#[repr(align(8))]
|
||||
struct ErrorQueueControlBuffer([u8; ERROR_QUEUE_CONTROL_LEN]);
|
||||
|
||||
impl ErrorQueueControlBuffer {
|
||||
fn new() -> Self {
|
||||
Self([0; ERROR_QUEUE_CONTROL_LEN])
|
||||
}
|
||||
}
|
||||
|
||||
/// Nonblocking drain of a single record from `fd`'s `MSG_ERRQUEUE`.
|
||||
///
|
||||
/// Returns `Ok(None)` when the queue is empty (`EAGAIN`/`EWOULDBLOCK`) — the
|
||||
@@ -171,7 +162,7 @@ impl ErrorQueueControlBuffer {
|
||||
pub(crate) fn try_recv_error_queue_record(fd: RawFd) -> io::Result<Option<ErrorQueueRecord>> {
|
||||
let mut payload: [u8; 0] = [];
|
||||
let mut iov = [IoSliceMut::new(&mut payload)];
|
||||
let mut control = ErrorQueueControlBuffer::new();
|
||||
let mut control = ErrorQueueControlBuffer([0; ERROR_QUEUE_CONTROL_LEN]);
|
||||
|
||||
match recvmsg::<()>(
|
||||
fd,
|
||||
@@ -190,448 +181,3 @@ pub(crate) fn try_recv_error_queue_record(fd: RawFd) -> io::Result<Option<ErrorQ
|
||||
Err(errno) => Err(io::Error::from(errno)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
net::{SocketAddr, UdpSocket},
|
||||
os::fd::AsRawFd,
|
||||
time::{Duration, Instant, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nix::sys::{
|
||||
socket::{setsockopt, sockopt},
|
||||
time::TimeSpec,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn extended_error(
|
||||
errno: u32,
|
||||
origin: u8,
|
||||
info: u32,
|
||||
data: u32,
|
||||
) -> nix::libc::sock_extended_err {
|
||||
nix::libc::sock_extended_err {
|
||||
ee_errno: errno,
|
||||
ee_origin: origin,
|
||||
ee_type: 0,
|
||||
ee_code: 0,
|
||||
ee_pad: 0,
|
||||
ee_info: info,
|
||||
ee_data: data,
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamps_with_system(system: TimeSpec) -> Timestamps {
|
||||
Timestamps {
|
||||
system,
|
||||
hw_trans: TimeSpec::new(0, 0),
|
||||
hw_raw: TimeSpec::new(0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_timestamp_error(id: u32) -> ControlMessageOwned {
|
||||
ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ENOMSG as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
SCM_TSTAMP_SND,
|
||||
id,
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_valid_send_timestamp_completion() {
|
||||
let cmsgs = vec![
|
||||
send_timestamp_error(7),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(1, 2))),
|
||||
];
|
||||
|
||||
let record = classify(cmsgs.into_iter());
|
||||
|
||||
assert_eq!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp {
|
||||
id: 7,
|
||||
timestamp: UNIX_EPOCH + Duration::new(1, 2),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamp_origin_without_timestamp_as_malformed() {
|
||||
let cmsgs = vec![send_timestamp_error(3)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamp_origin_with_invalid_timespec_as_malformed() {
|
||||
let cmsgs = vec![
|
||||
send_timestamp_error(3),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(-1, 0))),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_non_timestamp_extended_error_as_socket_error() {
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ECONNREFUSED as u32,
|
||||
nix::libc::SO_EE_ORIGIN_ICMP,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::SocketError {
|
||||
errno: nix::libc::ECONNREFUSED,
|
||||
origin: nix::libc::SO_EE_ORIGIN_ICMP,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamping_origin_with_wrong_completion_kind_as_malformed() {
|
||||
// Same origin byte as a send-timestamp completion, but a different
|
||||
// `ee_info` (e.g. SCM_TSTAMP_SCHED/ACK, which this client never
|
||||
// requests). A timestamping-origin record must never become a
|
||||
// fatal SocketError merely because it reports a completion kind we
|
||||
// did not ask for; it stays timing metadata.
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ENOMSG as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
1,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_timestamping_origin_with_unexpected_errno_as_malformed() {
|
||||
// Origin is the sole discriminator: even an origin==TIMESTAMPING
|
||||
// record with a surprising errno (the kernel always reports ENOMSG
|
||||
// for this origin, but classification must not assume that) is
|
||||
// still timing metadata, never a real socket error.
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4RecvErr(
|
||||
extended_error(
|
||||
nix::libc::ECONNREFUSED as u32,
|
||||
nix::libc::SO_EE_ORIGIN_TIMESTAMPING,
|
||||
SCM_TSTAMP_SND,
|
||||
0,
|
||||
),
|
||||
None,
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::MalformedOrUnsupportedTimestamp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_records_without_any_extended_error() {
|
||||
let cmsgs = vec![ControlMessageOwned::Ipv4Tos(0)];
|
||||
|
||||
assert_eq!(classify(cmsgs.into_iter()), ErrorQueueRecord::Ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cmsgs_do_not_affect_classification() {
|
||||
let cmsgs = vec![
|
||||
ControlMessageOwned::Ipv4Tos(0),
|
||||
send_timestamp_error(4),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(9, 0))),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
classify(cmsgs.into_iter()),
|
||||
ErrorQueueRecord::TxTimestamp {
|
||||
id: 4,
|
||||
timestamp: UNIX_EPOCH + Duration::new(9, 0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmsg_order_does_not_affect_classification() {
|
||||
let forward = classify(
|
||||
vec![
|
||||
send_timestamp_error(9),
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(5, 0))),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
let reversed = classify(
|
||||
vec![
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps_with_system(TimeSpec::new(5, 0))),
|
||||
send_timestamp_error(9),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(forward, reversed);
|
||||
}
|
||||
|
||||
/// Bounded poll for one error-queue record, since software TX timestamp
|
||||
/// delivery is asynchronous even on loopback. Never sleeps hoping
|
||||
/// delivery "probably" happened; it keeps polling until either a
|
||||
/// record appears or the overall deadline passes.
|
||||
fn poll_for_record(fd: RawFd, overall_timeout: Duration) -> Option<ErrorQueueRecord> {
|
||||
let deadline = Instant::now() + overall_timeout;
|
||||
loop {
|
||||
if let Some(record) = try_recv_error_queue_record(fd).unwrap() {
|
||||
return Some(record);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
}
|
||||
|
||||
fn tx_timestamped_loopback_socket() -> Option<(UdpSocket, UdpSocket)> {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
// Held for the caller's lifetime: if this peer socket were dropped,
|
||||
// its port would close and subsequent sends would fail with
|
||||
// ECONNREFUSED once the kernel reports ICMP port-unreachable.
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(peer.local_addr().unwrap()).unwrap();
|
||||
|
||||
match setsockopt(&socket, sockopt::Timestamping, &TX_TIMESTAMPING_FLAGS) {
|
||||
Ok(()) => Some((socket, peer)),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"skipping TX timestamping smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_opt_id_increments_sequentially_across_separate_sends() {
|
||||
let Some((socket, _peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
let fd = socket.as_raw_fd();
|
||||
|
||||
// Under heavy parallel test load, loopback UDP sends can
|
||||
// occasionally surface a stale ICMP-derived socket error unrelated
|
||||
// to this test's own sockets. That is an environmental condition,
|
||||
// not a claim this test makes about kernel TX timestamp behavior,
|
||||
// so it is a skip rather than a failure.
|
||||
if let Err(error) = socket.send(b"first") {
|
||||
eprintln!("skipping automatic OPT_ID assertion: first send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(first) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping automatic OPT_ID assertion: no TX timestamp record was \
|
||||
delivered within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(first, ErrorQueueRecord::TxTimestamp { id: 0, .. }),
|
||||
"expected first send to report id 0, got {first:?}"
|
||||
);
|
||||
|
||||
if let Err(error) = socket.send(b"second") {
|
||||
eprintln!("skipping automatic OPT_ID assertion: second send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(second) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping automatic OPT_ID assertion: no TX timestamp record was \
|
||||
delivered for the second send within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(second, ErrorQueueRecord::TxTimestamp { id: 1, .. }),
|
||||
"expected second send to report id 1, got {second:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rx_only_sends_before_tx_enable_do_not_consume_the_later_tx_id_namespace() {
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(peer.local_addr().unwrap()).unwrap();
|
||||
setsockopt(
|
||||
&socket,
|
||||
sockopt::Timestamping,
|
||||
&super::super::RX_TIMESTAMPING_FLAGS,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Ordinary pre-enable sends: TX_SOFTWARE is not yet requested, so
|
||||
// none of these may leave anything on the error queue for the
|
||||
// later-enabled OPT_ID counter to have skipped past.
|
||||
for _ in 0..3 {
|
||||
if let Err(error) = socket.send(b"pre-enable") {
|
||||
eprintln!("skipping pre-enable send/RX-only smoke test: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
try_recv_error_queue_record(socket.as_raw_fd()).unwrap(),
|
||||
None,
|
||||
"RX-only sends must never populate MSG_ERRQUEUE"
|
||||
);
|
||||
|
||||
// This mirrors production: RX-only is configured first (e.g. at
|
||||
// connect time), and only later, after a successful Open, is the
|
||||
// socket upgraded to TX_TIMESTAMPING_FLAGS.
|
||||
if setsockopt(&socket, sockopt::Timestamping, &TX_TIMESTAMPING_FLAGS).is_err() {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: kernel/container denied \
|
||||
SO_TIMESTAMPING TX flags"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = socket.send(b"first-timestamped") {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: post-upgrade send failed: {error}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping pre-enable send/RX-only smoke test: no TX timestamp record was \
|
||||
delivered within the poll deadline"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
matches!(record, ErrorQueueRecord::TxTimestamp { id: 0, .. }),
|
||||
"expected the first send after upgrade to report id 0 (pre-enable sends must not \
|
||||
have consumed part of the namespace), got {record:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_rx_scm_timestamping_still_works_after_tx_flags_are_enabled() {
|
||||
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
peer.send_to(b"reply", socket.local_addr().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let mut buf = [0_u8; 16];
|
||||
let Ok(datagram) = super::super::recv_datagram(&socket, &mut buf) else {
|
||||
eprintln!("skipping post-TX-enable RX smoke test: receive failed");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&buf[..datagram.len], b"reply");
|
||||
let Some(timestamp) = datagram.meta.kernel_rx_timestamp else {
|
||||
eprintln!(
|
||||
"skipping post-TX-enable RX smoke test: kernel did not provide SCM_TIMESTAMPING"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(timestamp.duration_since(std::time::UNIX_EPOCH).unwrap() > Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_timestamp_and_normal_reply_streams_do_not_interfere() {
|
||||
let Some((socket, peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
let fd = socket.as_raw_fd();
|
||||
|
||||
if let Err(error) = socket.send(b"ping") {
|
||||
eprintln!("skipping TX/RX separation smoke test: ping send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let mut incoming = [0_u8; 16];
|
||||
let Ok((len, from)) = peer.recv_from(&mut incoming) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: peer never observed ping");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&incoming[..len], b"ping");
|
||||
peer.send_to(b"pong", from).unwrap();
|
||||
|
||||
// Reading the normal payload must not require, wait for, or be
|
||||
// disturbed by draining MSG_ERRQUEUE first.
|
||||
let mut reply_buf = [0_u8; 16];
|
||||
let Ok(datagram) = super::super::recv_datagram(&socket, &mut reply_buf) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: normal receive failed");
|
||||
return;
|
||||
};
|
||||
assert_eq!(&reply_buf[..datagram.len], b"pong");
|
||||
|
||||
// The TX timestamp for "ping" is still independently available on
|
||||
// the error queue, unconsumed by the normal receive above.
|
||||
let Some(record) = poll_for_record(fd, Duration::from_secs(2)) else {
|
||||
eprintln!("skipping TX/RX separation smoke test: no TX timestamp record arrived");
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp { id: 0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_timestamp_correlation_does_not_depend_on_original_payload() {
|
||||
let Some((socket, _peer)) = tx_timestamped_loopback_socket() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// OPT_TSONLY notifications never carry the original payload; a
|
||||
// larger payload must classify identically to the zero-length one
|
||||
// used elsewhere, since correlation is by `id` alone.
|
||||
let payload = vec![0x5A_u8; 512];
|
||||
if let Err(error) = socket.send(&payload) {
|
||||
eprintln!("skipping OPT_TSONLY payload-independence smoke test: send failed: {error}");
|
||||
return;
|
||||
}
|
||||
let Some(record) = poll_for_record(socket.as_raw_fd(), Duration::from_secs(2)) else {
|
||||
eprintln!(
|
||||
"skipping OPT_TSONLY payload-independence smoke test: no TX timestamp record \
|
||||
arrived"
|
||||
);
|
||||
return;
|
||||
};
|
||||
assert!(matches!(
|
||||
record,
|
||||
ErrorQueueRecord::TxTimestamp { id: 0, .. }
|
||||
));
|
||||
}
|
||||
|
||||
// NOTE: There is no live test for whether a failed nonblocking `sendmsg`
|
||||
// consumes an automatic OPT_ID. Forcing a reliable, non-flaky `WouldBlock`
|
||||
// on a connected loopback UDP socket (for example through socket-buffer
|
||||
// saturation) has no deterministic OS-level mechanism in this environment,
|
||||
// and the repository's testing policy forbids sleep/saturation-based
|
||||
// flakiness. The correlation is intentionally best effort: a kernel ID can
|
||||
// theoretically be consumed by a send that later fails. The deterministic
|
||||
// adapter test covers only the local half: an injected `WouldBlock` does
|
||||
// not advance the local wire-sequence counter. Any later unmatched kernel
|
||||
// timestamp is discarded and the userspace `sent_at` remains the fallback.
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -346,180 +346,3 @@ fn record_restriction(
|
||||
restrictions.push(restriction);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_proto::{Clock, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
fn default_params() -> Params {
|
||||
Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
length: 256,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
// Raw wire TOS/Traffic Class byte for DSCP codepoint 46 (EF).
|
||||
dscp: 184,
|
||||
server_fill: Some(ServerFill {
|
||||
value: "rand".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_rejected(requested: &Params, returned: &Params, policy: NegotiationPolicy) {
|
||||
assert!(matches!(
|
||||
negotiate_params(requested, returned.clone(), policy),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
}
|
||||
|
||||
fn assert_negotiates(
|
||||
requested: &Params,
|
||||
returned: &Params,
|
||||
policy: NegotiationPolicy,
|
||||
) -> NegotiatedParams {
|
||||
negotiate_params(requested, returned.clone(), policy)
|
||||
.unwrap_or_else(|err| panic!("expected negotiation success, got {err:?}"))
|
||||
}
|
||||
|
||||
fn rejection_reason(
|
||||
requested: &Params,
|
||||
returned: &Params,
|
||||
policy: NegotiationPolicy,
|
||||
) -> String {
|
||||
match negotiate_params(requested, returned.clone(), policy) {
|
||||
Err(ClientError::NegotiationRejected { reason }) => reason,
|
||||
other => panic!("expected negotiation rejection, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rejects_changed_negotiated_fields() {
|
||||
let requested = default_params();
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.length = 128;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::LengthReduced {
|
||||
requested: requested.length,
|
||||
negotiated: returned.length,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 0;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::DscpChanged {
|
||||
requested: requested.dscp >> 2,
|
||||
negotiated: returned.dscp >> 2,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.received_stats = ReceivedStats::Count;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::ReceivedStatsChanged {
|
||||
requested: requested.received_stats,
|
||||
negotiated: returned.received_stats,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.stamp_at = StampAt::Midpoint;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::StampAtChanged {
|
||||
requested: requested.stamp_at,
|
||||
negotiated: returned.stamp_at,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.clock = Clock::Wall;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::ClockChanged {
|
||||
requested: requested.clock,
|
||||
negotiated: returned.clock,
|
||||
}
|
||||
.message()
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.server_fill = None;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Strict),
|
||||
NegotiationRestriction::ServerFillChanged.message()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_duration_negotiation_uses_run_duration_semantics() {
|
||||
let requested = default_params();
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = requested.duration_ns / 2;
|
||||
let negotiated = assert_negotiates(&requested, &returned, NegotiationPolicy::Loose);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested.duration_ns,
|
||||
negotiated_ns: returned.duration_ns,
|
||||
}]
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = requested.duration_ns + 1;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"duration increased"
|
||||
);
|
||||
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns = 0;
|
||||
assert_eq!(
|
||||
rejection_reason(&requested, &returned, NegotiationPolicy::Loose),
|
||||
"server returned continuous duration for finite request"
|
||||
);
|
||||
|
||||
let mut continuous_requested = requested.clone();
|
||||
continuous_requested.duration_ns = 0;
|
||||
let mut finite_returned = continuous_requested.clone();
|
||||
finite_returned.duration_ns = 1_000_000_000;
|
||||
let negotiated = assert_negotiates(
|
||||
&continuous_requested,
|
||||
&finite_returned,
|
||||
NegotiationPolicy::Loose,
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![NegotiationRestriction::DurationReduced {
|
||||
requested_ns: 0,
|
||||
negotiated_ns: finite_returned.duration_ns,
|
||||
}]
|
||||
);
|
||||
|
||||
assert_rejected(
|
||||
&continuous_requested,
|
||||
&finite_returned,
|
||||
NegotiationPolicy::Strict,
|
||||
);
|
||||
|
||||
assert!(negotiate_params(
|
||||
&continuous_requested,
|
||||
continuous_requested.clone(),
|
||||
NegotiationPolicy::Strict
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,14 @@ use crate::{
|
||||
socket_options::apply_ttl_to_socket,
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
use crate::socket_options::apply_routing_options;
|
||||
|
||||
pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), ClientError> {
|
||||
if timeouts.is_empty() {
|
||||
return Err(ClientError::NoOpenTimeouts);
|
||||
@@ -28,9 +36,6 @@ pub(crate) fn validate_open_timeouts(timeouts: &[Duration]) -> Result<(), Client
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_remote(config: &ClientConfig) -> Result<SocketAddr, ClientError> {
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
SYNC_RESOLVER_CALLS.with(|calls| calls.set(calls.get() + 1));
|
||||
|
||||
let addr = normalize_server_addr(&config.server_addr);
|
||||
let mut addrs = addr
|
||||
.to_socket_addrs()
|
||||
@@ -49,9 +54,6 @@ pub(crate) async fn resolve_remote_tokio(config: &ClientConfig) -> Result<Socket
|
||||
.ok_or(ClientError::Resolve { addr });
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
TOKIO_DNS_LOOKUPS.with(|calls| calls.set(calls.get() + 1));
|
||||
|
||||
let mut addrs = tokio::net::lookup_host(&addr)
|
||||
.await
|
||||
.map_err(|_| ClientError::Resolve { addr: addr.clone() })?;
|
||||
@@ -65,20 +67,6 @@ fn address_family_allowed(config: &ClientConfig, remote: SocketAddr) -> bool {
|
||||
&& (!config.socket_config.ipv6_only || remote.is_ipv6())
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
std::thread_local! {
|
||||
static SYNC_RESOLVER_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
static TOKIO_DNS_LOOKUPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
pub(crate) fn resolution_call_counts() -> (usize, usize) {
|
||||
(
|
||||
SYNC_RESOLVER_CALLS.with(std::cell::Cell::get),
|
||||
TOKIO_DNS_LOOKUPS.with(std::cell::Cell::get),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_server_addr(addr: &str) -> String {
|
||||
if addr.parse::<SocketAddr>().is_ok() {
|
||||
return addr.to_owned();
|
||||
@@ -115,6 +103,13 @@ fn create_connected_udp_socket(
|
||||
remote: SocketAddr,
|
||||
) -> Result<UdpSocket, ClientError> {
|
||||
let socket = create_prebind_udp_socket(config, remote)?;
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
apply_routing_options(&socket, config, remote)?;
|
||||
let bind_addr = config.bind_addr.unwrap_or_else(|| {
|
||||
if remote.is_ipv4() {
|
||||
SocketAddr::from(([0, 0, 0, 0], 0))
|
||||
@@ -163,207 +158,3 @@ pub(crate) fn connect_tokio_udp_socket(
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok(tokio::net::UdpSocket::from_std(socket)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_server_addr_adds_default_ports() {
|
||||
assert_eq!(normalize_server_addr("127.0.0.1"), "127.0.0.1:2112");
|
||||
assert_eq!(normalize_server_addr("127.0.0.1:1234"), "127.0.0.1:1234");
|
||||
assert_eq!(normalize_server_addr("localhost"), "localhost:2112");
|
||||
assert_eq!(normalize_server_addr("localhost:1234"), "localhost:1234");
|
||||
assert_eq!(normalize_server_addr("::1"), "[::1]:2112");
|
||||
assert_eq!(normalize_server_addr("[::1]"), "[::1]:2112");
|
||||
assert_eq!(normalize_server_addr("[::1]:1234"), "[::1]:1234");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_family_filtering_remains_unchanged() {
|
||||
let ipv4 = SocketAddr::from(([127, 0, 0, 1], DEFAULT_PORT));
|
||||
let ipv6 = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], DEFAULT_PORT));
|
||||
let mut config = ClientConfig::default();
|
||||
|
||||
assert!(address_family_allowed(&config, ipv4));
|
||||
assert!(address_family_allowed(&config, ipv6));
|
||||
|
||||
config.socket_config.ipv4_only = true;
|
||||
assert!(address_family_allowed(&config, ipv4));
|
||||
assert!(!address_family_allowed(&config, ipv6));
|
||||
|
||||
config.socket_config.ipv4_only = false;
|
||||
config.socket_config.ipv6_only = true;
|
||||
assert!(!address_family_allowed(&config, ipv4));
|
||||
assert!(address_family_allowed(&config, ipv6));
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv6_only_is_applied_before_bind() {
|
||||
let config = SocketConfig {
|
||||
ipv6_only: true,
|
||||
..SocketConfig::default()
|
||||
};
|
||||
let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], DEFAULT_PORT));
|
||||
|
||||
let socket = create_prebind_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket.only_v6().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv6_socket_preserves_v6only_and_explicit_bind() {
|
||||
let bind_addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0));
|
||||
let peer = match UdpSocket::bind(bind_addr) {
|
||||
Ok(peer) => peer,
|
||||
Err(error) if ipv6_loopback_unavailable(&error) => {
|
||||
eprintln!("skipping IPv6 Tokio socket test: IPv6 loopback unavailable: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
let remote = peer.local_addr().unwrap();
|
||||
let config = SocketConfig {
|
||||
bind_addr: Some(bind_addr),
|
||||
ipv6_only: true,
|
||||
..SocketConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let socket = connect_tokio_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket2::SockRef::from(&socket).only_v6().unwrap());
|
||||
assert!(socket.local_addr().unwrap().is_ipv6());
|
||||
assert_eq!(socket.local_addr().unwrap().ip(), bind_addr.ip());
|
||||
assert_eq!(socket.peer_addr().unwrap(), remote);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_ipv4_socket_preserves_family_bind_peer_and_ttl() {
|
||||
let peer = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
let remote = peer.local_addr().unwrap();
|
||||
let bind_addr = SocketAddr::from(([127, 0, 0, 1], 0));
|
||||
let config = SocketConfig {
|
||||
bind_addr: Some(bind_addr),
|
||||
ttl: Some(64),
|
||||
..SocketConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let socket = connect_tokio_udp_socket(&config, remote).unwrap();
|
||||
|
||||
assert!(socket.local_addr().unwrap().is_ipv4());
|
||||
assert_eq!(socket.local_addr().unwrap().ip(), bind_addr.ip());
|
||||
assert_eq!(socket.peer_addr().unwrap(), remote);
|
||||
assert_eq!(socket2::SockRef::from(&socket).ttl_v4().unwrap(), 64);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_filters_families_without_using_blocking_resolution() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "localhost:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv4_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let before = resolution_call_counts();
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
let after = resolution_call_counts();
|
||||
|
||||
assert!(remote.is_ipv4());
|
||||
assert_eq!(after.0, before.0);
|
||||
assert_eq!(after.1, before.1 + 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_selects_ipv6_for_ipv6_only_hostname() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "localhost:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
assert!(remote.is_ipv6());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_literal_path_bypasses_all_name_resolution() {
|
||||
let config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
let before = resolution_call_counts();
|
||||
let remote = resolve_remote_tokio(&config).await.unwrap();
|
||||
let after = resolution_call_counts();
|
||||
|
||||
assert_eq!(remote, SocketAddr::from(([127, 0, 0, 1], 2112)));
|
||||
assert_eq!(after, before);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_rejects_disallowed_literal_family() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
assert!(matches!(
|
||||
resolve_remote_tokio(&config).await,
|
||||
Err(ClientError::Resolve { .. })
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn tokio_resolver_rejects_mixed_family_restrictions() {
|
||||
let mut config = ClientConfig {
|
||||
server_addr: "127.0.0.1:2112".to_owned(),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
config.socket_config.ipv4_only = true;
|
||||
config.socket_config.ipv6_only = true;
|
||||
|
||||
tokio_runtime().block_on(async {
|
||||
assert!(matches!(
|
||||
resolve_remote_tokio(&config).await,
|
||||
Err(ClientError::Resolve { .. })
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn tokio_runtime() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn ipv6_loopback_unavailable(error: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,64 @@ use std::{
|
||||
|
||||
use socket2::SockRef;
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
use socket2::Socket;
|
||||
|
||||
use crate::{
|
||||
config::{MAX_DSCP_CODEPOINT, MAX_TTL},
|
||||
error::ClientError,
|
||||
};
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux"
|
||||
))]
|
||||
pub(crate) fn apply_routing_options(
|
||||
socket: &Socket,
|
||||
config: &crate::SocketConfig,
|
||||
remote: SocketAddr,
|
||||
) -> Result<(), ClientError> {
|
||||
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
|
||||
{
|
||||
if let Some(device) = &config.bind_to_device {
|
||||
socket
|
||||
.bind_device(Some(device.as_bytes()))
|
||||
.map_err(|source| ClientError::SocketOption {
|
||||
operation: "bind socket to device",
|
||||
remote,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
if let Some(mark) = config.mark {
|
||||
socket
|
||||
.set_mark(mark)
|
||||
.map_err(|source| ClientError::SocketOption {
|
||||
operation: "set firewall mark",
|
||||
remote,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "freebsd")]
|
||||
if let Some(fib) = config.fib {
|
||||
socket
|
||||
.set_fib(fib)
|
||||
.map_err(|source| ClientError::SocketOption {
|
||||
operation: "set FIB",
|
||||
remote,
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts a public DSCP codepoint (`0..=`[`MAX_DSCP_CODEPOINT`]) into the
|
||||
/// raw IP TOS / Traffic Class byte it occupies the upper six bits of.
|
||||
///
|
||||
@@ -87,52 +140,15 @@ pub(crate) fn apply_ttl_to_socket(
|
||||
ttl: u32,
|
||||
) -> Result<(), ClientError> {
|
||||
validate_ttl(ttl)?;
|
||||
set_socket_ttl(socket, remote, ttl).map_err(|source| ClientError::SocketOption {
|
||||
operation: "set TTL/hop limit",
|
||||
remote,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn socket_traffic_class(
|
||||
socket: &UdpSocket,
|
||||
remote: SocketAddr,
|
||||
) -> Result<u32, ClientError> {
|
||||
get_socket_traffic_class(socket, remote).map_err(|source| ClientError::SocketOption {
|
||||
operation: "read DSCP socket option",
|
||||
remote,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
pub(crate) fn tokio_socket_traffic_class(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
remote: SocketAddr,
|
||||
) -> Result<u32, ClientError> {
|
||||
get_socket_traffic_class_ref(SockRef::from(socket), remote).map_err(|source| {
|
||||
set_socket_ttl_ref(SockRef::from(socket), remote, ttl).map_err(|source| {
|
||||
ClientError::SocketOption {
|
||||
operation: "read DSCP socket option",
|
||||
operation: "set TTL/hop limit",
|
||||
remote,
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn socket_ttl(socket: &UdpSocket, remote: SocketAddr) -> Result<u32, ClientError> {
|
||||
get_socket_ttl(socket, remote).map_err(|source| ClientError::SocketOption {
|
||||
operation: "read TTL/hop limit socket option",
|
||||
remote,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_socket_ttl(socket: &UdpSocket, remote: SocketAddr, ttl: u32) -> io::Result<()> {
|
||||
set_socket_ttl_ref(SockRef::from(socket), remote, ttl)
|
||||
}
|
||||
|
||||
fn set_socket_ttl_ref(socket: SockRef<'_>, remote: SocketAddr, ttl: u32) -> io::Result<()> {
|
||||
if remote.is_ipv4() {
|
||||
socket.set_ttl_v4(ttl)
|
||||
@@ -141,23 +157,13 @@ fn set_socket_ttl_ref(socket: SockRef<'_>, remote: SocketAddr, ttl: u32) -> io::
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_socket_ttl(socket: &UdpSocket, remote: SocketAddr) -> io::Result<u32> {
|
||||
let socket = SockRef::from(socket);
|
||||
if remote.is_ipv4() {
|
||||
socket.ttl_v4()
|
||||
} else {
|
||||
socket.unicast_hops_v6()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_socket_traffic_class(
|
||||
socket: &UdpSocket,
|
||||
remote: SocketAddr,
|
||||
traffic_class: u32,
|
||||
operation: &'static str,
|
||||
) -> Result<(), ClientError> {
|
||||
set_socket_traffic_class_io(socket, remote, traffic_class).map_err(|source| {
|
||||
set_socket_traffic_class_ref(SockRef::from(socket), remote, traffic_class).map_err(|source| {
|
||||
ClientError::SocketOption {
|
||||
operation,
|
||||
remote,
|
||||
@@ -166,14 +172,6 @@ fn set_socket_traffic_class(
|
||||
})
|
||||
}
|
||||
|
||||
fn set_socket_traffic_class_io(
|
||||
socket: &UdpSocket,
|
||||
remote: SocketAddr,
|
||||
traffic_class: u32,
|
||||
) -> io::Result<()> {
|
||||
set_socket_traffic_class_ref(SockRef::from(socket), remote, traffic_class)
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
fn set_tokio_socket_traffic_class(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
@@ -282,302 +280,3 @@ fn unsupported_traffic_class(traffic_class: u32, feature: &'static str) -> io::R
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
))
|
||||
))]
|
||||
fn get_ipv4_traffic_class(socket: SockRef<'_>) -> io::Result<u32> {
|
||||
socket.tos_v4()
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)
|
||||
))]
|
||||
fn get_ipv4_traffic_class(_socket: SockRef<'_>) -> io::Result<u32> {
|
||||
unsupported_readback("IPv4 DSCP socket option readback")
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
)
|
||||
))]
|
||||
fn get_ipv6_traffic_class(socket: SockRef<'_>) -> io::Result<u32> {
|
||||
socket.tclass_v6()
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
not(any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
))
|
||||
))]
|
||||
fn get_ipv6_traffic_class(_socket: SockRef<'_>) -> io::Result<u32> {
|
||||
unsupported_readback("IPv6 DSCP socket option readback")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_socket_traffic_class(socket: &UdpSocket, remote: SocketAddr) -> io::Result<u32> {
|
||||
get_socket_traffic_class_ref(SockRef::from(socket), remote)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_socket_traffic_class_ref(socket: SockRef<'_>, remote: SocketAddr) -> io::Result<u32> {
|
||||
if remote.is_ipv4() {
|
||||
get_ipv4_traffic_class(socket)
|
||||
} else {
|
||||
get_ipv6_traffic_class(socket)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
test,
|
||||
any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
not(any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
)),
|
||||
)
|
||||
))]
|
||||
fn unsupported_readback(feature: &'static str) -> io::Result<u32> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!("{feature} is unsupported on this target"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_dscp_codepoint_to_traffic_class_byte() {
|
||||
for (dscp, traffic_class) in [(0, 0), (1, 4), (46, 184), (63, 252)] {
|
||||
let value = dscp_codepoint_to_traffic_class(dscp).unwrap();
|
||||
assert_eq!(value, traffic_class);
|
||||
assert_eq!(value & 0b11, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_dscp_codepoint() {
|
||||
assert!(matches!(
|
||||
dscp_codepoint_to_traffic_class(64),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_ttl_range() {
|
||||
assert!(validate_ttl(1).is_ok());
|
||||
assert!(validate_ttl(64).is_ok());
|
||||
assert!(validate_ttl(255).is_ok());
|
||||
assert!(matches!(
|
||||
validate_ttl(0),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_ttl(256),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_socket_option_sets_ttl() {
|
||||
let remote = SocketAddr::from(([127, 0, 0, 1], 9));
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(remote).unwrap();
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 64).unwrap();
|
||||
assert_eq!(socket_ttl(&socket, remote).unwrap(), 64);
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 1).unwrap();
|
||||
assert_eq!(socket_ttl(&socket, remote).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_socket_option_sets_unicast_hop_limit() {
|
||||
let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9));
|
||||
let Some(socket) = bind_connected_ipv6_loopback(remote) else {
|
||||
return;
|
||||
};
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 64).unwrap();
|
||||
let ttl = match socket_ttl(&socket, remote) {
|
||||
Ok(ttl) => ttl,
|
||||
Err(error) if is_unsupported_socket_readback(&error) => {
|
||||
eprintln!("skipping IPv6 hop-limit readback test: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
assert_eq!(ttl, 64);
|
||||
|
||||
apply_ttl_to_socket(&socket, remote, 1).unwrap();
|
||||
assert_eq!(socket_ttl(&socket, remote).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn ipv4_socket_option_sets_and_clears_traffic_class() {
|
||||
let remote = SocketAddr::from(([127, 0, 0, 1], 9));
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(remote).unwrap();
|
||||
|
||||
// 184 is the raw wire TOS/Traffic Class byte for DSCP codepoint 46
|
||||
// (EF); applying it directly must not shift it again.
|
||||
apply_traffic_class_to_socket(&socket, remote, 184).unwrap();
|
||||
assert_eq!(socket_traffic_class(&socket, remote).unwrap(), 184);
|
||||
|
||||
clear_dscp_on_socket(&socket, remote).unwrap();
|
||||
assert_eq!(socket_traffic_class(&socket, remote).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
feature = "tokio",
|
||||
not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
))
|
||||
))]
|
||||
#[test]
|
||||
fn tokio_ipv4_socket_option_sets_and_clears_traffic_class() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
runtime.block_on(async {
|
||||
let remote = SocketAddr::from(([127, 0, 0, 1], 9));
|
||||
let socket = UdpSocket::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
|
||||
socket.connect(remote).unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let socket = tokio::net::UdpSocket::from_std(socket).unwrap();
|
||||
|
||||
apply_traffic_class_to_tokio_socket(&socket, remote, 184).unwrap();
|
||||
assert_eq!(tokio_socket_traffic_class(&socket, remote).unwrap(), 184);
|
||||
|
||||
clear_dscp_on_tokio_socket(&socket, remote).unwrap();
|
||||
assert_eq!(tokio_socket_traffic_class(&socket, remote).unwrap(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(
|
||||
target_os = "android",
|
||||
target_os = "dragonfly",
|
||||
target_os = "freebsd",
|
||||
target_os = "fuchsia",
|
||||
target_os = "linux",
|
||||
target_os = "macos",
|
||||
target_os = "netbsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "cygwin",
|
||||
))]
|
||||
fn ipv6_socket_option_sets_and_clears_traffic_class() {
|
||||
let remote = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 9));
|
||||
let Some(socket) = bind_connected_ipv6_loopback(remote) else {
|
||||
return;
|
||||
};
|
||||
|
||||
apply_traffic_class_to_socket(&socket, remote, 184).unwrap();
|
||||
let traffic_class = match socket_traffic_class(&socket, remote) {
|
||||
Ok(traffic_class) => traffic_class,
|
||||
Err(error) if is_unsupported_socket_readback(&error) => {
|
||||
eprintln!("skipping IPv6 traffic-class readback test: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
assert_eq!(traffic_class, 184);
|
||||
|
||||
clear_dscp_on_socket(&socket, remote).unwrap();
|
||||
assert_eq!(socket_traffic_class(&socket, remote).unwrap(), 0);
|
||||
}
|
||||
|
||||
fn bind_connected_ipv6_loopback(remote: SocketAddr) -> Option<UdpSocket> {
|
||||
let socket = match UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0))) {
|
||||
Ok(socket) => socket,
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!("skipping IPv6 socket option test: IPv6 loopback unavailable: {error}");
|
||||
return None;
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
};
|
||||
match socket.connect(remote) {
|
||||
Ok(()) => Some(socket),
|
||||
Err(error) if is_unavailable_ipv6_loopback(&error) => {
|
||||
eprintln!("skipping IPv6 socket option test: IPv6 loopback unavailable: {error}");
|
||||
None
|
||||
}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unavailable_ipv6_loopback(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
|
||||
fn is_unsupported_socket_readback(error: &ClientError) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
ClientError::SocketOption { source, .. }
|
||||
if matches!(source.kind(), io::ErrorKind::Unsupported)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use irtt_client::*;
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[path = "client/mod.rs"]
|
||||
mod client;
|
||||
+102
-338
@@ -1,204 +1,34 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_fails_after_close() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
fn operations_fail_after_local_close_without_datagrams() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.recv_timeout = Some(Duration::from_millis(50));
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyClosed)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_sends_one_close_packet_with_negotiated_token() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1_234),
|
||||
mono: Instant::now() + Duration::from_secs(5),
|
||||
};
|
||||
client.test_hooks.close_sent_at.set(Some(sent_at));
|
||||
let events = client.close().unwrap();
|
||||
assert!(matches!(
|
||||
events.as_slice(),
|
||||
[ClientEvent::SessionClosed {
|
||||
token: TOKEN,
|
||||
at,
|
||||
..
|
||||
}] if *at == sent_at
|
||||
));
|
||||
assert!(!client.runtime.is_open());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let close = &packets[1];
|
||||
assert_eq!(close[3], flags::FLAG_CLOSE);
|
||||
assert_eq!(u64::from_le_bytes(close[4..12].try_into().unwrap()), TOKEN);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_event_reservation_failure_precedes_dscp_clear_and_send() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_event_reserve.set(true);
|
||||
client.test_hooks.fail_close_dscp_clear.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.close(),
|
||||
Err(ClientError::AllocationFailed {
|
||||
operation: "close event result",
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
|
||||
assert!(client.test_hooks.fail_close_dscp_clear.get());
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
|
||||
client.test_hooks.fail_close_dscp_clear.set(false);
|
||||
client.close().unwrap();
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_dscp_clear_failure_precedes_send_and_preserves_open_state() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.test_hooks.fail_close_dscp_clear.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.close(),
|
||||
Err(ClientError::SocketOption { .. })
|
||||
));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_send_failure_leaves_machine_and_schedule_open_for_retry() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(2_345),
|
||||
mono: Instant::now() + Duration::from_secs(6),
|
||||
};
|
||||
client.test_hooks.close_sent_at.set(Some(sent_at));
|
||||
client.test_hooks.fail_close_send.set(true);
|
||||
|
||||
assert!(matches!(client.close(), Err(ClientError::Socket(_))));
|
||||
assert_eq!(client.test_hooks.close_sent_at.get(), Some(sent_at));
|
||||
assert!(client.runtime.is_open());
|
||||
assert!(client.schedule.is_some());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 1);
|
||||
|
||||
assert!(matches!(
|
||||
client.close().unwrap().as_slice(),
|
||||
[ClientEvent::SessionClosed {
|
||||
token: TOKEN,
|
||||
at,
|
||||
..
|
||||
}] if *at == sent_at
|
||||
));
|
||||
assert_eq!(client.test_hooks.close_sent_at.get(), None);
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 2);
|
||||
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_successful_close_commits_before_length_mismatch() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let expected = client.runtime.prepare_close().unwrap().bytes.len();
|
||||
let sent_at = ClientTimestamp {
|
||||
wall: SystemTime::UNIX_EPOCH + Duration::from_secs(3_456),
|
||||
mono: Instant::now() + Duration::from_secs(7),
|
||||
};
|
||||
client.test_hooks.close_sent_at.set(Some(sent_at));
|
||||
client.test_hooks.close_reported_len.set(Some(expected - 1));
|
||||
|
||||
assert!(matches!(
|
||||
client.close(),
|
||||
Err(ClientError::DatagramLengthMismatch {
|
||||
expected: error_expected,
|
||||
actual,
|
||||
}) if error_expected == expected && actual + 1 == expected
|
||||
));
|
||||
assert!(!client.runtime.is_open());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
assert_eq!(client.test_hooks.close_sent_at.get(), None);
|
||||
assert!(matches!(client.close(), Err(ClientError::AlreadyClosed)));
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::AlreadyClosed),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -232,135 +62,6 @@ fn send_probe_fails_after_close() {
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server({
|
||||
let params = params.clone();
|
||||
move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
let reply = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
while recv_request_timeout(&socket, &tx).is_some() {}
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(ClientEvent::EchoReply { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
events.get(1),
|
||||
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
|
||||
));
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
assert!(client.schedule.is_none());
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
));
|
||||
|
||||
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
|
||||
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_close_dscp_cleanup_failure_preserves_events_and_closed_state() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server({
|
||||
let params = params.clone();
|
||||
move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
socket
|
||||
.send_to(
|
||||
&echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
while recv_request_timeout(&socket, &tx).is_some() {}
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
})
|
||||
.unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
client.test_hooks.fail_close_dscp_clear.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.recv_once().unwrap().as_slice(),
|
||||
[
|
||||
ClientEvent::EchoReply { .. },
|
||||
ClientEvent::SessionClosed { token: TOKEN, .. }
|
||||
]
|
||||
));
|
||||
assert!(client.is_peer_closed());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, Some(0));
|
||||
assert_eq!(client.test_hooks.close_send_attempts.get(), 0);
|
||||
let open = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
let echo = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
assert_ne!(open[3] & FLAG_OPEN, 0);
|
||||
assert_eq!(echo[3] & flags::FLAG_CLOSE, 0);
|
||||
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_available_stops_after_peer_close() {
|
||||
let params = default_params();
|
||||
@@ -407,21 +108,6 @@ fn recv_available_stops_after_peer_close() {
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_echo_reply_does_not_close_session() {
|
||||
let params = default_params();
|
||||
let (mut client, server) = open_client_with_echo_server(¶ms);
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(events.as_slice(), [ClientEvent::EchoReply { .. }]));
|
||||
assert!(client.next_send_deadline().is_some());
|
||||
assert!(client.send_probe().is_ok());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_flagged_duplicate_emits_duplicate_then_closes() {
|
||||
let params = default_params();
|
||||
@@ -452,7 +138,7 @@ fn close_flagged_duplicate_emits_duplicate_then_closes() {
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -510,7 +196,7 @@ fn close_flagged_retained_timeout_emits_late_then_closes() {
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
probe_timeout: Duration::from_millis(50),
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -605,7 +291,7 @@ fn close_flagged_evicted_sequence_emits_late_then_closes() {
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
max_pending_probes: 1,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -669,7 +355,7 @@ fn close_flagged_untracked_sequence_emits_warning_then_closes() {
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -727,7 +413,7 @@ fn wrong_token_close_flag_does_not_close_session() {
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -752,3 +438,81 @@ fn wrong_token_close_flag_does_not_close_session() {
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server({
|
||||
let params = params.clone();
|
||||
move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
let reply = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
while recv_request_timeout(&socket, &tx).is_some() {}
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(ClientEvent::EchoReply { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
events.get(1),
|
||||
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
|
||||
));
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::AlreadyClosed),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
));
|
||||
|
||||
let first = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
let second = server.rx.recv_timeout(Duration::from_millis(100)).unwrap();
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
assert_eq!(second[3] & flags::FLAG_CLOSE, 0);
|
||||
assert!(server.rx.recv_timeout(Duration::from_millis(400)).is_err());
|
||||
server.join();
|
||||
}
|
||||
+4
-90
@@ -1,93 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_config_default() {
|
||||
let config = ClientConfig::default();
|
||||
assert_eq!(config.duration, Some(Duration::from_secs(3)));
|
||||
assert_eq!(config.interval, Duration::from_secs(1));
|
||||
assert_eq!(config.length, 0);
|
||||
assert_eq!(config.received_stats, ReceivedStats::Both);
|
||||
assert_eq!(config.stamp_at, StampAt::Both);
|
||||
assert_eq!(config.clock, Clock::Both);
|
||||
assert_eq!(config.dscp, 0);
|
||||
assert_eq!(config.hmac_key, None);
|
||||
assert_eq!(config.server_fill, None);
|
||||
assert_eq!(config.open_timeouts, DEFAULT_OPEN_TIMEOUTS);
|
||||
assert_eq!(config.run_mode, RunMode::Normal);
|
||||
assert_eq!(config.negotiation_policy, NegotiationPolicy::Strict);
|
||||
assert_eq!(config.probe_timeout, Duration::from_secs(4));
|
||||
assert_eq!(config.max_pending_probes, 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_maps_compatibility_fields() {
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_secs(5)),
|
||||
interval: Duration::from_millis(250),
|
||||
length: 1472,
|
||||
received_stats: ReceivedStats::Window,
|
||||
stamp_at: StampAt::Midpoint,
|
||||
clock: Clock::Wall,
|
||||
dscp: 46,
|
||||
hmac_key: Some(b"secret".to_vec()),
|
||||
server_fill: Some("rand".to_owned()),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
|
||||
let params = params_from_config(&config).unwrap();
|
||||
assert_eq!(params.protocol_version, PROTOCOL_VERSION);
|
||||
assert_eq!(params.duration_ns, 5_000_000_000);
|
||||
assert_eq!(params.interval_ns, 250_000_000);
|
||||
assert_eq!(params.length, 1472);
|
||||
assert_eq!(params.received_stats, ReceivedStats::Window);
|
||||
assert_eq!(params.stamp_at, StampAt::Midpoint);
|
||||
assert_eq!(params.clock, Clock::Wall);
|
||||
assert_eq!(
|
||||
params.dscp, 184,
|
||||
"config DSCP codepoint 46 must be shifted into raw wire byte 184"
|
||||
);
|
||||
assert_eq!(
|
||||
params.server_fill.as_ref().map(|fill| fill.value.as_str()),
|
||||
Some("rand")
|
||||
);
|
||||
assert_eq!(config.hmac_key.as_deref(), Some(b"secret".as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_accepts_boundary_values() {
|
||||
for length in [0, 1, 1472, 4096, MAX_UDP_PAYLOAD_LENGTH] {
|
||||
let config = ClientConfig {
|
||||
length,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
params_from_config(&config).unwrap().length,
|
||||
i64::from(length)
|
||||
);
|
||||
}
|
||||
|
||||
let config = ClientConfig {
|
||||
dscp: 63,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
params_from_config(&config).unwrap().dscp,
|
||||
252,
|
||||
"boundary codepoint 63 must be shifted into raw wire byte 252"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_encodes_continuous_duration_as_zero() {
|
||||
let config = ClientConfig {
|
||||
duration: None,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(params_from_config(&config).unwrap().duration_ns, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_invalid_values() {
|
||||
fn connect_rejects_invalid_configuration() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let too_large = Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1);
|
||||
let cases = [
|
||||
@@ -170,10 +84,10 @@ fn params_from_config_rejects_invalid_values() {
|
||||
for (name, config, expected_reason) in cases {
|
||||
assert!(
|
||||
matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason }) if reason.contains(expected_reason)
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
),
|
||||
"{name} should fail with InvalidConfig containing {expected_reason:?}"
|
||||
"{name} should fail with InvalidConfig ({expected_reason})"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use super::*;
|
||||
use irtt_client::{Authentication, HmacKey};
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_missing_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let missing = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&missing, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_ignores_bad_hmac_before_valid_reply() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let bad = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&bad, peer).unwrap();
|
||||
socket.send_to(&valid, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_hmac_negotiation_failure_sends_authenticated_cleanup_close() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key.clone()));
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let cleanup = &packets[1];
|
||||
assert_eq!(cleanup[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_packet_hmac(&key, cleanup).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(cleanup[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
use irtt_client::NegotiationPolicy;
|
||||
use irtt_client::{NegotiatedParams, RunMode, WarningKind, MAX_UDP_PAYLOAD_LENGTH};
|
||||
use irtt_proto::{flags, Params, TimestampFields};
|
||||
use irtt_proto::{
|
||||
flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_packet_hmac, Clock, HMAC_SIZE,
|
||||
MAGIC,
|
||||
};
|
||||
use std::thread;
|
||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
use std::time::SystemTime;
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
mod close;
|
||||
mod config;
|
||||
mod hmac;
|
||||
mod no_test;
|
||||
mod open;
|
||||
mod probes_replies;
|
||||
mod ttl;
|
||||
+23
-52
@@ -1,27 +1,10 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_test_success_validates_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = no_test_server(params.clone(), 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
assert!(client.schedule.is_none());
|
||||
assert_eq!(client.applied_traffic_class, None);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_rejects_non_close_open_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
@@ -44,33 +27,6 @@ fn no_test_rejects_non_close_open_reply() {
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_cleanup_send_failure_preserves_unexpected_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
client.test_hooks.fail_cleanup_send.set(true);
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::UnexpectedNoTestReply)
|
||||
));
|
||||
assert!(client.runtime.prepare_open_request().is_ok());
|
||||
server.join();
|
||||
}
|
||||
|
||||
@@ -78,7 +34,7 @@ fn no_test_cleanup_send_failure_preserves_unexpected_reply() {
|
||||
fn no_test_rejects_non_zero_token_with_close_reply() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, TOKEN);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
@@ -93,7 +49,7 @@ fn no_test_rejects_non_zero_token_with_close_reply() {
|
||||
fn no_test_strict_negotiation_rejects_changed_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let mut params = params_from_config(&config).unwrap();
|
||||
let mut params = default_params();
|
||||
params.dscp = 1;
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
@@ -110,7 +66,7 @@ fn no_test_loose_negotiation_accepts_restricted_params() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let requested = default_params();
|
||||
let mut params = requested.clone();
|
||||
params.duration_ns /= 2;
|
||||
let server = no_test_server(params.clone(), 0);
|
||||
@@ -120,7 +76,7 @@ fn no_test_loose_negotiation_accepts_restricted_params() {
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert_eq!(
|
||||
negotiated.restrictions,
|
||||
vec![crate::NegotiationRestriction::DurationReduced {
|
||||
vec![irtt_client::NegotiationRestriction::DurationReduced {
|
||||
requested_ns: requested.duration_ns,
|
||||
negotiated_ns: params.duration_ns,
|
||||
}]
|
||||
@@ -132,7 +88,7 @@ fn no_test_loose_negotiation_accepts_restricted_params() {
|
||||
fn send_probe_fails_after_no_test_completed() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
@@ -145,14 +101,29 @@ fn send_probe_fails_after_no_test_completed() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_after_no_test_completed() {
|
||||
fn operations_fail_after_no_test_completed_without_datagrams() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
config.socket_config.recv_timeout = Some(Duration::from_millis(50));
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_no_test_completed(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::AlreadyCompleted),
|
||||
Err(ClientError::AlreadyCompleted)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn receive_before_open_returns_not_open_without_datagrams() {
|
||||
let peer = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let mut config = default_test_config(peer.local_addr().unwrap());
|
||||
// Bound the old implementation's blocking receive without sending a packet.
|
||||
config.socket_config.recv_timeout = Some(Duration::from_millis(50));
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
let results = [
|
||||
client.recv_once(),
|
||||
client.recv_available(RecvBudget { max_packets: 1 }),
|
||||
client.recv_available(RecvBudget { max_packets: 0 }),
|
||||
];
|
||||
assert!(
|
||||
matches!(
|
||||
results,
|
||||
[
|
||||
Err(ClientError::NotOpen),
|
||||
Err(ClientError::NotOpen),
|
||||
Err(ClientError::NotOpen)
|
||||
]
|
||||
),
|
||||
"unexpected receive results: {results:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Untrusted returned intervals must be rejected before managed scheduling,
|
||||
// even when loose negotiation permits legitimate server restrictions.
|
||||
#[test]
|
||||
fn loose_open_rejects_nonpositive_returned_intervals() {
|
||||
for interval_ns in [0, -1] {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns = interval_ns;
|
||||
let server = open_success_server(returned);
|
||||
let config = ClientConfig {
|
||||
negotiation_policy: NegotiationPolicy::Loose,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(
|
||||
matches!(client.open(), Err(ClientError::NegotiationRejected { .. })),
|
||||
"returned interval {interval_ns} must be rejected"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_when_already_open() {
|
||||
let params = default_params();
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_retries_after_first_timeout() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let (first, _) = recv_request(&socket, &tx);
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let params = Params::decode(&first[4..]).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
assert_open_started(outcome);
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_version_mismatch_fails() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.negotiation_policy = NegotiationPolicy::Loose;
|
||||
let mut params = default_params();
|
||||
params.protocol_version = 2;
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::ProtocolVersionMismatch { received: 2, .. })
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_rejection_fails_in_normal_mode() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_datagrams_do_not_restart_the_attempt_deadline() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(250), Duration::from_millis(250)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let started = Instant::now();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
let requests: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(700),
|
||||
"ignored datagrams restarted an attempt deadline"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_deadline_overflow_occurs_before_send() {
|
||||
let server = timeout_server(Duration::from_millis(250));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::MAX],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::DurationOverflow)));
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_token_negotiation_failure_sends_cleanup_close() {
|
||||
let mut returned = default_params();
|
||||
returned.interval_ns += 1;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &returned, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
assert_eq!(packets[1][3], flags::FLAG_CLOSE);
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(packets[1][4..12].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_timeout_after_all_timeouts() {
|
||||
let server = timeout_server(Duration::from_millis(700));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_untrusted_datagrams_before_valid_reply_use_one_attempt() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
let mut wrong_type = valid.clone();
|
||||
wrong_type[3] = FLAG_REPLY;
|
||||
let mut reserved_flags = valid.clone();
|
||||
reserved_flags[3] |= 0x10;
|
||||
let mut bad_magic = valid.clone();
|
||||
bad_magic[0] ^= 0xff;
|
||||
let unexpected_hmac =
|
||||
open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(b"unexpected"));
|
||||
|
||||
for packet in [
|
||||
vec![0_u8],
|
||||
bad_magic,
|
||||
wrong_type,
|
||||
reserved_flags,
|
||||
unexpected_hmac,
|
||||
valid,
|
||||
] {
|
||||
socket.send_to(&packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ignored_open_datagrams_eventually_time_out() {
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
for _ in 0..2 {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
socket.send_to(&MAGIC, peer).unwrap();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(200)],
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
assert!(server.rx.try_recv().is_err());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_zero_token_normal_reply_is_terminal() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
// encode_open_reply rejects a zero token without FLAG_CLOSE, so this
|
||||
// deliberately non-compliant reply is built by encoding a normal
|
||||
// reply with a placeholder token and then zeroing the token field.
|
||||
let mut reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
reply[HMAC_OFFSET..HMAC_OFFSET + 8].copy_from_slice(&0_u64.to_le_bytes());
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::Protocol(irtt_proto::ProtoError::ZeroToken))
|
||||
));
|
||||
assert_eq!(server.rx.iter().take(1).count(), 1);
|
||||
|
||||
server.join();
|
||||
}
|
||||
File renamed without changes.
+1
-4
@@ -2,7 +2,7 @@ use super::*;
|
||||
use support::echo_packet_len;
|
||||
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
fn assert_packet_meta_unavailable(packet_meta: &crate::event::PacketMeta) {
|
||||
fn assert_packet_meta_unavailable(packet_meta: &irtt_client::PacketMeta) {
|
||||
assert_eq!(packet_meta.traffic_class, None);
|
||||
assert_eq!(packet_meta.dscp, None);
|
||||
assert_eq!(packet_meta.ecn, None);
|
||||
@@ -20,11 +20,8 @@ fn kernel_rx_timestamp_unavailable_skip(test_name: &str) {
|
||||
}
|
||||
|
||||
mod burst_recv;
|
||||
mod interrupted_receive;
|
||||
mod packet_length;
|
||||
mod probe_schedule;
|
||||
mod reply_classification;
|
||||
mod reply_decode;
|
||||
mod reply_metadata;
|
||||
mod timeouts;
|
||||
mod timing_math;
|
||||
+3
-60
@@ -1,62 +1,5 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minimal_negotiated_layout_works() {
|
||||
let params = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, None);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let ClientEvent::EchoReply {
|
||||
received_stats,
|
||||
server_timing,
|
||||
..
|
||||
} = &events[0]
|
||||
{
|
||||
assert!(received_stats.is_none());
|
||||
assert!(server_timing.is_none());
|
||||
} else {
|
||||
panic!("expected EchoReply");
|
||||
}
|
||||
server.join();
|
||||
}
|
||||
|
||||
// ---------- Regression tests ----------
|
||||
|
||||
#[test]
|
||||
@@ -81,7 +24,7 @@ fn short_echo_reply_does_not_emit_echo_reply() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 64,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -127,7 +70,7 @@ fn overlong_datagram_detection_uses_extra_receive_byte() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 4096,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -170,7 +113,7 @@ fn exact_length_echo_reply_still_emits_echo_reply() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
length: 4096,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+2
-2
@@ -23,7 +23,7 @@ fn duplicate_reply_emits_duplicate_event() {
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -77,7 +77,7 @@ fn out_of_order_reply_emits_late_event() {
|
||||
socket.send_to(&reply0, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+2
-62
@@ -5,7 +5,7 @@ fn recv_once_returns_empty_on_timeout() {
|
||||
let params = default_params();
|
||||
let server = open_success_server(params);
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -18,66 +18,6 @@ fn recv_once_returns_empty_on_timeout() {
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_once_decodes_echo_reply_and_emits_event() {
|
||||
let params = default_params();
|
||||
let (mut client, server) = open_client_with_echo_server(¶ms);
|
||||
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
ClientEvent::EchoReply {
|
||||
seq,
|
||||
rtt,
|
||||
received_stats,
|
||||
server_timing,
|
||||
bytes,
|
||||
packet_meta,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*seq, 0);
|
||||
assert_eq!(*bytes, echo_packet_len(false, ¶ms));
|
||||
assert!(rtt.raw > Duration::ZERO);
|
||||
assert_eq!(
|
||||
rtt.effective,
|
||||
rtt.adjusted
|
||||
.unwrap_or_else(|| SignedDuration::from_duration(rtt.raw))
|
||||
);
|
||||
assert!(received_stats.is_some());
|
||||
let stats = received_stats.as_ref().unwrap();
|
||||
assert_eq!(stats.count, Some(42));
|
||||
assert_eq!(stats.window, Some(0x07));
|
||||
assert!(server_timing.is_some());
|
||||
let st = server_timing.as_ref().unwrap();
|
||||
assert!(st.processing.is_some());
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
{
|
||||
assert_eq!(packet_meta.traffic_class, None);
|
||||
assert_eq!(packet_meta.dscp, None);
|
||||
assert_eq!(packet_meta.ecn, None);
|
||||
assert_eq!(packet_meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
{
|
||||
assert_eq!(
|
||||
packet_meta.dscp,
|
||||
packet_meta.traffic_class.map(|tc| tc >> 2)
|
||||
);
|
||||
assert_eq!(
|
||||
packet_meta.ecn,
|
||||
packet_meta.traffic_class.map(|tc| tc & 0b11)
|
||||
);
|
||||
}
|
||||
}
|
||||
other => panic!("expected EchoReply, got {other:?}"),
|
||||
}
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
|
||||
let params = default_params();
|
||||
@@ -113,7 +53,7 @@ fn recv_once_decodes_only_received_bytes_after_longer_datagram() {
|
||||
socket.send_to(&shorter, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+2
-2
@@ -14,13 +14,13 @@ fn echo_reply_metadata_propagates_observed_dscp_with_ancillary() {
|
||||
let (size, _) = socket.recv_from(&mut buf).unwrap();
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
let seq = u32::from_le_bytes(buf[12..16].try_into().unwrap());
|
||||
crate::socket_options::apply_traffic_class_to_socket(&socket, peer, 184).unwrap();
|
||||
socket2::SockRef::from(&socket).set_tos_v4(184).unwrap();
|
||||
let reply_packet =
|
||||
echo_reply_packet(TOKEN, seq, ¶ms, &TimestampFields::default(), None);
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
+43
-3
@@ -1,12 +1,52 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn probe_timeout_overflow_transmits_no_echo() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
// CLOSE is a completion barrier: any incorrectly transmitted ECHO
|
||||
// precedes it on this connected loopback socket.
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::MAX,
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::DurationOverflow)
|
||||
));
|
||||
assert!(!client.has_pending_probes());
|
||||
client.close().unwrap();
|
||||
let packets: Vec<_> = server.rx.iter().collect();
|
||||
assert_eq!(packets.len(), 2);
|
||||
assert_eq!(
|
||||
packets[1][3],
|
||||
flags::FLAG_CLOSE,
|
||||
"overflow must not transmit an ECHO"
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_timeouts_emits_echo_loss() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::from_millis(100),
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -57,7 +97,7 @@ fn late_reply_after_timeout_preserves_measurement_metadata() {
|
||||
});
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::from_millis(40),
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -119,7 +159,7 @@ fn pending_full_does_not_send_packet() {
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_secs(60)),
|
||||
max_pending_probes: 2,
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(50)),
|
||||
..Default::default()
|
||||
},
|
||||
+1
-1
@@ -253,7 +253,7 @@ pub(super) fn assert_no_test_completed(outcome: OpenOutcome) -> NegotiatedParams
|
||||
pub(super) fn open_client_with_echo_server(params: &Params) -> (Client, FakeServer) {
|
||||
let server = echo_server(params.clone());
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
socket_config: irtt_client::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -0,0 +1,22 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_accepts_valid_ttl_config_values() {
|
||||
for ttl in [None, Some(1), Some(255)] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = ttl;
|
||||
Client::connect(config).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_ttl_values() {
|
||||
for ttl in [0, 256] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = Some(ttl);
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Real Linux socket observations through the public client adapters.
|
||||
#![cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
|
||||
use std::{
|
||||
io::IoSliceMut,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent, NegotiationPolicy, SocketConfig};
|
||||
use irtt_proto::{decode_request, Clock, DecodedRequestKind, ReceivedStats, StampAt};
|
||||
use irtt_server::{ServerConfig, ServerCore};
|
||||
use nix::sys::socket::{recvmsg, ControlMessageOwned, MsgFlags};
|
||||
|
||||
const TEST_TTL: u32 = 37;
|
||||
|
||||
// A compliant packet peer uses the real server core. Its receive-side IP_TOS
|
||||
// observation is independent of the client/server parameter agreement.
|
||||
fn peer(dscp_allowed: bool, probes: usize) -> (SocketAddr, thread::JoinHandle<Vec<u8>>) {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
socket2::SockRef::from(&socket)
|
||||
.set_recv_tos_v4(true)
|
||||
.unwrap();
|
||||
nix::sys::socket::setsockopt(&socket, nix::sys::socket::sockopt::Ipv4RecvTtl, &true).unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let task = thread::spawn(move || {
|
||||
let mut core = ServerCore::new(
|
||||
ServerConfig::default()
|
||||
.with_min_send_interval(Duration::ZERO)
|
||||
.with_dscp_allowed(dscp_allowed),
|
||||
);
|
||||
let mut markings = Vec::new();
|
||||
// OPEN, every ECHO, then CLOSE.
|
||||
for _ in 0..probes + 2 {
|
||||
let mut bytes = [0; 2048];
|
||||
let mut control = nix::cmsg_space!(u8, i32);
|
||||
let mut iov = [IoSliceMut::new(&mut bytes)];
|
||||
let msg = recvmsg::<nix::sys::socket::SockaddrIn>(
|
||||
std::os::fd::AsRawFd::as_raw_fd(&socket),
|
||||
&mut iov,
|
||||
Some(&mut control),
|
||||
MsgFlags::empty(),
|
||||
)
|
||||
.unwrap();
|
||||
let size = msg.bytes;
|
||||
let source = msg.address.unwrap();
|
||||
let marking = msg
|
||||
.cmsgs()
|
||||
.unwrap()
|
||||
.find_map(|message| match message {
|
||||
ControlMessageOwned::Ipv4Tos(tos) => Some(tos),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Linux must report IP_TOS");
|
||||
let ttl = msg
|
||||
.cmsgs()
|
||||
.unwrap()
|
||||
.find_map(|message| match message {
|
||||
ControlMessageOwned::Ipv4Ttl(ttl) => Some(ttl),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Linux must report IP_TTL");
|
||||
assert_eq!(
|
||||
ttl, TEST_TTL as i32,
|
||||
"configured TTL must survive OPEN, ECHO and CLOSE"
|
||||
);
|
||||
markings.push(marking);
|
||||
let source = SocketAddr::from((source.ip(), source.port()));
|
||||
if let Some(reply) = core.handle_datagram(source, &bytes[..size]).unwrap() {
|
||||
socket.send_to(reply.bytes(), source).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(core.session_count(), 0);
|
||||
markings
|
||||
});
|
||||
(addr, task)
|
||||
}
|
||||
|
||||
fn config(addr: SocketAddr) -> ClientConfig {
|
||||
ClientConfig {
|
||||
server_addr: addr.to_string(),
|
||||
dscp: 46,
|
||||
negotiation_policy: NegotiationPolicy::Loose,
|
||||
duration: None,
|
||||
interval: Duration::from_millis(10),
|
||||
clock: Clock::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
received_stats: ReceivedStats::Both,
|
||||
length: 128,
|
||||
open_timeouts: vec![Duration::from_secs(1)],
|
||||
socket_config: SocketConfig {
|
||||
ttl: Some(TEST_TTL),
|
||||
recv_timeout: Some(Duration::from_secs(1)),
|
||||
..SocketConfig::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
// TX timestamps have no separate public field: the upstream OWD exposes the
|
||||
// selected send endpoint. Reconstruct it and require a real kernel endpoint
|
||||
// rather than accepting the userspace fallback. RTT must still use sent_at.
|
||||
fn assert_kernel_send_endpoint(event: &ClientEvent, before_send: SystemTime) {
|
||||
let ClientEvent::EchoReply {
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt,
|
||||
server_timing: Some(timing),
|
||||
one_way: Some(one_way),
|
||||
packet_meta,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected measured reply: {event:?}")
|
||||
};
|
||||
let ns = |wall: SystemTime| {
|
||||
i128::try_from(wall.duration_since(UNIX_EPOCH).unwrap().as_nanos()).unwrap()
|
||||
};
|
||||
let selected =
|
||||
i128::from(timing.receive_wall_ns.unwrap()) - one_way.client_to_server.unwrap().as_nanos();
|
||||
assert!(selected >= ns(before_send));
|
||||
// TX_SOFTWARE may arrive before or after send returns; only equality
|
||||
// identifies the userspace fallback. Receipt remains the upper bound.
|
||||
assert_ne!(
|
||||
selected,
|
||||
ns(sent_at.wall),
|
||||
"TX timestamp was not observed; userspace fallback is insufficient"
|
||||
);
|
||||
assert!(selected <= ns(received_at.wall));
|
||||
let selected_receive =
|
||||
i128::from(timing.send_wall_ns.unwrap()) + one_way.server_to_client.unwrap().as_nanos();
|
||||
assert_eq!(
|
||||
selected_receive,
|
||||
ns(packet_meta
|
||||
.kernel_rx_timestamp
|
||||
.expect("Linux RX timestamp required")),
|
||||
"downstream OWD must use the kernel receive endpoint"
|
||||
);
|
||||
assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_probes_use_kernel_tx_timing_and_negotiated_packet_marking() {
|
||||
for allowed in [true, false] {
|
||||
const PROBES: usize = 3;
|
||||
let (addr, peer) = peer(allowed, PROBES);
|
||||
let mut client = Client::connect(config(addr)).unwrap();
|
||||
client.open().unwrap();
|
||||
assert_eq!(
|
||||
client.negotiated_params().unwrap().params.dscp,
|
||||
if allowed { 184 } else { 0 }
|
||||
);
|
||||
for _ in 0..PROBES {
|
||||
let before = SystemTime::now();
|
||||
client.send_probe().unwrap();
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_kernel_send_endpoint(&events[0], before);
|
||||
}
|
||||
client.close().unwrap();
|
||||
let markings = peer.join().unwrap();
|
||||
assert_eq!(markings[0], 0, "OPEN is unmarked");
|
||||
assert_eq!(
|
||||
&markings[1..=PROBES],
|
||||
vec![if allowed { 184 } else { 0 }; PROBES]
|
||||
);
|
||||
assert_eq!(
|
||||
markings[PROBES + 1],
|
||||
0,
|
||||
"local CLOSE clears the negotiated marking"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn async_probes_use_kernel_tx_timing_and_negotiated_packet_marking() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
for allowed in [true, false] {
|
||||
const PROBES: usize = 3;
|
||||
let (addr, peer) = peer(allowed, PROBES);
|
||||
runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let mut client = irtt_client::AsyncClient::connect(config(addr))
|
||||
.await
|
||||
.unwrap();
|
||||
client.open().await.unwrap();
|
||||
assert_eq!(
|
||||
client.negotiated_params().unwrap().params.dscp,
|
||||
if allowed { 184 } else { 0 }
|
||||
);
|
||||
for _ in 0..PROBES {
|
||||
let before = SystemTime::now();
|
||||
client.send_probe().await.unwrap();
|
||||
let events = client.recv().await.unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_kernel_send_endpoint(&events[0], before);
|
||||
}
|
||||
client.close().await.unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("async transport observations timed out");
|
||||
});
|
||||
let markings = peer.join().unwrap();
|
||||
assert_eq!(markings[0], 0);
|
||||
assert_eq!(
|
||||
&markings[1..=PROBES],
|
||||
vec![if allowed { 184 } else { 0 }; PROBES]
|
||||
);
|
||||
assert_eq!(markings[PROBES + 1], 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Real ICMP refusal remains a socket error, never optional timestamp metadata.
|
||||
// The bound peer closes only after the client has accepted a valid OPEN.
|
||||
#[test]
|
||||
fn a_closed_udp_peer_surfaces_a_socket_error() {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let (close_tx, close_rx) = mpsc::channel();
|
||||
let task = thread::spawn(move || {
|
||||
let mut core = ServerCore::new(ServerConfig::default());
|
||||
let mut bytes = [0; 2048];
|
||||
let (size, source) = socket.recv_from(&mut bytes).unwrap();
|
||||
assert!(matches!(
|
||||
decode_request(&bytes[..size]).unwrap().kind,
|
||||
DecodedRequestKind::Open { .. }
|
||||
));
|
||||
let reply = core
|
||||
.handle_datagram(source, &bytes[..size])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
socket.send_to(reply.bytes(), source).unwrap();
|
||||
close_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
drop(socket);
|
||||
});
|
||||
let mut client = Client::connect(config(addr)).unwrap();
|
||||
client.open().unwrap();
|
||||
close_tx.send(()).unwrap();
|
||||
task.join().unwrap();
|
||||
// Refusal can arrive during send's TX drain or the following receive.
|
||||
let result = client.send_probe().and_then(|_| client.recv_once());
|
||||
assert!(
|
||||
matches!(result, Err(irtt_client::ClientError::Socket(ref error))
|
||||
if error.kind() == std::io::ErrorKind::ConnectionRefused),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
#![cfg(feature = "tokio")]
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::pin,
|
||||
task::{Context, Poll, Waker},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_client::managed::*;
|
||||
|
||||
fn config() -> ManagedClientConfig {
|
||||
ManagedClientConfig {
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
command_capacity: 3,
|
||||
..ManagedClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_resolves_every_accepted_update_and_closes_admission() {
|
||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||
let receipts: Vec<_> = (0..3)
|
||||
.map(|index| {
|
||||
handle
|
||||
.update_targets(vec![ManagedTargetConfig::new(
|
||||
format!("queued-{index}"),
|
||||
"127.0.0.1:9",
|
||||
)])
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::QueueFull)
|
||||
));
|
||||
|
||||
let stop = handle.stop();
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::Stopping)
|
||||
));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
let outcome = task.await;
|
||||
stop.await;
|
||||
for receipt in receipts {
|
||||
assert!(matches!(
|
||||
receipt.await,
|
||||
Err(ManagedCommandApplyError::Stopping)
|
||||
));
|
||||
}
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
assert_eq!(outcome.applied_command_sequence, 0);
|
||||
assert_eq!(outcome.total_target_outcomes, 0);
|
||||
let status = handle.status();
|
||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||
assert!(status.stop_requested);
|
||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::DriverClosed)
|
||||
));
|
||||
handle.stop().await;
|
||||
})
|
||||
.await
|
||||
.expect("stop must resolve every buffered update and repeated stop receipt");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_driver_resolves_every_accepted_update_with_its_terminal_failure() {
|
||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||
let receipts: Vec<_> = (0..3)
|
||||
.map(|index| {
|
||||
handle
|
||||
.update_targets(vec![ManagedTargetConfig::new(
|
||||
format!("queued-{index}"),
|
||||
"127.0.0.1:9",
|
||||
)])
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
let Poll::Ready(outcome) = pin!(task).as_mut().poll(&mut context) else {
|
||||
panic!("polling without Tokio must fail immediately");
|
||||
};
|
||||
assert_eq!(
|
||||
outcome.end_reason,
|
||||
ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime)
|
||||
);
|
||||
for receipt in receipts {
|
||||
assert!(matches!(
|
||||
pin!(receipt).as_mut().poll(&mut context),
|
||||
Poll::Ready(Err(ManagedCommandApplyError::DriverFailed(
|
||||
ManagedDriverFailure::NoTokioRuntime
|
||||
)))
|
||||
));
|
||||
}
|
||||
let status = handle.status();
|
||||
assert_eq!(status.lifecycle, ManagedLifecycle::Failed);
|
||||
assert_eq!(status.final_outcome.as_deref(), Some(&outcome));
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::DriverClosed)
|
||||
));
|
||||
assert!(pin!(handle.stop()).as_mut().poll(&mut context).is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_submission_and_stop_or_seal_never_lose_an_accepted_receipt() {
|
||||
let (task, handle) = ManagedClient::task(config(), vec![]).unwrap();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let submit_barrier = std::sync::Arc::clone(&barrier);
|
||||
let submit_handle = handle.clone();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let submit = std::thread::spawn(move || {
|
||||
submit_barrier.wait();
|
||||
sender.send(submit_handle.update_targets(vec![])).unwrap();
|
||||
});
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
// Stop is latched before the driver starts, while submission may cross
|
||||
// admission before stop, after stop, or after terminal sealing.
|
||||
barrier.wait();
|
||||
let stop = handle.stop();
|
||||
runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
let outcome = task.await;
|
||||
stop.await;
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
assert_eq!(outcome.applied_command_sequence, 0);
|
||||
assert_eq!(outcome.total_target_outcomes, 0);
|
||||
let result = receiver
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("concurrent submission must return");
|
||||
submit.join().unwrap();
|
||||
match result {
|
||||
Ok(receipt) => assert!(matches!(
|
||||
receipt.await,
|
||||
Err(ManagedCommandApplyError::Stopping)
|
||||
)),
|
||||
Err(ManagedCommandError::Stopping | ManagedCommandError::DriverClosed) => {}
|
||||
Err(error) => panic!("unexpected concurrent submission result: {error:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
handle.update_targets(vec![]),
|
||||
Err(ManagedCommandError::DriverClosed)
|
||||
));
|
||||
})
|
||||
.await
|
||||
.expect("every accepted concurrent submission must receive a terminal disposition");
|
||||
});
|
||||
}
|
||||
@@ -75,16 +75,48 @@ async fn exercise_client_server() {
|
||||
..
|
||||
}] if *remote == server_addr
|
||||
));
|
||||
assert!(matches!(
|
||||
client.recv().await.unwrap().as_slice(),
|
||||
[ClientEvent::EchoReply {
|
||||
seq: 0,
|
||||
remote,
|
||||
server_timing: Some(_),
|
||||
received_stats: Some(_),
|
||||
..
|
||||
}] if *remote == server_addr
|
||||
));
|
||||
let events = client.recv().await.unwrap();
|
||||
let [ClientEvent::EchoReply {
|
||||
seq: 0,
|
||||
remote,
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt,
|
||||
server_timing: Some(timing),
|
||||
one_way: Some(one_way),
|
||||
received_stats: Some(_),
|
||||
packet_meta,
|
||||
..
|
||||
}] = events.as_slice()
|
||||
else {
|
||||
panic!("expected a measured reply, got {events:?}");
|
||||
};
|
||||
assert_eq!(*remote, server_addr);
|
||||
assert_eq!(rtt.raw, received_at.mono.duration_since(sent_at.mono));
|
||||
// Verify the measurement endpoint through public reply metadata and real
|
||||
// OS timestamps. This also covers the userspace fallback on other targets.
|
||||
let receive_wall = packet_meta
|
||||
.kernel_rx_timestamp
|
||||
.filter(|kernel| {
|
||||
received_at
|
||||
.wall
|
||||
.duration_since(*kernel)
|
||||
.is_ok_and(|lag| lag <= Duration::from_secs(1))
|
||||
})
|
||||
.unwrap_or(received_at.wall);
|
||||
let receive_ns = i128::try_from(
|
||||
receive_wall
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
one_way.server_to_client,
|
||||
Some(irtt_client::SignedDuration::from_nanos(
|
||||
receive_ns - i128::from(timing.send_wall_ns.unwrap())
|
||||
))
|
||||
);
|
||||
assert!(matches!(
|
||||
client.close().await.unwrap().as_slice(),
|
||||
[ClientEvent::SessionClosed { remote, .. }] if *remote == server_addr
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
use irtt_client::{Authentication, HmacKey};
|
||||
use std::{
|
||||
future::Future,
|
||||
net::UdpSocket,
|
||||
pin::Pin,
|
||||
sync::mpsc,
|
||||
task::{Context, Poll, Waker},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_request, encode_echo_reply, encode_open_reply, flags, verify_packet_hmac,
|
||||
DecodedRequestKind, EchoReply, OpenReply, Params, ReceivedStats, StampAt, TimestampFields,
|
||||
};
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
mod support;
|
||||
|
||||
use irtt_client::{AsyncClient, ClientConfig, ClientError, ClientEvent, OpenOutcome};
|
||||
use irtt_client::{Client, RunMode, SocketConfig};
|
||||
use irtt_server::ServerConfig;
|
||||
use std::{
|
||||
io,
|
||||
net::SocketAddr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use support::InTreeServer;
|
||||
|
||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||
|
||||
struct TestServer {
|
||||
addr: SocketAddr,
|
||||
packets: mpsc::Receiver<Vec<u8>>,
|
||||
done: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
fn finish(self) -> Vec<Vec<u8>> {
|
||||
self.done.join().unwrap();
|
||||
self.packets.try_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn config(addr: SocketAddr, key: Option<Vec<u8>>, dscp: u8) -> ClientConfig {
|
||||
ClientConfig {
|
||||
server_addr: addr.to_string(),
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
dscp,
|
||||
auth: key.map_or(Authentication::Unauthenticated, |key| {
|
||||
Authentication::Hmac(HmacKey::new(key))
|
||||
}),
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
socket_config: SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..SocketConfig::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn opened_client(addr: SocketAddr, dscp: u8) -> AsyncClient {
|
||||
let mut client = AsyncClient::connect(config(addr, None, dscp))
|
||||
.await
|
||||
.unwrap();
|
||||
client.open().await.unwrap();
|
||||
client
|
||||
}
|
||||
|
||||
fn start_server<F>(handler: F) -> TestServer
|
||||
where
|
||||
F: FnOnce(UdpSocket, mpsc::Sender<Vec<u8>>) + Send + 'static,
|
||||
{
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let (tx, packets) = mpsc::channel();
|
||||
let done = thread::spawn(move || handler(socket, tx));
|
||||
TestServer {
|
||||
addr,
|
||||
packets,
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
fn recv_packet(socket: &UdpSocket, tx: &mpsc::Sender<Vec<u8>>) -> (Vec<u8>, SocketAddr) {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let (len, peer) = socket.recv_from(&mut buffer).unwrap();
|
||||
let packet = buffer[..len].to_vec();
|
||||
tx.send(packet.clone()).unwrap();
|
||||
(packet, peer)
|
||||
}
|
||||
|
||||
/// Admits a request the way a compliant server does: structural decode first,
|
||||
/// then HMAC presence policy, then authentication. Returns `None` for anything
|
||||
/// a server would silently discard.
|
||||
fn decode_server_request<'a>(
|
||||
packet: &'a [u8],
|
||||
key: Option<&[u8]>,
|
||||
) -> Option<DecodedRequestKind<'a>> {
|
||||
let request = decode_request(packet).ok()?;
|
||||
if request.hmac_present != key.is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(key) = key {
|
||||
verify_packet_hmac(key, packet).ok()?;
|
||||
}
|
||||
Some(request.kind)
|
||||
}
|
||||
|
||||
/// Returns the requested parameters and whether this was a no-test open.
|
||||
fn open_request(packet: &[u8], key: Option<&[u8]>) -> (Params, bool) {
|
||||
match decode_server_request(packet, key) {
|
||||
Some(DecodedRequestKind::Open { params, no_test }) => {
|
||||
(Params::decode(params).unwrap(), no_test)
|
||||
}
|
||||
other => panic!("expected an authenticated open request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn echo_request_sequence(packet: &[u8], key: Option<&[u8]>) -> u32 {
|
||||
match decode_server_request(packet, key) {
|
||||
Some(DecodedRequestKind::Echo { sequence, .. }) => sequence,
|
||||
other => panic!("expected an authenticated echo request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn close_request_token(packet: &[u8], key: Option<&[u8]>) -> u64 {
|
||||
match decode_server_request(packet, key) {
|
||||
Some(DecodedRequestKind::Close { token }) => token,
|
||||
other => panic!("expected an authenticated close request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_open_reply(
|
||||
socket: &UdpSocket,
|
||||
peer: SocketAddr,
|
||||
params: Params,
|
||||
key: Option<&[u8]>,
|
||||
reply_flags: u8,
|
||||
token: u64,
|
||||
) {
|
||||
let reply = encode_open_reply(
|
||||
&OpenReply {
|
||||
flags: reply_flags,
|
||||
token,
|
||||
params,
|
||||
},
|
||||
key,
|
||||
)
|
||||
.unwrap();
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
}
|
||||
|
||||
fn open_session(
|
||||
socket: &UdpSocket,
|
||||
tx: &mpsc::Sender<Vec<u8>>,
|
||||
key: Option<&[u8]>,
|
||||
) -> (Params, SocketAddr) {
|
||||
let (packet, peer) = recv_packet(socket, tx);
|
||||
let (params, _) = open_request(&packet, key);
|
||||
send_open_reply(
|
||||
socket,
|
||||
peer,
|
||||
params.clone(),
|
||||
key,
|
||||
flags::FLAG_OPEN | flags::FLAG_REPLY,
|
||||
TOKEN,
|
||||
);
|
||||
(params, peer)
|
||||
}
|
||||
|
||||
fn echo_reply(
|
||||
params: &Params,
|
||||
sequence: u32,
|
||||
token: u64,
|
||||
reply_flags: u8,
|
||||
key: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
encode_echo_reply(
|
||||
&EchoReply {
|
||||
flags: reply_flags,
|
||||
token,
|
||||
sequence,
|
||||
recv_count: None,
|
||||
recv_window: None,
|
||||
timestamps: TimestampFields::default(),
|
||||
payload: Vec::new(),
|
||||
},
|
||||
params,
|
||||
key,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn start_no_test_server() -> TestServer {
|
||||
start_server(move |socket, tx| {
|
||||
let (open_packet, peer) = recv_packet(&socket, &tx);
|
||||
let (params, no_test) = open_request(&open_packet, None);
|
||||
assert!(no_test);
|
||||
send_open_reply(
|
||||
&socket,
|
||||
peer,
|
||||
params,
|
||||
None,
|
||||
flags::FLAG_OPEN | flags::FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
0,
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
fn start_peer_close_server() -> TestServer {
|
||||
start_server(move |socket, tx| {
|
||||
let (params, peer) = open_session(&socket, &tx, None);
|
||||
let (probe_packet, _) = recv_packet(&socket, &tx);
|
||||
let sequence = echo_request_sequence(&probe_packet, None);
|
||||
socket
|
||||
.send_to(
|
||||
&echo_reply(
|
||||
¶ms,
|
||||
sequence,
|
||||
TOKEN,
|
||||
flags::FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
None,
|
||||
),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
})
|
||||
}
|
||||
|
||||
fn poll_once<F: Future>(future: Pin<&mut F>) -> Poll<F::Output> {
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
Future::poll(future, &mut context)
|
||||
}
|
||||
|
||||
fn poll_recv_once(client: &mut AsyncClient) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
|
||||
let mut future = Box::pin(client.recv());
|
||||
poll_once(Pin::as_mut(&mut future))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_requires_current_runtime_when_polled() {
|
||||
let mut future = Box::pin(AsyncClient::connect(ClientConfig::default()));
|
||||
assert!(matches!(
|
||||
poll_once(Pin::as_mut(&mut future)),
|
||||
Poll::Ready(Err(ClientError::NoTokioRuntime))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_before_open_fails_on_first_poll_without_socket_readiness() {
|
||||
runtime().block_on(async {
|
||||
let remote = SocketAddr::from(([127, 0, 0, 1], 2112));
|
||||
let mut client = AsyncClient::connect(config(remote, None, 0)).await.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
poll_recv_once(&mut client),
|
||||
Poll::Ready(Err(ClientError::NotOpen))
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() {
|
||||
let server = start_server(move |socket, tx| {
|
||||
let (first_packet, peer) = recv_packet(&socket, &tx);
|
||||
let (params, _) = open_request(&first_packet, None);
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(10)))
|
||||
.unwrap();
|
||||
let started = Instant::now();
|
||||
let mut next_noise = started;
|
||||
let mut buffer = [0_u8; 512];
|
||||
|
||||
while started.elapsed() < Duration::from_millis(350) {
|
||||
let now = Instant::now();
|
||||
if now >= next_noise {
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
next_noise = now + Duration::from_millis(25);
|
||||
}
|
||||
match socket.recv_from(&mut buffer) {
|
||||
Ok((len, second_peer)) => {
|
||||
tx.send(buffer[..len].to_vec()).unwrap();
|
||||
send_open_reply(
|
||||
&socket,
|
||||
second_peer,
|
||||
params,
|
||||
None,
|
||||
flags::FLAG_OPEN | flags::FLAG_REPLY,
|
||||
TOKEN,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
) => {}
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
}
|
||||
panic!("second open request did not arrive after the first absolute deadline");
|
||||
});
|
||||
let mut client_config = config(server.addr, None, 0);
|
||||
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
|
||||
|
||||
runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(client_config).await.unwrap();
|
||||
assert!(matches!(
|
||||
client.open().await.unwrap(),
|
||||
OpenOutcome::Started { .. }
|
||||
));
|
||||
});
|
||||
assert_eq!(server.finish().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_rejection_is_terminal_without_retry() {
|
||||
let server = start_server(move |socket, tx| {
|
||||
let (open_packet, peer) = recv_packet(&socket, &tx);
|
||||
let (params, _) = open_request(&open_packet, None);
|
||||
send_open_reply(
|
||||
&socket,
|
||||
peer,
|
||||
params,
|
||||
None,
|
||||
flags::FLAG_OPEN | flags::FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
0,
|
||||
);
|
||||
});
|
||||
let mut client_config = config(server.addr, None, 0);
|
||||
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
|
||||
|
||||
runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(client_config).await.unwrap();
|
||||
assert!(matches!(
|
||||
client.open().await,
|
||||
Err(ClientError::ServerRejected)
|
||||
));
|
||||
});
|
||||
assert_eq!(server.finish().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_after_local_close_and_no_test_fails_on_first_poll() {
|
||||
let close_server = start_server(move |socket, tx| {
|
||||
open_session(&socket, &tx, None);
|
||||
let (close_packet, _) = recv_packet(&socket, &tx);
|
||||
assert_eq!(close_request_token(&close_packet, None), TOKEN);
|
||||
});
|
||||
runtime().block_on(async {
|
||||
let mut client = opened_client(close_server.addr, 0).await;
|
||||
client.close().await.unwrap();
|
||||
assert!(matches!(
|
||||
poll_recv_once(&mut client),
|
||||
Poll::Ready(Err(ClientError::AlreadyClosed))
|
||||
));
|
||||
});
|
||||
assert_eq!(close_server.finish().len(), 2);
|
||||
|
||||
let no_test_server = start_no_test_server();
|
||||
runtime().block_on(async {
|
||||
let mut client_config = config(no_test_server.addr, None, 0);
|
||||
client_config.run_mode = RunMode::NoTest;
|
||||
let mut client = AsyncClient::connect(client_config).await.unwrap();
|
||||
client.open().await.unwrap();
|
||||
assert!(matches!(
|
||||
poll_recv_once(&mut client),
|
||||
Poll::Ready(Err(ClientError::AlreadyCompleted))
|
||||
));
|
||||
});
|
||||
assert_eq!(no_test_server.finish().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_async_lifecycle_negotiates_dscp() {
|
||||
let key = b"async-lifecycle-key".to_vec();
|
||||
let server = InTreeServer::start(ServerConfig::default().with_hmac_key(key.clone()));
|
||||
runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(config(server.addr, Some(key), 46))
|
||||
.await
|
||||
.unwrap();
|
||||
let opened = client.open().await.unwrap();
|
||||
assert_eq!(open_negotiated(&opened).params.dscp, 184);
|
||||
client.send_probe().await.unwrap();
|
||||
assert!(matches!(client.recv().await.unwrap().as_slice(), [ClientEvent::EchoReply { seq: 0, .. }]));
|
||||
assert!(matches!(client.close().await.unwrap().as_slice(), [ClientEvent::SessionClosed { token, .. }] if *token == open_token(&opened)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_and_async_peer_close_are_semantically_equivalent() {
|
||||
let blocking_peer_close_server = start_peer_close_server();
|
||||
let mut blocking_peer_close =
|
||||
Client::connect(config(blocking_peer_close_server.addr, None, 0)).unwrap();
|
||||
blocking_peer_close.open().unwrap();
|
||||
blocking_peer_close.send_probe().unwrap();
|
||||
let blocking_peer_close_events = blocking_peer_close.recv_once().unwrap();
|
||||
assert!(blocking_peer_close.is_peer_closed());
|
||||
assert_eq!(blocking_peer_close_server.finish().len(), 2);
|
||||
|
||||
let async_peer_close_server = start_peer_close_server();
|
||||
let async_peer_close_events = runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(config(async_peer_close_server.addr, None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
client.open().await.unwrap();
|
||||
client.send_probe().await.unwrap();
|
||||
let events = client.recv().await.unwrap();
|
||||
assert!(client.is_peer_closed());
|
||||
events
|
||||
});
|
||||
assert_eq!(async_peer_close_server.finish().len(), 2);
|
||||
assert_eq!(
|
||||
blocking_peer_close_events.len(),
|
||||
async_peer_close_events.len()
|
||||
);
|
||||
for (blocking, asynchronous) in blocking_peer_close_events
|
||||
.iter()
|
||||
.zip(&async_peer_close_events)
|
||||
{
|
||||
assert_matching_event_shape(blocking, asynchronous);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_negotiated(outcome: &OpenOutcome) -> &irtt_client::NegotiatedParams {
|
||||
match outcome {
|
||||
OpenOutcome::Started { negotiated, .. }
|
||||
| OpenOutcome::NoTestCompleted { negotiated, .. } => negotiated,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_token(outcome: &OpenOutcome) -> u64 {
|
||||
match outcome {
|
||||
OpenOutcome::Started { token, .. } => *token,
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("no-test open has no session token"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_matching_event_shape(blocking: &ClientEvent, asynchronous: &ClientEvent) {
|
||||
assert_eq!(event_shape(blocking), event_shape(asynchronous));
|
||||
}
|
||||
|
||||
fn event_shape(event: &ClientEvent) -> (&'static str, u64, usize) {
|
||||
match event {
|
||||
ClientEvent::EchoSent { seq, bytes, .. } => ("sent", u64::from(*seq), *bytes),
|
||||
ClientEvent::EchoReply { seq, bytes, .. } => ("reply", u64::from(*seq), *bytes),
|
||||
ClientEvent::SessionClosed { .. } => ("closed", 0, 0),
|
||||
other => panic!("unexpected equivalence event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer that ignores the first `ignored_requests` datagrams before serving a
|
||||
/// compliant open, one echo, and one close.
|
||||
///
|
||||
/// Silence is the point here, so this stays a narrow fake peer rather than a
|
||||
/// real `irtt-server`: a compliant server has no way to be deliberately mute
|
||||
/// for exactly one open attempt.
|
||||
fn start_silent_then_open_server(ignored_requests: usize) -> TestServer {
|
||||
start_server(move |socket, tx| {
|
||||
for _ in 0..ignored_requests {
|
||||
let _ = recv_packet(&socket, &tx);
|
||||
}
|
||||
let (params, peer) = open_session(&socket, &tx, None);
|
||||
let (probe_packet, _) = recv_packet(&socket, &tx);
|
||||
let sequence = echo_request_sequence(&probe_packet, None);
|
||||
socket
|
||||
.send_to(
|
||||
&echo_reply(¶ms, sequence, TOKEN, flags::FLAG_REPLY, None),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
let (close_packet, _) = recv_packet(&socket, &tx);
|
||||
assert_eq!(close_request_token(&close_packet, None), TOKEN);
|
||||
})
|
||||
}
|
||||
|
||||
/// Stable name for a `ClientError` variant, for comparing what each driver
|
||||
/// promises rather than how it produced it.
|
||||
fn error_name(error: &ClientError) -> &'static str {
|
||||
match error {
|
||||
ClientError::OpenTimeout => "open timeout",
|
||||
ClientError::NotOpen => "not open",
|
||||
ClientError::AlreadyOpen => "already open",
|
||||
ClientError::AlreadyClosed => "already closed",
|
||||
ClientError::AlreadyCompleted => "already completed",
|
||||
ClientError::ServerRejected => "server rejected",
|
||||
other => panic!("unexpected conformance error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_sequences(events: &[ClientEvent]) -> Vec<u32> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
ClientEvent::EchoSent { seq, .. } => Some(*seq),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reply_sequences(events: &[ClientEvent]) -> Vec<u32> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
ClientEvent::EchoReply { seq, .. } => Some(*seq),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
|
||||
// One open attempt is ignored, so the first open times out and the second
|
||||
// must be able to open the same connected client.
|
||||
let blocking_server = start_silent_then_open_server(1);
|
||||
let mut blocking = Client::connect(config(blocking_server.addr, None, 0)).unwrap();
|
||||
assert!(!blocking.has_pending_probes());
|
||||
let blocking_first = blocking.open().unwrap_err();
|
||||
assert!(!blocking.has_pending_probes());
|
||||
let blocking_second = blocking.open().unwrap();
|
||||
assert!(!blocking.has_pending_probes());
|
||||
let blocking_sent = blocking.send_probe().unwrap();
|
||||
let blocking_reply = blocking.recv_once().unwrap();
|
||||
let blocking_close = blocking.close().unwrap();
|
||||
assert_eq!(blocking_server.finish().len(), 4);
|
||||
|
||||
let async_server = start_silent_then_open_server(1);
|
||||
let (async_first, async_second, async_sent, async_reply, async_close) =
|
||||
runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(config(async_server.addr, None, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!client.has_pending_probes());
|
||||
let first = client.open().await.unwrap_err();
|
||||
assert!(!client.has_pending_probes());
|
||||
let second = client.open().await.unwrap();
|
||||
assert!(!client.has_pending_probes());
|
||||
let sent = client.send_probe().await.unwrap();
|
||||
let reply = client.recv().await.unwrap();
|
||||
let closed = client.close().await.unwrap();
|
||||
(first, second, sent, reply, closed)
|
||||
});
|
||||
assert_eq!(async_server.finish().len(), 4);
|
||||
|
||||
// Both drivers promise the same failure, and neither leaves the client in a
|
||||
// state that refuses the retry.
|
||||
assert_eq!(error_name(&blocking_first), "open timeout");
|
||||
assert_eq!(error_name(&blocking_first), error_name(&async_first));
|
||||
assert!(matches!(blocking_second, OpenOutcome::Started { .. }));
|
||||
assert!(matches!(async_second, OpenOutcome::Started { .. }));
|
||||
assert_eq!(
|
||||
open_negotiated(&blocking_second),
|
||||
open_negotiated(&async_second)
|
||||
);
|
||||
assert_eq!(open_token(&blocking_second), open_token(&async_second));
|
||||
|
||||
// The retried session starts a fresh probe sequence in both drivers.
|
||||
assert_eq!(probe_sequences(&blocking_sent), [0]);
|
||||
assert_eq!(probe_sequences(&async_sent), [0]);
|
||||
assert_eq!(reply_sequences(&blocking_reply), [0]);
|
||||
assert_eq!(reply_sequences(&async_reply), [0]);
|
||||
assert_eq!(blocking_sent.len(), async_sent.len());
|
||||
assert_eq!(blocking_reply.len(), async_reply.len());
|
||||
assert_eq!(blocking_close.len(), async_close.len());
|
||||
assert_matching_event_shape(&blocking_close[0], &async_close[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_and_async_complete_every_caller_paced_probe() {
|
||||
const PROBES: u32 = 3;
|
||||
fn caller_config(addr: SocketAddr) -> ClientConfig {
|
||||
ClientConfig {
|
||||
duration: Some(Duration::from_millis(1)),
|
||||
interval: Duration::from_secs(1),
|
||||
..config(addr, None, 0)
|
||||
}
|
||||
}
|
||||
|
||||
let blocking_server = InTreeServer::start(ServerConfig::default());
|
||||
let mut blocking = Client::connect(caller_config(blocking_server.addr)).unwrap();
|
||||
let outcome = blocking.open().unwrap();
|
||||
assert_eq!(
|
||||
blocking.negotiated_params(),
|
||||
Some(open_negotiated(&outcome))
|
||||
);
|
||||
assert!(!blocking.has_pending_probes());
|
||||
// Oversleeping cannot invalidate this: low-level sends remain caller-owned
|
||||
// after the negotiated duration. The compliant server has no duration cap.
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
let mut blocking_sent = Vec::new();
|
||||
let mut blocking_replies = Vec::new();
|
||||
for _ in 0..PROBES {
|
||||
blocking_sent.extend(blocking.send_probe().unwrap());
|
||||
assert!(blocking.has_pending_probes());
|
||||
assert!(blocking.next_probe_timeout_deadline().is_some());
|
||||
blocking_replies.extend(blocking.recv_once().unwrap());
|
||||
assert!(!blocking.has_pending_probes());
|
||||
}
|
||||
blocking.close().unwrap();
|
||||
drop(blocking_server);
|
||||
|
||||
let async_server = InTreeServer::start(ServerConfig::default());
|
||||
let (async_sent, async_replies) = runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(caller_config(async_server.addr))
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome = client.open().await.unwrap();
|
||||
assert_eq!(client.negotiated_params(), Some(open_negotiated(&outcome)));
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
let mut sent = Vec::new();
|
||||
let mut replies = Vec::new();
|
||||
for _ in 0..PROBES {
|
||||
sent.extend(client.send_probe().await.unwrap());
|
||||
assert!(client.has_pending_probes());
|
||||
assert!(client.next_probe_timeout_deadline().is_some());
|
||||
replies.extend(client.recv().await.unwrap());
|
||||
assert!(!client.has_pending_probes());
|
||||
}
|
||||
client.close().await.unwrap();
|
||||
(sent, replies)
|
||||
});
|
||||
drop(async_server);
|
||||
|
||||
let expected: Vec<u32> = (0..PROBES).collect();
|
||||
assert_eq!(probe_sequences(&blocking_sent), expected);
|
||||
assert_eq!(probe_sequences(&async_sent), expected);
|
||||
assert_eq!(reply_sequences(&blocking_replies), expected);
|
||||
assert_eq!(reply_sequences(&async_replies), expected);
|
||||
// Compare the whole emitted streams, not just the probe and reply events
|
||||
// the sequence helpers keep: an extra event on either side is a difference.
|
||||
assert_eq!(blocking_sent.len(), async_sent.len());
|
||||
assert_eq!(blocking_replies.len(), async_replies.len());
|
||||
for event in blocking_sent.iter().chain(&async_sent) {
|
||||
assert!(matches!(
|
||||
event,
|
||||
ClientEvent::EchoSent {
|
||||
scheduled_at: None,
|
||||
timer_error: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
for (blocking, asynchronous) in blocking_sent.iter().zip(&async_sent) {
|
||||
assert_matching_event_shape(blocking, asynchronous);
|
||||
}
|
||||
for (blocking, asynchronous) in blocking_replies.iter().zip(&async_replies) {
|
||||
assert_matching_event_shape(blocking, asynchronous);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_open_traffic_uses_one_request_and_deadline_per_attempt() {
|
||||
let server = start_server(move |socket, tx| {
|
||||
for _ in 0..2 {
|
||||
let (_, peer) = recv_packet(&socket, &tx);
|
||||
for _ in 0..3 {
|
||||
socket.send_to(&[0_u8], peer).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut client_config = config(server.addr, None, 0);
|
||||
client_config.open_timeouts = vec![Duration::from_millis(200), Duration::from_millis(200)];
|
||||
|
||||
runtime().block_on(async {
|
||||
let mut client = AsyncClient::connect(client_config).await.unwrap();
|
||||
assert!(matches!(client.open().await, Err(ClientError::OpenTimeout)));
|
||||
});
|
||||
assert_eq!(server.finish().len(), 2);
|
||||
}
|
||||
+6
-55
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
net::{SocketAddr, UdpSocket},
|
||||
sync::{mpsc::sync_channel, Arc, Condvar, Mutex},
|
||||
sync::{Arc, Condvar, Mutex},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -11,13 +11,11 @@ use irtt_proto::{
|
||||
};
|
||||
use tokio::{runtime::Builder, time::timeout};
|
||||
|
||||
use super::{
|
||||
blocking::{join_worker, WorkerRuntime},
|
||||
BlockingManagedClient, BlockingManagedJoinError, ManagedClientConfig, ManagedCompletionPolicy,
|
||||
ManagedEndReason, ManagedEvent, ManagedLifecycle, ManagedPacing, ManagedTargetConfig,
|
||||
ManagedTargetEndReason,
|
||||
use irtt_client::managed::{
|
||||
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEndReason,
|
||||
ManagedEvent, ManagedLifecycle, ManagedPacing, ManagedTargetConfig, ManagedTargetEndReason,
|
||||
};
|
||||
use crate::ClientConfig;
|
||||
use irtt_client::ClientConfig;
|
||||
|
||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||
|
||||
@@ -167,7 +165,7 @@ fn target(server: &EchoServer) -> ManagedTargetConfig {
|
||||
ManagedTargetConfig::new("target", server.addr.to_string())
|
||||
}
|
||||
|
||||
fn assert_stopped(outcome: &super::ManagedOutcome) {
|
||||
fn assert_stopped(outcome: &irtt_client::managed::ManagedOutcome) {
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
}
|
||||
|
||||
@@ -309,53 +307,6 @@ fn dropping_owner_stops_and_joins_worker() {
|
||||
server.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_panic_is_a_join_error() {
|
||||
let worker = thread::spawn(|| -> super::ManagedOutcome { panic!("test worker panic") });
|
||||
assert_eq!(
|
||||
join_worker(worker),
|
||||
Err(BlockingManagedJoinError::WorkerPanicked)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_worker_runtime_inside_async_context_does_not_panic() {
|
||||
test_runtime().block_on(async {
|
||||
let runtime = Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
drop(WorkerRuntime::new(runtime));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_runtime_shutdown_is_bounded_with_blocking_work() {
|
||||
let runtime = Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let (started_send, started_receive) = sync_channel(0);
|
||||
let (release_send, release_receive) = sync_channel(0);
|
||||
|
||||
runtime.block_on(async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
started_send.send(()).unwrap();
|
||||
release_receive.recv().unwrap();
|
||||
});
|
||||
});
|
||||
started_receive
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap();
|
||||
|
||||
let started = Instant::now();
|
||||
WorkerRuntime::new(runtime).shutdown();
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
release_send.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_after_natural_completion_preserves_outcome() {
|
||||
let server = EchoServer::start();
|
||||
@@ -1,3 +1,4 @@
|
||||
use irtt_client::{Authentication, HmacKey};
|
||||
mod support;
|
||||
|
||||
use std::time::Duration;
|
||||
@@ -27,7 +28,7 @@ fn hmac_open_success_negotiates_without_warnings() {
|
||||
TimestampFields::default(),
|
||||
Some(key),
|
||||
|addr| ClientConfig {
|
||||
hmac_key: Some(config_key),
|
||||
auth: Authentication::Hmac(HmacKey::new(config_key)),
|
||||
..config_for_params(addr, &config_params)
|
||||
},
|
||||
);
|
||||
@@ -52,7 +53,7 @@ fn hmac_echo_success_verifies_request_and_accepts_reply() {
|
||||
|
||||
let run = run_one_probe_with_config(params.clone(), standard_timestamps(), Some(key), |addr| {
|
||||
ClientConfig {
|
||||
hmac_key: Some(config_key),
|
||||
auth: Authentication::Hmac(HmacKey::new(config_key)),
|
||||
..config_for_params(addr, &config_params)
|
||||
}
|
||||
});
|
||||
@@ -94,7 +95,7 @@ fn hmac_close_success_sends_authenticated_close_and_closes_session() {
|
||||
let params = default_params();
|
||||
let server = start_hmac_close_server(params.clone(), key.clone());
|
||||
let mut config = config_for_params(server.addr, ¶ms);
|
||||
config.hmac_key = Some(key);
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key));
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
@@ -135,7 +136,9 @@ fn hmac_required_server_rejects_missing_or_wrong_client_key() {
|
||||
let server = start_hmac_required_open_drop_server(server_key, Duration::from_millis(250));
|
||||
let mut config = config_for_params(server.addr, &default_params());
|
||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||
config.hmac_key = hmac_key;
|
||||
config.auth = hmac_key.map_or(Authentication::Unauthenticated, |key| {
|
||||
Authentication::Hmac(HmacKey::new(key))
|
||||
});
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
@@ -158,7 +161,7 @@ fn bad_hmac_echo_reply_is_rejected_without_echo_reply_event() {
|
||||
let params = default_params();
|
||||
let server = start_bad_hmac_echo_reply_server(params.clone(), key.clone());
|
||||
let mut config = config_for_params(server.addr, ¶ms);
|
||||
config.hmac_key = Some(key);
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key));
|
||||
config.socket_config = SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(500)),
|
||||
..Default::default()
|
||||
@@ -205,7 +208,7 @@ fn hmac_open_reply_with_bad_hmac_is_ignored_until_timeout() {
|
||||
let params = default_params();
|
||||
let server = support::start_bad_hmac_open_reply_server(params.clone(), key.clone(), wrong_key);
|
||||
let mut config = config_for_params(server.addr, ¶ms);
|
||||
config.hmac_key = Some(key);
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key));
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
@@ -221,7 +224,7 @@ fn non_hmac_client_open_does_not_set_hmac_flag() {
|
||||
);
|
||||
let server = support::start_open_server(params.clone(), None);
|
||||
let mut config = config_for_params(server.addr, ¶ms);
|
||||
config.hmac_key = None;
|
||||
config.auth = Authentication::Unauthenticated;
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
@@ -278,7 +281,7 @@ fn backend_hmac_correct_key_succeeds() {
|
||||
let params = default_params();
|
||||
let peer = BackendPeer::start_open_echo(Some(key.clone()));
|
||||
let mut config = config_for_params(peer.addr(), ¶ms);
|
||||
config.hmac_key = Some(key);
|
||||
config.auth = Authentication::Hmac(HmacKey::new(key));
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
@@ -301,7 +304,9 @@ fn backend_hmac_required_rejects_missing_or_wrong_client_key() {
|
||||
let peer = BackendPeer::start_hmac_required(server_key);
|
||||
let mut config = config_for_params(peer.addr(), &default_params());
|
||||
config.open_timeouts = vec![Duration::from_millis(200)];
|
||||
config.hmac_key = hmac_key;
|
||||
config.auth = hmac_key.map_or(Authentication::Unauthenticated, |key| {
|
||||
Authentication::Hmac(HmacKey::new(key))
|
||||
});
|
||||
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
mod support;
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use irtt_client::{managed::*, ClientConfig, ClientEvent};
|
||||
use irtt_server::ServerConfig;
|
||||
use support::InTreeServer;
|
||||
use tokio::time::timeout;
|
||||
|
||||
// A real service and public events protect cadence/fairness without depending
|
||||
// on a scheduler, a send cursor, or a private event-publication hook.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn both_pacing_modes_serve_every_target_on_its_negotiated_grid() {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
let server = InTreeServer::start(ServerConfig::default());
|
||||
for pacing in [ManagedPacing::Burst, ManagedPacing::Staggered] {
|
||||
let mut observed_pacing = false;
|
||||
for _ in 0..3 {
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: Some(Duration::from_millis(600)),
|
||||
interval: Duration::from_millis(100),
|
||||
probe_timeout: Duration::from_millis(100),
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
..ClientConfig::default()
|
||||
},
|
||||
pacing,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
let targets = ["first", "second"]
|
||||
.map(|id| ManagedTargetConfig::new(id, server.addr.to_string()));
|
||||
let (task, handle) = ManagedClient::task(config, targets.into()).unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
let run = tokio::spawn(task);
|
||||
let mut sends: HashMap<TargetInstance, Vec<_>> = HashMap::new();
|
||||
let mut actual_sends = Vec::new();
|
||||
while let Ok(event) = events.recv().await {
|
||||
if let ManagedEvent::Client {
|
||||
target,
|
||||
event:
|
||||
ClientEvent::EchoSent {
|
||||
scheduled_at,
|
||||
sent_at,
|
||||
..
|
||||
},
|
||||
} = event
|
||||
{
|
||||
let scheduled = scheduled_at.expect("managed probes expose their cadence");
|
||||
assert!(sent_at.mono >= scheduled);
|
||||
actual_sends.push((target.clone(), sent_at.mono));
|
||||
let previous = sends.entry(target).or_default();
|
||||
if let Some(last) = previous.last() {
|
||||
let elapsed: Duration = scheduled.duration_since(*last);
|
||||
assert!(!elapsed.is_zero());
|
||||
assert_eq!(
|
||||
elapsed.as_nanos() % Duration::from_millis(100).as_nanos(),
|
||||
0
|
||||
);
|
||||
}
|
||||
previous.push(scheduled);
|
||||
}
|
||||
}
|
||||
let outcome = run.await.unwrap();
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::TargetsComplete);
|
||||
assert_eq!(outcome.successful_target_outcomes, 2);
|
||||
assert_eq!(sends.len(), 2);
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
assert!(sends[&target.target].len() >= 2);
|
||||
assert!(target.replies_received >= 2);
|
||||
}
|
||||
// Use only the initial pair: later stagger gates can legitimately
|
||||
// compress after a delayed wake. Startup may instead serve one
|
||||
// target twice before the other opens, so retry a fresh run then.
|
||||
if actual_sends.len() < 2 || actual_sends[0].0 == actual_sends[1].0 {
|
||||
continue;
|
||||
}
|
||||
let gap = actual_sends[1].1.duration_since(actual_sends[0].1);
|
||||
match pacing {
|
||||
ManagedPacing::Staggered => {
|
||||
assert!(
|
||||
gap >= Duration::from_millis(50),
|
||||
"first stagger gate was bypassed: {gap:?}"
|
||||
);
|
||||
observed_pacing = true;
|
||||
}
|
||||
ManagedPacing::Burst => {
|
||||
// A pause between burst sends makes this observation
|
||||
// inconclusive; it must not fail an otherwise valid run.
|
||||
observed_pacing = gap < Duration::from_millis(25);
|
||||
}
|
||||
}
|
||||
if observed_pacing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
observed_pacing,
|
||||
"could not observe {pacing:?} startup pacing in three fresh runs"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("managed pacing did not complete");
|
||||
}
|
||||
|
||||
// Updates, receipts, generations and cleanup are observable through the control
|
||||
// handle and event subscription; no task internals or artificial races are needed.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn target_updates_preserve_identical_sessions_and_retire_removed_generations() {
|
||||
timeout(Duration::from_secs(5), async {
|
||||
let server = InTreeServer::start(ServerConfig::default());
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: None,
|
||||
interval: Duration::from_millis(20),
|
||||
probe_timeout: Duration::from_millis(100),
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
..ClientConfig::default()
|
||||
},
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
outcome_history_limit: 1,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
let configured = ManagedTargetConfig::new("one", server.addr.to_string());
|
||||
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
let run = tokio::spawn(task);
|
||||
let added = handle
|
||||
.update_targets(vec![configured.clone()])
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap();
|
||||
let first = added.status.targets[0].target.clone();
|
||||
loop {
|
||||
if matches!(events.recv().await.unwrap(), ManagedEvent::Client {
|
||||
target, event: ClientEvent::EchoReply { .. }
|
||||
} if target == first)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
let identical = handle
|
||||
.update_targets(vec![configured.clone()])
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(identical.status.targets.len(), 1);
|
||||
assert_eq!(identical.status.targets[0].target, first);
|
||||
loop {
|
||||
match events.recv().await.unwrap() {
|
||||
ManagedEvent::Client {
|
||||
target,
|
||||
event: ClientEvent::SessionStarted { .. },
|
||||
} if target == first => panic!("identical update reopened the session"),
|
||||
ManagedEvent::Client {
|
||||
target,
|
||||
event: ClientEvent::EchoReply { .. },
|
||||
} if target == first => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
handle.update_targets(vec![]).unwrap().await.unwrap();
|
||||
loop {
|
||||
if let ManagedEvent::TargetFinished { outcome } = events.recv().await.unwrap() {
|
||||
let status = handle.status();
|
||||
assert_eq!(outcome.target, first);
|
||||
assert_eq!(outcome.end_reason, ManagedTargetEndReason::Removed);
|
||||
assert_eq!(outcome.cleanup_failure, None);
|
||||
assert_eq!(status.total_target_outcomes, 1);
|
||||
assert_eq!(status.successful_target_outcomes, 1);
|
||||
assert_eq!(
|
||||
status.recent_target_outcomes.as_ref(),
|
||||
&[(*outcome).clone()]
|
||||
);
|
||||
assert!(status.targets.iter().all(|target| target.target != first
|
||||
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let readded = handle
|
||||
.update_targets(vec![configured])
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap();
|
||||
let second = readded
|
||||
.status
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| target.desired)
|
||||
.unwrap()
|
||||
.target
|
||||
.clone();
|
||||
assert!(second.generation > first.generation);
|
||||
loop {
|
||||
if matches!(events.recv().await.unwrap(), ManagedEvent::Client {
|
||||
target, event: ClientEvent::EchoReply { .. }
|
||||
} if target == second)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
let stop = handle.stop();
|
||||
loop {
|
||||
match events.recv().await.unwrap() {
|
||||
ManagedEvent::TargetFinished { outcome } => {
|
||||
let status = handle.status();
|
||||
assert_eq!(outcome.target, second);
|
||||
assert_eq!(status.total_target_outcomes, 2);
|
||||
assert_eq!(
|
||||
status.recent_target_outcomes.as_ref(),
|
||||
&[(*outcome).clone()]
|
||||
);
|
||||
assert!(status.targets.iter().all(|target| target.target != second
|
||||
|| target.lifecycle == ManagedTargetLifecycle::Terminal));
|
||||
}
|
||||
ManagedEvent::Completed { outcome } => {
|
||||
let status = handle.status();
|
||||
assert_eq!(status.lifecycle, ManagedLifecycle::Completed);
|
||||
assert_eq!(status.final_outcome.as_ref(), Some(&outcome));
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let outcome = run.await.unwrap();
|
||||
stop.await;
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
assert_eq!(outcome.total_target_outcomes, 2);
|
||||
assert_eq!(outcome.successful_target_outcomes, 2);
|
||||
assert_eq!(outcome.failed_target_outcomes, 0);
|
||||
assert_eq!(outcome.discarded_target_outcomes, 1);
|
||||
assert_eq!(outcome.recent_target_outcomes.len(), 1);
|
||||
let status = handle.status();
|
||||
assert_eq!(status.total_target_outcomes, 2);
|
||||
assert_eq!(status.successful_target_outcomes, 2);
|
||||
assert_eq!(status.failed_target_outcomes, 0);
|
||||
assert_eq!(status.discarded_target_outcomes, 1);
|
||||
assert_eq!(status.recent_target_outcomes.len(), 1);
|
||||
assert_eq!(status.recent_target_outcomes[0].target, second);
|
||||
assert!(outcome.recent_target_outcomes.iter().any(|target| {
|
||||
target.target == second
|
||||
&& target.end_reason == ManagedTargetEndReason::Stopped
|
||||
&& target.cleanup_failure.is_none()
|
||||
}));
|
||||
})
|
||||
.await
|
||||
.expect("managed target updates did not complete");
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
use irtt_client::{Authentication, HmacKey};
|
||||
use std::{
|
||||
future::Future,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex},
|
||||
task::{Context, Poll, Waker},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_request, encode_echo_reply, encode_open_reply, flags, verify_packet_hmac,
|
||||
DecodedRequestKind, EchoReply, OpenReply, Params, ReceivedStats, StampAt, TimestampFields,
|
||||
};
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
use irtt_client::RunMode;
|
||||
use irtt_client::{managed::*, ClientConfig};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ServerBehavior {
|
||||
Echo,
|
||||
NoTest,
|
||||
PeerClose,
|
||||
DelayedEcho(Duration),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PacketKind {
|
||||
Open,
|
||||
Probe,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PacketRecord {
|
||||
kind: PacketKind,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
struct TestServer {
|
||||
addr: SocketAddr,
|
||||
records: Arc<Mutex<Vec<PacketRecord>>>,
|
||||
thread: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
fn finish(self) -> Vec<PacketRecord> {
|
||||
self.thread.join().unwrap();
|
||||
self.records.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admits a request the way a compliant server does: structural decode first,
|
||||
/// then HMAC presence policy, then authentication. Returns `None` for anything
|
||||
/// a server would silently discard.
|
||||
fn decode_server_request<'a>(
|
||||
packet: &'a [u8],
|
||||
key: Option<&[u8]>,
|
||||
) -> Option<DecodedRequestKind<'a>> {
|
||||
let request = decode_request(packet).ok()?;
|
||||
if request.hmac_present != key.is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(key) = key {
|
||||
verify_packet_hmac(key, packet).ok()?;
|
||||
}
|
||||
Some(request.kind)
|
||||
}
|
||||
|
||||
fn decode_open_params(packet: &[u8], key: Option<&[u8]>) -> Params {
|
||||
match decode_server_request(packet, key) {
|
||||
Some(DecodedRequestKind::Open { params, .. }) => Params::decode(params).unwrap(),
|
||||
other => panic!("expected an authenticated open request, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_server(behavior: ServerBehavior, key: Option<Vec<u8>>) -> TestServer {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let records = Arc::new(Mutex::new(Vec::new()));
|
||||
let thread_records = Arc::clone(&records);
|
||||
let thread = thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let (open_len, peer) = socket.recv_from(&mut buffer).unwrap();
|
||||
thread_records.lock().unwrap().push(PacketRecord {
|
||||
kind: PacketKind::Open,
|
||||
at: Instant::now(),
|
||||
});
|
||||
let negotiated = decode_open_params(&buffer[..open_len], key.as_deref());
|
||||
let no_test = matches!(behavior, ServerBehavior::NoTest);
|
||||
let open_reply = encode_open_reply(
|
||||
&OpenReply {
|
||||
flags: flags::FLAG_OPEN
|
||||
| flags::FLAG_REPLY
|
||||
| if no_test { flags::FLAG_CLOSE } else { 0 },
|
||||
token: if no_test { 0 } else { TOKEN },
|
||||
params: negotiated.clone(),
|
||||
},
|
||||
key.as_deref(),
|
||||
)
|
||||
.unwrap();
|
||||
socket.send_to(&open_reply, peer).unwrap();
|
||||
if no_test {
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
let Ok((len, packet_peer)) = socket.recv_from(&mut buffer) else {
|
||||
return;
|
||||
};
|
||||
let packet = &buffer[..len];
|
||||
match decode_server_request(packet, key.as_deref()) {
|
||||
Some(DecodedRequestKind::Echo { sequence, .. }) => {
|
||||
thread_records.lock().unwrap().push(PacketRecord {
|
||||
kind: PacketKind::Probe,
|
||||
at: Instant::now(),
|
||||
});
|
||||
if let ServerBehavior::DelayedEcho(delay) = behavior {
|
||||
thread::sleep(delay);
|
||||
}
|
||||
let peer_close = matches!(behavior, ServerBehavior::PeerClose);
|
||||
let reply = encode_echo_reply(
|
||||
&EchoReply {
|
||||
flags: flags::FLAG_REPLY
|
||||
| if peer_close { flags::FLAG_CLOSE } else { 0 },
|
||||
token: TOKEN,
|
||||
sequence,
|
||||
recv_count: None,
|
||||
recv_window: None,
|
||||
timestamps: TimestampFields::default(),
|
||||
payload: Vec::new(),
|
||||
},
|
||||
&negotiated,
|
||||
key.as_deref(),
|
||||
)
|
||||
.unwrap();
|
||||
socket.send_to(&reply, packet_peer).unwrap();
|
||||
if peer_close {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Some(DecodedRequestKind::Close { .. }) => {
|
||||
thread_records.lock().unwrap().push(PacketRecord {
|
||||
kind: PacketKind::Close,
|
||||
at: Instant::now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
Some(DecodedRequestKind::Open { .. }) | None => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
TestServer {
|
||||
addr,
|
||||
records,
|
||||
thread,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Runtime {
|
||||
Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn config(pacing: ManagedPacing) -> ManagedClientConfig {
|
||||
ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
duration: Some(Duration::from_millis(130)),
|
||||
interval: Duration::from_millis(60),
|
||||
probe_timeout: Duration::from_millis(35),
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
received_stats: ReceivedStats::None,
|
||||
stamp_at: StampAt::None,
|
||||
..ClientConfig::default()
|
||||
},
|
||||
pacing,
|
||||
final_drain: Duration::from_millis(5),
|
||||
..ManagedClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn target(id: &str, addr: SocketAddr) -> ManagedTargetConfig {
|
||||
ManagedTargetConfig::new(id, addr.to_string())
|
||||
}
|
||||
|
||||
fn poll_once<F: Future>(future: Pin<&mut F>) -> Poll<F::Output> {
|
||||
Future::poll(future, &mut Context::from_waker(Waker::noop()))
|
||||
}
|
||||
|
||||
fn probes(records: &[PacketRecord]) -> Vec<Instant> {
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|record| matches!(record.kind, PacketKind::Probe).then_some(record.at))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_close(records: &[PacketRecord]) -> bool {
|
||||
records
|
||||
.iter()
|
||||
.any(|record| matches!(record.kind, PacketKind::Close))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_is_runtime_and_io_free() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![ManagedTargetConfig::new("dns", "no-such-host.invalid")],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::NotStarted);
|
||||
drop(task);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiescent_rejects_empty_initial_targets() {
|
||||
assert!(matches!(
|
||||
ManagedClient::task(config(ManagedPacing::Staggered), vec![]),
|
||||
Err(ManagedConfigError::EmptyInitialTargets)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_waits_for_stop() {
|
||||
let mut config = config(ManagedPacing::Staggered);
|
||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
let (task, handle) = ManagedClient::task(config, vec![]).unwrap();
|
||||
let outcome = runtime().block_on(async move {
|
||||
let mut task = Box::pin(task);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(20), task.as_mut())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Running);
|
||||
let receipt = handle.stop();
|
||||
let outcome = task.await;
|
||||
receipt.await;
|
||||
outcome
|
||||
});
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_event_capacity_is_rejected_without_panic() {
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
managed.event_capacity = usize::MAX;
|
||||
assert!(matches!(
|
||||
ManagedClient::task(managed, vec![]),
|
||||
Err(ManagedConfigError::EventCapacityTooLarge {
|
||||
configured: usize::MAX,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extreme_command_capacity_is_rejected_without_panic() {
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
managed.command_capacity = usize::MAX;
|
||||
assert!(matches!(
|
||||
ManagedClient::task(managed, vec![]),
|
||||
Err(ManagedConfigError::CommandCapacityTooLarge {
|
||||
configured: usize::MAX,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_poll_without_runtime_fails_durably() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||
)
|
||||
.unwrap();
|
||||
let mut task = Box::pin(task);
|
||||
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||
panic!("task did not fail on its first poll")
|
||||
};
|
||||
assert_eq!(
|
||||
outcome.end_reason,
|
||||
ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime)
|
||||
);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_poll_stop() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![ManagedTargetConfig::new("one", "no-such-host.invalid")],
|
||||
)
|
||||
.unwrap();
|
||||
let mut receipt = Box::pin(handle.stop());
|
||||
let mut task = Box::pin(task);
|
||||
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
|
||||
panic!("pre-stopped task did not complete")
|
||||
};
|
||||
assert!(poll_once(receipt.as_mut()).is_ready());
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_immediate_replies_use_retained_drain_deadline() {
|
||||
let server = start_server(ServerBehavior::Echo, None);
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.client.probe_timeout = Duration::from_secs(3);
|
||||
managed.final_drain = Duration::from_millis(30);
|
||||
let (task, _) = ManagedClient::task(managed, vec![target("one", server.addr)]).unwrap();
|
||||
let started_at = Instant::now();
|
||||
let outcome = runtime().block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("replied probes must not hold the drain until their obsolete timeout")
|
||||
});
|
||||
assert!(started_at.elapsed() < Duration::from_secs(1));
|
||||
let records = server.finish();
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::TargetsComplete);
|
||||
assert!(matches!(
|
||||
outcome.recent_target_outcomes[0].end_reason,
|
||||
ManagedTargetEndReason::TestComplete
|
||||
));
|
||||
assert!(!probes(&records).is_empty() && has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_target_completion() {
|
||||
let server = start_server(ServerBehavior::NoTest, None);
|
||||
let mut config = config(ManagedPacing::Staggered);
|
||||
config.client.run_mode = RunMode::NoTest;
|
||||
let (task, _) = ManagedClient::task(config, vec![target("no-test", server.addr)]).unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
let records = server.finish();
|
||||
assert!(matches!(
|
||||
outcome.recent_target_outcomes[0].end_reason,
|
||||
ManagedTargetEndReason::NoTestComplete
|
||||
));
|
||||
assert!(probes(&records).is_empty() && !has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_peer_close_outcome() {
|
||||
let key = b"managed-peer-close".to_vec();
|
||||
let server = start_server(ServerBehavior::PeerClose, Some(key.clone()));
|
||||
let mut configured = target("peer", server.addr);
|
||||
configured.auth = TargetAuth::Override(Authentication::Hmac(HmacKey::new(key)));
|
||||
let (task, _) =
|
||||
ManagedClient::task(config(ManagedPacing::Staggered), vec![configured]).unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
let records = server.finish();
|
||||
assert_eq!(outcome.peer_closed_target_outcomes, 1);
|
||||
assert!(matches!(
|
||||
outcome.recent_target_outcomes[0].end_reason,
|
||||
ManagedTargetEndReason::PeerClosed
|
||||
));
|
||||
assert!(!has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_multi_target_completion() {
|
||||
let first = start_server(ServerBehavior::Echo, None);
|
||||
let second = start_server(ServerBehavior::Echo, None);
|
||||
let (task, _) = ManagedClient::task(
|
||||
config(ManagedPacing::Burst),
|
||||
vec![target("first", first.addr), target("second", second.addr)],
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
assert_eq!(outcome.total_target_outcomes, 2);
|
||||
assert_eq!(outcome.successful_target_outcomes, 2);
|
||||
assert!(!probes(&first.finish()).is_empty());
|
||||
assert!(!probes(&second.finish()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_failure_isolation() {
|
||||
let good = start_server(ServerBehavior::Echo, None);
|
||||
let unused = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let bad_addr = unused.local_addr().unwrap();
|
||||
drop(unused);
|
||||
let (task, _) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("good", good.addr), target("bad", bad_addr)],
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
assert_eq!(outcome.total_target_outcomes, 2);
|
||||
assert_eq!(outcome.successful_target_outcomes, 1);
|
||||
assert_eq!(outcome.failed_target_outcomes, 1);
|
||||
assert!(!probes(&good.finish()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_limit_failure_drains_reply_and_closes_session() {
|
||||
let key = b"managed-send-failure-cleanup".to_vec();
|
||||
let server = start_server(
|
||||
ServerBehavior::DelayedEcho(Duration::from_millis(80)),
|
||||
Some(key.clone()),
|
||||
);
|
||||
let mut managed = config(ManagedPacing::Staggered);
|
||||
managed.client.duration = Some(Duration::from_millis(250));
|
||||
managed.client.interval = Duration::from_millis(20);
|
||||
managed.client.probe_timeout = Duration::from_secs(2);
|
||||
managed.client.max_pending_probes = 1;
|
||||
managed.final_drain = Duration::from_millis(50);
|
||||
let mut configured = target("one", server.addr);
|
||||
configured.auth = TargetAuth::Override(Authentication::Hmac(HmacKey::new(key)));
|
||||
let (task, _) = ManagedClient::task(managed, vec![configured]).unwrap();
|
||||
let outcome = runtime().block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("send failure cleanup must shorten after draining the committed reply")
|
||||
});
|
||||
let records = server.finish();
|
||||
let target = &outcome.recent_target_outcomes[0];
|
||||
assert_eq!(outcome.failed_target_outcomes, 1);
|
||||
assert!(matches!(
|
||||
target.end_reason,
|
||||
ManagedTargetEndReason::Failed(ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Sending,
|
||||
kind: ManagedTargetFailureKind::ResourceExhausted,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_eq!(target.cleanup_failure, None);
|
||||
assert_eq!(target.packets_sent, 1);
|
||||
assert_eq!(target.replies_received, 1);
|
||||
assert_eq!(probes(&records).len(), 1);
|
||||
assert!(has_close(&records));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_loss_independence() {
|
||||
let server = start_server(ServerBehavior::Echo, None);
|
||||
let mut config = config(ManagedPacing::Staggered);
|
||||
config.event_capacity = 1;
|
||||
let (task, handle) = ManagedClient::task(config, vec![target("one", server.addr)]).unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
let outcome = runtime().block_on(task);
|
||||
assert!(matches!(
|
||||
events.try_recv(),
|
||||
Err(broadcast::error::TryRecvError::Lagged(_))
|
||||
));
|
||||
assert_eq!(outcome.successful_target_outcomes, 1);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Completed);
|
||||
server.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_all_handles_does_not_stop() {
|
||||
let server = start_server(ServerBehavior::Echo, None);
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", server.addr)],
|
||||
)
|
||||
.unwrap();
|
||||
drop(handle);
|
||||
let outcome = runtime().block_on(task);
|
||||
assert_eq!(outcome.end_reason, ManagedEndReason::TargetsComplete);
|
||||
server.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_drop_abandonment() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||
)
|
||||
.unwrap();
|
||||
let mut events = handle.subscribe().unwrap();
|
||||
drop(task);
|
||||
assert_eq!(handle.status().lifecycle, ManagedLifecycle::Abandoned);
|
||||
assert_eq!(handle.status().total_target_outcomes, 0);
|
||||
assert!(matches!(events.try_recv(), Ok(ManagedEvent::Abandoned)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_subscription_is_closed() {
|
||||
let (task, handle) = ManagedClient::task(
|
||||
config(ManagedPacing::Staggered),
|
||||
vec![target("one", "127.0.0.1:9".parse().unwrap())],
|
||||
)
|
||||
.unwrap();
|
||||
let mut task = Box::pin(task);
|
||||
assert!(poll_once(task.as_mut()).is_ready());
|
||||
assert!(matches!(
|
||||
handle.subscribe(),
|
||||
Err(ManagedSubscribeError::Closed)
|
||||
));
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use irtt_client::{Authentication, HmacKey};
|
||||
mod support;
|
||||
mod protocol_options {
|
||||
pub mod backend_smoke;
|
||||
@@ -148,7 +149,7 @@ fn hmac_rich_mode_uses_negotiated_echo_layout_and_decodes_reply() {
|
||||
let config_key = key.clone();
|
||||
let run = run_one_probe_with_config(params.clone(), standard_timestamps(), Some(key), |addr| {
|
||||
ClientConfig {
|
||||
hmac_key: Some(config_key),
|
||||
auth: Authentication::Hmac(HmacKey::new(config_key)),
|
||||
..config_for_params(addr, &config_params)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ categories = ["network-programming"]
|
||||
hmac = "0.13"
|
||||
md-5 = "0.11"
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.20"
|
||||
thiserror = "2.0.21"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.11.0"
|
||||
@@ -333,9 +333,9 @@ effective parameters must be safe for the server to run.
|
||||
- The transition is pure and is committed only after the reply has encoded, so
|
||||
an internal encoding failure cannot leave a session claiming to have answered
|
||||
a request it never did.
|
||||
- Clock sampling is a private injected seam, like token generation, not a
|
||||
runtime abstraction: the receive instant is taken as soon as a datagram is
|
||||
classified as an echo and the send instant just before the reply is built, so
|
||||
- Clock sampling uses the system clock directly, not a runtime abstraction: the
|
||||
receive instant is taken as soon as a datagram is classified as an echo and
|
||||
the send instant just before the reply is built, so
|
||||
they bracket the server's own handling. The monotonic origin belongs to the
|
||||
clock source and is stable for its life.
|
||||
- A reply's receive instant is held back to its send instant where the wall
|
||||
@@ -601,16 +601,22 @@ half-created session.
|
||||
maintain shadow encoders for well-formed wire data.
|
||||
- Hand-build packets only where the point is a payload a compliant encoder
|
||||
cannot produce — truncated varints, out-of-range enums, corrupted MACs.
|
||||
- Token generation and clock sampling are the only nondeterministic parts; tests
|
||||
inject a scripted source for each, so identity, collisions, allocation failure
|
||||
and timestamp values are all assertable. Keep both seams private, and keep
|
||||
timestamp, rate and lifetime tests free of sleeps, tolerances and real
|
||||
wall-clock assertions.
|
||||
- Two clock fakes, for two different questions. A scripted clock returns a fixed
|
||||
list of samples and is for tests that assert individual timestamp *readings*; a
|
||||
hand-moved clock stands still until the test moves it, and is for rate and
|
||||
lifetime tests, which care when a datagram arrived and not how many times the
|
||||
core read the clock on the way. Prefer the latter for anything about deadlines.
|
||||
- Prefer the public `ServerCore` packet interface and real runtime sockets for
|
||||
durable behavior. Use production encoders and decoders, actual OS-generated
|
||||
tokens, and observable replies/session capacity rather than private state.
|
||||
- Observe nonzero rate refill and lifetime deadlines with real time when an
|
||||
oversleep cannot invalidate the assertion. Bound observation loops and report
|
||||
useful failures. Do not restore fake clocks, random-source injection, race
|
||||
gates, synthetic ancillary classifiers or private observability merely to
|
||||
reproduce the deleted test structure. Focused fault injection remains allowed
|
||||
under the root policy only when no reasonable production-interface alternative
|
||||
can protect an important invariant.
|
||||
- Random fill's OS-source failure cannot be forced through the packet interface.
|
||||
Its zero-fill fallback remains an explicitly unexercised error path; ordinary
|
||||
random-fill wire tests must not claim to cover it or add an injection seam
|
||||
merely for coverage. Existing token-allocation fault tests protect bounded
|
||||
allocation and transactional failure, which cannot be driven reliably with
|
||||
actual random draws.
|
||||
- Assert rate and lifetime behavior from replies: which datagrams came back, the
|
||||
count and window they reported, and `session_count`. The token bucket is a
|
||||
black-box inference about the reference server, not a protocol requirement, so
|
||||
|
||||
@@ -15,7 +15,7 @@ categories = ["network-programming"]
|
||||
irtt-proto.workspace = true
|
||||
getrandom = "0.4.3"
|
||||
socket2 = { version = "0.6.5", features = ["all"] }
|
||||
thiserror = "2.0.20"
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "net", "time", "sync", "macros"] }
|
||||
|
||||
# Wildcard listeners recover each request's local destination address and send
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
//! Clock sampling for echo timestamps.
|
||||
//!
|
||||
//! An echo reply reports when the server received the request and when it sent
|
||||
//! the answer, so the core needs a clock. It takes one through this private
|
||||
//! seam rather than reading the system clock inline, so that timestamp behavior
|
||||
//! is deterministically testable without a runtime, sleeps or timing
|
||||
//! tolerances.
|
||||
//!
|
||||
//! This is the clock counterpart of [`TokenSource`](crate::token::TokenSource)
|
||||
//! and nothing more. It is **not** a runtime abstraction, not a transport
|
||||
//! boundary and not a pluggable product API: it is crate-private and the public
|
||||
//! constructor still takes only a [`ServerConfig`](crate::ServerConfig).
|
||||
//!
|
||||
//! The public [`ServerCore::handle_datagram`](crate::ServerCore::handle_datagram)
|
||||
//! entry point takes no timestamp: the core samples every instant it reports.
|
||||
//! The Tokio runtime uses a crate-private entry point that may additionally hand
|
||||
@@ -21,10 +10,7 @@
|
||||
//! one that models transport metadata. A [`ClockSample`] remains one paired
|
||||
//! userspace instant and never carries a kernel reading.
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// One instant, read from both clock domains together.
|
||||
///
|
||||
@@ -77,19 +63,6 @@ impl ClockSample {
|
||||
}
|
||||
}
|
||||
|
||||
/// A source of clock samples.
|
||||
///
|
||||
/// Deliberately private, like [`TokenSource`](crate::token::TokenSource): it
|
||||
/// exists so tests can script the receive and send instants of a reply, not as
|
||||
/// an extension point. The production implementation is [`SystemClock`].
|
||||
///
|
||||
/// Sampling is infallible. A clock that could refuse would make an admitted
|
||||
/// echo fail for a reason that is neither the peer's fault nor recoverable, so
|
||||
/// the production source saturates rather than reporting an error.
|
||||
pub(crate) trait ClockSource: fmt::Debug + Send {
|
||||
fn sample(&mut self) -> ClockSample;
|
||||
}
|
||||
|
||||
/// Reads the wall clock from [`SystemTime`] and the monotonic clock from an
|
||||
/// [`Instant`] captured when the source was created.
|
||||
///
|
||||
@@ -115,8 +88,8 @@ impl Default for SystemClock {
|
||||
}
|
||||
}
|
||||
|
||||
impl ClockSource for SystemClock {
|
||||
fn sample(&mut self) -> ClockSample {
|
||||
impl SystemClock {
|
||||
pub(crate) fn sample(&mut self) -> ClockSample {
|
||||
ClockSample {
|
||||
wall_ns: wall_ns(),
|
||||
mono_ns: saturating_ns(self.origin.elapsed()),
|
||||
@@ -136,7 +109,7 @@ fn wall_ns() -> i64 {
|
||||
/// does not fit the wire's signed-nanosecond field.
|
||||
///
|
||||
/// This is the conversion for an instant the server did **not** read from its
|
||||
/// own [`ClockSource`] — today, a kernel receive timestamp the transport
|
||||
/// own clock — today, a kernel receive timestamp the transport
|
||||
/// observed. It reports unrepresentability rather than saturating, because a
|
||||
/// clamped instant is indistinguishable from a real one at the boundary and
|
||||
/// would then be compared against a genuine sample as though it were plausible.
|
||||
@@ -183,88 +156,3 @@ fn mean_ns(a: i64, b: i64) -> i64 {
|
||||
i64::MAX
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Constructed values only: the production clock's real readings are not
|
||||
/// assertable without a timing tolerance, and the behavioral echo tests use
|
||||
/// a scripted source instead.
|
||||
#[test]
|
||||
fn a_midpoint_is_the_per_domain_mean_and_cannot_overflow() {
|
||||
let receive = ClockSample {
|
||||
wall_ns: 1_000,
|
||||
mono_ns: 10_000,
|
||||
};
|
||||
let send = ClockSample {
|
||||
wall_ns: 1_200,
|
||||
mono_ns: 10_300,
|
||||
};
|
||||
assert_eq!(
|
||||
receive.midpoint(send),
|
||||
ClockSample {
|
||||
wall_ns: 1_100,
|
||||
mono_ns: 10_150,
|
||||
}
|
||||
);
|
||||
|
||||
// The sum overflows `i64`; the mean does not.
|
||||
let high = ClockSample {
|
||||
wall_ns: i64::MAX,
|
||||
mono_ns: i64::MAX,
|
||||
};
|
||||
assert_eq!(
|
||||
high.midpoint(high),
|
||||
ClockSample {
|
||||
wall_ns: i64::MAX,
|
||||
mono_ns: i64::MAX,
|
||||
}
|
||||
);
|
||||
let low = ClockSample {
|
||||
wall_ns: i64::MIN,
|
||||
mono_ns: i64::MIN,
|
||||
};
|
||||
assert_eq!(
|
||||
low.midpoint(low),
|
||||
ClockSample {
|
||||
wall_ns: i64::MIN,
|
||||
mono_ns: i64::MIN,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
high.midpoint(low),
|
||||
ClockSample {
|
||||
wall_ns: 0,
|
||||
mono_ns: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duration_beyond_the_wire_field_saturates_rather_than_panicking() {
|
||||
assert_eq!(saturating_ns(Duration::from_nanos(1_500)), 1_500);
|
||||
assert_eq!(saturating_ns(Duration::MAX), i64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_observed_instant_converts_or_reports_that_it_cannot() {
|
||||
assert_eq!(
|
||||
wall_ns_of(UNIX_EPOCH + Duration::from_nanos(1_500)),
|
||||
Some(1_500)
|
||||
);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH), Some(0));
|
||||
|
||||
// Before the epoch is negative, not a wrap and not a rejection.
|
||||
assert_eq!(
|
||||
wall_ns_of(UNIX_EPOCH - Duration::from_nanos(1_500)),
|
||||
Some(-1_500)
|
||||
);
|
||||
|
||||
// Beyond the field, in either direction, is `None` rather than a
|
||||
// clamped value that would then read as an ordinary instant.
|
||||
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH + beyond), None);
|
||||
assert_eq!(wall_ns_of(UNIX_EPOCH - beyond), None);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use irtt_proto::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
clock::{saturating_ns, wall_ns_of, ClockSample, ClockSource, SystemClock},
|
||||
clock::{saturating_ns, wall_ns_of, ClockSample, SystemClock},
|
||||
config::ServerConfig,
|
||||
error::ServerError,
|
||||
fill::{echo_payload_len, negotiate_server_fill},
|
||||
@@ -27,8 +27,7 @@ use crate::{
|
||||
/// the bytes of one received datagram, and gets back the bytes to send to that
|
||||
/// endpoint, or nothing. Everything it does is a pure function of its state and
|
||||
/// that input, except for drawing session tokens and sampling the clock an echo
|
||||
/// reply's timestamps come from — both of which are private injected seams, so
|
||||
/// the whole engine stays deterministically testable.
|
||||
/// reply's timestamps come from — the clock is sampled directly and tokens have a private fault-injection seam.
|
||||
///
|
||||
/// The Tokio runtime wraps it directly:
|
||||
///
|
||||
@@ -88,7 +87,7 @@ pub struct ServerCore {
|
||||
config: ServerConfig,
|
||||
sessions: HashMap<u64, Session>,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
clock: Box<dyn ClockSource>,
|
||||
clock: SystemClock,
|
||||
}
|
||||
|
||||
impl ServerCore {
|
||||
@@ -99,24 +98,13 @@ impl ServerCore {
|
||||
Self::with_token_source(config, Box::new(OsTokenSource))
|
||||
}
|
||||
|
||||
/// A core with a chosen token source and the production clock, which is
|
||||
/// what tests that care about session identity but not about timestamp
|
||||
/// values want.
|
||||
// Token fault injection covers random-source failures and allocation bounds.
|
||||
pub(crate) fn with_token_source(config: ServerConfig, tokens: Box<dyn TokenSource>) -> Self {
|
||||
Self::with_sources(config, tokens, Box::new(SystemClock::new()))
|
||||
}
|
||||
|
||||
/// A core with both nondeterministic sources chosen.
|
||||
pub(crate) fn with_sources(
|
||||
config: ServerConfig,
|
||||
tokens: Box<dyn TokenSource>,
|
||||
clock: Box<dyn ClockSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
sessions: HashMap::new(),
|
||||
tokens,
|
||||
clock,
|
||||
clock: SystemClock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ impl FillMode {
|
||||
// region as unspecified residual buffer content, which returned
|
||||
// other traffic's bytes, and a compatible server must not.
|
||||
Self::None => Vec::new(),
|
||||
Self::Random => random_payload(len, getrandom::fill),
|
||||
Self::Random => random_payload(len),
|
||||
Self::Pattern(pattern) => pattern.iter().copied().cycle().take(len).collect(),
|
||||
}
|
||||
}
|
||||
@@ -132,25 +132,17 @@ fn hex_digit(digit: u8) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `len` random bytes drawn with `draw`, or `len` zeroes if the draw failed.
|
||||
/// `len` random bytes from the operating system, or zeroes if the draw failed.
|
||||
///
|
||||
/// `draw` is a parameter rather than a direct call so the failure path is
|
||||
/// assertable without a production hook: the caller passes the operating
|
||||
/// system's random source and one unit test passes a scripted one. An empty
|
||||
/// region asks the source for nothing at all.
|
||||
///
|
||||
/// **A failed draw zero-fills and is not an error.** Payload bytes carry no
|
||||
/// protocol meaning, so a random source having a bad afternoon must not cost a
|
||||
/// session its reply, let alone take the server down: the reply is still
|
||||
/// structurally valid and interoperable. The buffer is rewritten rather than
|
||||
/// trusted, because a failed draw leaves its contents unspecified.
|
||||
/// Failure is not fatal: payload variation has no protocol meaning, so a
|
||||
/// random-source failure must not terminate a listener or drop a session.
|
||||
///
|
||||
/// The bytes exist to vary the payload, and nothing here is a security claim.
|
||||
/// `getrandom` is used because the crate already depends on it; session tokens,
|
||||
/// which *are* security state, keep their own separate source.
|
||||
fn random_payload<E>(len: usize, draw: impl FnOnce(&mut [u8]) -> Result<(), E>) -> Vec<u8> {
|
||||
fn random_payload(len: usize) -> Vec<u8> {
|
||||
let mut payload = vec![0; len];
|
||||
if len > 0 && draw(&mut payload).is_err() {
|
||||
if len > 0 && getrandom::fill(&mut payload).is_err() {
|
||||
payload.fill(0);
|
||||
}
|
||||
payload
|
||||
@@ -223,111 +215,3 @@ pub(crate) fn echo_payload_len(hmac: bool, params: &Params) -> usize {
|
||||
let header_len = PacketLayout::echo(hmac, params).header_len();
|
||||
echo_packet_len(hmac, params).map_or(0, |len| len.saturating_sub(header_len))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognized_descriptors_parse_to_their_mode() {
|
||||
for (descriptor, expected) in [
|
||||
("none", FillMode::None),
|
||||
("rand", FillMode::Random),
|
||||
("pattern:00", FillMode::Pattern(vec![0x00])),
|
||||
("pattern:ff00", FillMode::Pattern(vec![0xff, 0x00])),
|
||||
// Only the hexadecimal body is case-insensitive.
|
||||
("pattern:AaBb", FillMode::Pattern(vec![0xaa, 0xbb])),
|
||||
(
|
||||
"pattern:69727474",
|
||||
FillMode::Pattern(DEFAULT_FILL_PATTERN.to_vec()),
|
||||
),
|
||||
] {
|
||||
assert_eq!(FillMode::parse(descriptor), Some(expected), "{descriptor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecognized_and_malformed_descriptors_parse_to_nothing() {
|
||||
for descriptor in [
|
||||
"",
|
||||
"bogus",
|
||||
// The mode names themselves are case-sensitive.
|
||||
"RAND",
|
||||
"None",
|
||||
"Pattern:aabb",
|
||||
"rand ",
|
||||
"pattern:",
|
||||
"pattern:f",
|
||||
"pattern:abc",
|
||||
"pattern:zz",
|
||||
"pattern:0g",
|
||||
// A multi-byte character is not two hexadecimal digits, whatever
|
||||
// its byte length.
|
||||
"pattern:é",
|
||||
] {
|
||||
assert_eq!(FillMode::parse(descriptor), None, "{descriptor:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_descriptor_and_the_default_pattern_agree() {
|
||||
// The two constants are held separately so nothing has to parse a
|
||||
// string per open; this is what keeps them from drifting.
|
||||
assert_eq!(
|
||||
FillMode::parse(DEFAULT_FILL_DESCRIPTOR),
|
||||
Some(FillMode::default_fill())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_repeats_from_its_first_byte_and_stops_at_the_region() {
|
||||
let mode = FillMode::Pattern(vec![0xaa, 0xbb]);
|
||||
assert_eq!(mode.payload(0), Vec::<u8>::new());
|
||||
assert_eq!(mode.payload(1), vec![0xaa]);
|
||||
assert_eq!(
|
||||
mode.payload(7),
|
||||
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa, 0xbb, 0xaa]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fill_leaves_the_region_to_the_encoder() {
|
||||
// Not a length-sized run of zeroes: the encoder has already zeroed the
|
||||
// region, and copying zeroes over zeroes would only cost an allocation.
|
||||
assert!(FillMode::None.payload(16).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_random_draw_yields_zeroes() {
|
||||
// The real source cannot be made to fail without a production hook, and
|
||||
// the fallback itself is the policy under test: a failed draw must
|
||||
// produce a full-length payload of zeroes rather than an error or the
|
||||
// buffer's unspecified contents.
|
||||
let scribble = |buffer: &mut [u8]| {
|
||||
buffer.fill(0xab);
|
||||
Err(())
|
||||
};
|
||||
assert_eq!(random_payload(6, scribble), vec![0; 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_random_draw_is_propagated_whole() {
|
||||
// Deterministic on purpose. Nothing here asserts that real random bytes
|
||||
// are nonzero, distinct or well distributed; those are properties of
|
||||
// the operating system's source, and testing them would be a coin flip
|
||||
// dressed up as an assertion.
|
||||
let drawn = [1, 2, 3, 4, 5];
|
||||
let source = |buffer: &mut [u8]| -> Result<(), ()> {
|
||||
assert_eq!(buffer.len(), drawn.len(), "the whole region is offered");
|
||||
buffer.copy_from_slice(&drawn);
|
||||
Ok(())
|
||||
};
|
||||
assert_eq!(random_payload(drawn.len(), source), drawn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_region_never_reaches_the_random_source() {
|
||||
let source = |_: &mut [u8]| -> Result<(), ()> { panic!("no bytes were needed") };
|
||||
assert!(random_payload(0, source).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
//! admission, authentication policy, open negotiation, the session table, echo
|
||||
//! receive state, rate and lifetime policy, resource decisions and reply
|
||||
//! construction. It performs no I/O; the clock its timestamps and deadlines
|
||||
//! come from is a private injected seam, not a runtime abstraction, so the
|
||||
//! engine stays testable. Each reply it produces is an [`OutboundDatagram`]:
|
||||
//! come from is sampled by the core. Each reply is an [`OutboundDatagram`]:
|
||||
//! the packet and the raw traffic class it must be sent with. [`Server`] owns
|
||||
//! one Tokio UDP listener and one core, and provides sequential receive, reply,
|
||||
//! scheduled expiry maintenance and caller-controlled shutdown. [`ServerSet`]
|
||||
|
||||
@@ -403,203 +403,7 @@ pub enum ServerRuntimeError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{net::Ipv4Addr, time::Duration};
|
||||
|
||||
use irtt_proto::{
|
||||
decode_echo_reply, decode_open_reply, encode_request, Clock, Params, ReceivedStats,
|
||||
RequestToEncode, StampAt,
|
||||
};
|
||||
use tokio::{net::UdpSocket, sync::oneshot, time::timeout};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn bind_reports_local_address_and_shutdown_stops_run() {
|
||||
let requested = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
|
||||
let mut server = Server::bind(requested, ServerConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let bound = server.local_addr().unwrap();
|
||||
assert_eq!(bound.ip(), requested.ip());
|
||||
assert_ne!(bound.port(), 0);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Construction is where a wildcard listener's promise is made or refused.
|
||||
///
|
||||
/// Both outcomes are asserted from the same code, because which one a target
|
||||
/// gets is exactly the thing under test: a target with a source-selection
|
||||
/// path configures it and serves; one without refuses the bind rather than
|
||||
/// starting a listener whose replies may come from an address no client
|
||||
/// contacted. An explicit bind is unaffected either way.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn from_socket_settles_wildcard_source_selection_before_serving_anything() {
|
||||
let explicit = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
assert!(Server::from_socket(explicit, ServerConfig::default()).is_ok());
|
||||
|
||||
let wildcard = UdpSocket::bind("0.0.0.0:0").await.unwrap();
|
||||
let wildcard = Server::from_socket(wildcard, ServerConfig::default());
|
||||
if socket_io::SUPPORTED {
|
||||
let server = wildcard.expect("a supported target configures source selection");
|
||||
assert!(server.select_reply_source);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
wildcard,
|
||||
Err(ServerRuntimeError::WildcardSourceSelectionUnsupported { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv4_open_echo_close_crosses_a_real_udp_socket() {
|
||||
exercise_udp_path(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ipv6_open_echo_close_when_loopback_is_available() {
|
||||
let addr = "[::1]:0".parse().unwrap();
|
||||
let server = match Server::bind(addr, unthrottled()).await {
|
||||
Ok(server) => server,
|
||||
Err(ServerRuntimeError::Bind { source, .. })
|
||||
if matches!(
|
||||
source.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
) =>
|
||||
{
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("unexpected IPv6 loopback bind failure: {error}"),
|
||||
};
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_udp_path(addr: SocketAddr) {
|
||||
let server = Server::bind(addr, unthrottled()).await.unwrap();
|
||||
exercise_bound_server(server).await;
|
||||
}
|
||||
|
||||
async fn exercise_bound_server(mut server: Server) {
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
let client_bind = match server_addr {
|
||||
SocketAddr::V4(_) => "127.0.0.1:0",
|
||||
SocketAddr::V6(_) => "[::1]:0",
|
||||
};
|
||||
let client = UdpSocket::bind(client_bind).await.unwrap();
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let server_task = tokio::spawn(async move {
|
||||
server
|
||||
.run(async {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
let requested = Params {
|
||||
protocol_version: 1,
|
||||
duration_ns: 1_000_000_000,
|
||||
interval_ns: 100_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let open = encode_request(
|
||||
RequestToEncode::Open {
|
||||
params: &requested,
|
||||
no_test: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&open, server_addr).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; RECEIVE_BUFFER_LEN];
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("OPEN reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let open_reply = decode_open_reply(&buffer[..len], None).unwrap();
|
||||
assert_ne!(open_reply.token, 0);
|
||||
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token: open_reply.token,
|
||||
sequence: 0,
|
||||
params: &open_reply.params,
|
||||
payload: &[],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
let (len, reply_source) = timeout(Duration::from_secs(1), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.expect("ECHO reply timeout")
|
||||
.unwrap();
|
||||
assert_eq!(reply_source, server_addr);
|
||||
let echo_reply = decode_echo_reply(&buffer[..len], &open_reply.params, None).unwrap();
|
||||
assert_eq!(echo_reply.token, open_reply.token);
|
||||
assert_eq!(echo_reply.sequence, 0);
|
||||
assert_eq!(echo_reply.recv_count, Some(1));
|
||||
assert_eq!(echo_reply.recv_window, Some(1));
|
||||
|
||||
let close = encode_request(
|
||||
RequestToEncode::Close {
|
||||
token: open_reply.token,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
client.send_to(&close, server_addr).await.unwrap();
|
||||
client.send_to(&echo, server_addr).await.unwrap();
|
||||
assert!(
|
||||
timeout(Duration::from_millis(100), client.recv_from(&mut buffer))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
shutdown_tx.send(()).unwrap();
|
||||
server_task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
fn unthrottled() -> ServerConfig {
|
||||
ServerConfig::default().with_min_send_interval(Duration::ZERO)
|
||||
}
|
||||
|
||||
/// Which bound addresses owe their replies a selected source.
|
||||
///
|
||||
/// The IPv4-mapped unspecified address is the one that is not obvious, and
|
||||
/// it is not hypothetical: Linux accepts `[::ffff:0.0.0.0]` as an IPv4
|
||||
/// wildcard bind and reports it back unchanged, so missing it would leave a
|
||||
/// working wildcard listener on the plain send path. A mapped address
|
||||
/// naming a real host address is explicit and must stay there.
|
||||
#[test]
|
||||
fn a_bound_address_naming_no_particular_local_address_is_a_wildcard() {
|
||||
for (addr, wildcard) in [
|
||||
("0.0.0.0:2112", true),
|
||||
("[::]:2112", true),
|
||||
("[::ffff:0.0.0.0]:2112", true),
|
||||
("127.0.0.1:2112", false),
|
||||
("192.0.2.10:2112", false),
|
||||
("[::1]:2112", false),
|
||||
("[::ffff:127.0.0.1]:2112", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
is_wildcard(addr.parse().unwrap()),
|
||||
wildcard,
|
||||
"{addr} classified wrongly"
|
||||
);
|
||||
}
|
||||
}
|
||||
use super::may_send_unappliable;
|
||||
|
||||
/// The rule that decides what an unappliable traffic class means, which no
|
||||
/// normal interface can reach: it needs a host that refuses the socket
|
||||
|
||||
@@ -393,91 +393,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{token::TokenSource, ServerCore, ServerError};
|
||||
|
||||
/// Which listener of a requested set has to be bound IPv6-only.
|
||||
///
|
||||
/// The interesting rows are the mapped ones. `[::ffff:0.0.0.0]` is an IPv4
|
||||
/// wildcard bind wearing an IPv6 address, so it is neither a candidate for
|
||||
/// `IPV6_V6ONLY` — which would refuse the traffic it exists to serve — nor
|
||||
/// the IPv6 half of a same-port pair. A genuine IPv6 address paired with it
|
||||
/// still is.
|
||||
#[test]
|
||||
fn only_a_genuine_ipv6_listener_sharing_an_explicit_port_is_bound_ipv6_only() {
|
||||
for (addr, requested, ipv6_only, why) in [
|
||||
(
|
||||
"[::]:2112",
|
||||
vec!["0.0.0.0:2112", "[::]:2112"],
|
||||
true,
|
||||
"the wildcard pair is the reason this exists",
|
||||
),
|
||||
(
|
||||
"[::1]:2112",
|
||||
vec!["127.0.0.1:2112", "[::1]:2112"],
|
||||
true,
|
||||
"and so is the explicit loopback pair",
|
||||
),
|
||||
(
|
||||
"0.0.0.0:2112",
|
||||
vec!["0.0.0.0:2112", "[::]:2112"],
|
||||
false,
|
||||
"the IPv4 half is bound exactly as it always was",
|
||||
),
|
||||
(
|
||||
"[::]:2112",
|
||||
vec!["[::]:2112"],
|
||||
false,
|
||||
"an IPv6 listener alone keeps ordinary bind semantics",
|
||||
),
|
||||
(
|
||||
"[::]:2112",
|
||||
vec!["0.0.0.0:2113", "[::]:2112"],
|
||||
false,
|
||||
"different ports cannot collide",
|
||||
),
|
||||
(
|
||||
"[::]:0",
|
||||
vec!["0.0.0.0:0", "[::]:0"],
|
||||
false,
|
||||
"two ephemeral requests resolve independently",
|
||||
),
|
||||
(
|
||||
"[::ffff:0.0.0.0]:2112",
|
||||
vec!["0.0.0.0:2112", "[::ffff:0.0.0.0]:2112"],
|
||||
false,
|
||||
"a mapped wildcard is an IPv4 listener, not an IPv6 one",
|
||||
),
|
||||
(
|
||||
"[::]:2112",
|
||||
vec!["[::ffff:0.0.0.0]:2112", "[::]:2112"],
|
||||
true,
|
||||
"and it is the IPv4 half a genuine IPv6 listener must avoid",
|
||||
),
|
||||
(
|
||||
"[::ffff:127.0.0.1]:2112",
|
||||
vec!["[::ffff:127.0.0.1]:2112", "[::1]:2112"],
|
||||
false,
|
||||
"a mapped host address is explicit IPv4 too",
|
||||
),
|
||||
] {
|
||||
let requested: Vec<SocketAddr> = requested
|
||||
.iter()
|
||||
.map(|addr| addr.parse().expect("a test address must parse"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
needs_ipv6_only(addr.parse().unwrap(), &requested),
|
||||
ipv6_only,
|
||||
"{addr} among {requested:?}: {why}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_set_serving_nothing_is_refused() {
|
||||
assert!(matches!(
|
||||
ServerSet::bind([], ServerConfig::default()).await,
|
||||
Err(ServerSetError::NoListeners)
|
||||
));
|
||||
}
|
||||
|
||||
/// A token source that draws one value successfully and then fails every
|
||||
/// later draw.
|
||||
///
|
||||
|
||||
@@ -256,189 +256,3 @@ fn unsupported() -> io::Error {
|
||||
"reply source-address selection is unsupported on this target",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Sends `payload` to a fresh explicit-address listener and returns what the
|
||||
/// transport layer decided about it, received into a `capacity`-byte buffer.
|
||||
///
|
||||
/// The buffer is deliberately tiny: the full-buffer rule is written against
|
||||
/// the caller's capacity, so it is provable without a 65,536-byte datagram.
|
||||
async fn receive_one(
|
||||
capacity: usize,
|
||||
payload: &[u8],
|
||||
) -> (io::Result<Option<ReceivedDatagram>>, Vec<u8>) {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
sender
|
||||
.send_to(payload, listener.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut buffer = vec![0; capacity];
|
||||
let received = receive(&listener, &mut buffer, false).await;
|
||||
(received, buffer)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_shorter_than_the_buffer_reaches_the_core_intact() {
|
||||
let payload = b"irtt";
|
||||
let (received, buffer) = receive_one(16, payload).await;
|
||||
|
||||
let received = received
|
||||
.unwrap()
|
||||
.expect("a datagram that cannot have been truncated is accepted");
|
||||
assert_eq!(received.len, payload.len());
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
assert_eq!(&buffer[..received.len], payload);
|
||||
}
|
||||
|
||||
/// An exactly-full receive is ambiguous, not obviously fine: `recv_from`
|
||||
/// reports no truncation flag, so this datagram is indistinguishable from a
|
||||
/// larger one cut down to the same capacity. Both are dropped.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_filling_the_buffer_is_dropped_as_possibly_truncated() {
|
||||
let (received, _) = receive_one(4, b"irtt").await;
|
||||
assert!(received.unwrap().is_none());
|
||||
}
|
||||
|
||||
/// What matters is that an oversized datagram never reaches the core, not
|
||||
/// how the platform says so: Unix truncates it to the buffer, which the
|
||||
/// full-buffer rule then drops, while Windows fails the receive outright
|
||||
/// with `WSAEMSGSIZE`. Asserting the shared guarantee keeps this test true
|
||||
/// on both.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_larger_than_the_buffer_never_reaches_the_core() {
|
||||
let (received, _) = receive_one(4, b"irtt-rs").await;
|
||||
assert!(!matches!(received, Ok(Some(_))));
|
||||
}
|
||||
|
||||
/// A listener that never asked for kernel timestamps receives normally and
|
||||
/// reports none — the field is optional in the plainest sense, and its
|
||||
/// absence is not a defect to be worked around later.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_datagram_with_no_kernel_timestamp_is_served_all_the_same() {
|
||||
let (received, _) = receive_one(16, b"irtt").await;
|
||||
let received = received.unwrap().expect("an untimestamped datagram serves");
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
assert_eq!(received.kernel_rx_timestamp, None);
|
||||
}
|
||||
|
||||
/// Linux-only coverage of the two receive paths, which is where the
|
||||
/// timestamp actually comes from.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Sends `payload` from loopback to `listener` and receives it once.
|
||||
async fn exchange(
|
||||
listener: &UdpSocket,
|
||||
select_reply_source: bool,
|
||||
) -> (Option<ReceivedDatagram>, Vec<u8>) {
|
||||
let payload = b"irtt";
|
||||
let destination =
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, listener.local_addr().unwrap().port()));
|
||||
let sender = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
sender.send_to(payload, destination).await.unwrap();
|
||||
|
||||
let mut buffer = vec![0; 64];
|
||||
let received = receive(listener, &mut buffer, select_reply_source)
|
||||
.await
|
||||
.unwrap();
|
||||
(received, buffer)
|
||||
}
|
||||
|
||||
/// Reports the timestamp, or says why it is skipping the assertion
|
||||
/// rather than inventing one. Loopback under a normal kernel supplies
|
||||
/// it; a container or emulator that does not must not fail the run.
|
||||
fn timestamp_or_skip(received: &ReceivedDatagram, what: &str) {
|
||||
match received.kernel_rx_timestamp {
|
||||
Some(timestamp) => assert!(
|
||||
timestamp.duration_since(std::time::UNIX_EPOCH).is_ok(),
|
||||
"a kernel receive timestamp is a wall-clock instant"
|
||||
),
|
||||
None => eprintln!(
|
||||
"skipping {what} kernel timestamp assertion: \
|
||||
kernel did not provide SCM_TIMESTAMPNS"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit-address listener's new `recvmsg` path: it must keep
|
||||
/// reporting exactly what `recv_from` did, and additionally see the
|
||||
/// timestamp `recv_from` had no way to retrieve.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn an_explicit_listener_receives_a_datagram_and_its_kernel_timestamp() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
|
||||
assert!(
|
||||
try_configure_kernel_rx_timestamp(&listener),
|
||||
"Linux loopback accepts SO_TIMESTAMPNS"
|
||||
);
|
||||
|
||||
let (received, buffer) = exchange(&listener, false).await;
|
||||
let received = received.expect("an explicit listener serves a short datagram");
|
||||
|
||||
assert_eq!(received.len, 4);
|
||||
assert_eq!(&buffer[..received.len], b"irtt");
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(received.reply_source, ReplySource::Bound);
|
||||
timestamp_or_skip(&received, "explicit");
|
||||
}
|
||||
|
||||
/// The regression this whole change risks: asking for a second control
|
||||
/// message must not crowd out the first. A wildcard listener with both
|
||||
/// enabled has to recover its destination *and* its timestamp from one
|
||||
/// datagram, with no `MSG_CTRUNC` drop and no dependence on the order
|
||||
/// the kernel returns them in.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_wildcard_listener_recovers_its_destination_and_timestamp_together() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
|
||||
configure_destination_metadata(&listener, true).unwrap();
|
||||
assert!(try_configure_kernel_rx_timestamp(&listener));
|
||||
|
||||
let (received, buffer) = exchange(&listener, true).await;
|
||||
let received = received.expect(
|
||||
"a wildcard listener asking for two control messages still serves the datagram",
|
||||
);
|
||||
|
||||
assert_eq!(received.len, 4);
|
||||
assert_eq!(&buffer[..received.len], b"irtt");
|
||||
assert_eq!(received.peer.ip(), Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(
|
||||
received.reply_source,
|
||||
ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST
|
||||
},
|
||||
"the required destination survives the added timestamp request"
|
||||
);
|
||||
timestamp_or_skip(&received, "wildcard");
|
||||
}
|
||||
|
||||
/// The timestamp is not a precondition for either listener: a wildcard
|
||||
/// one that never enabled it still recovers the destination its reply
|
||||
/// depends on and serves the request.
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn a_wildcard_listener_without_timestamps_still_recovers_its_destination() {
|
||||
let listener = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await.unwrap();
|
||||
configure_destination_metadata(&listener, true).unwrap();
|
||||
|
||||
let (received, _) = exchange(&listener, true).await;
|
||||
let received = received.expect("no timestamp requested, and none needed");
|
||||
|
||||
assert_eq!(
|
||||
received.reply_source,
|
||||
ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST
|
||||
}
|
||||
);
|
||||
assert_eq!(received.kernel_rx_timestamp, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,150 +545,3 @@ impl ControlBuffer {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Which reply sources need their interface named, which no normal
|
||||
/// interface can show: it takes a host whose route back to a peer leaves by
|
||||
/// a different interface than the request arrived on, and a test host has
|
||||
/// one loopback.
|
||||
#[test]
|
||||
fn only_a_link_local_source_pins_the_egress_interface() {
|
||||
const ARRIVED_ON: u32 = 7;
|
||||
|
||||
for (address, expected, why) in [
|
||||
(
|
||||
"fe80::1",
|
||||
ARRIVED_ON,
|
||||
"a link-local source needs its link named",
|
||||
),
|
||||
(
|
||||
"fe80::7858:baff:fe91:183d",
|
||||
ARRIVED_ON,
|
||||
"and so does any other one",
|
||||
),
|
||||
("2001:db8::1", 0, "a global source identifies itself"),
|
||||
("fd00::1", 0, "so does a unique-local one"),
|
||||
("::1", 0, "and loopback"),
|
||||
("::ffff:127.0.0.2", 0, "and an IPv4-mapped destination"),
|
||||
] {
|
||||
assert_eq!(
|
||||
egress_interface_index(address.parse().unwrap(), ARRIVED_ON),
|
||||
expected,
|
||||
"{address}: {why}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the two listener kinds do with the metadata they did or did not
|
||||
/// get, which is the whole distinction this module rests on: a destination
|
||||
/// is correctness-critical for one of them and irrelevant to the other.
|
||||
#[test]
|
||||
fn only_a_listener_that_needs_a_destination_is_stopped_by_a_missing_one() {
|
||||
let recovered = ReplySource::V4 {
|
||||
address: Ipv4Addr::LOCALHOST,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
resolved_reply_source(Some(recovered), true),
|
||||
Some(recovered),
|
||||
"a wildcard listener replies from the destination it recovered"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(None, true),
|
||||
None,
|
||||
"and cannot answer a request whose destination did not arrive"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(None, false),
|
||||
Some(ReplySource::Bound),
|
||||
"an explicit listener never needed one"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved_reply_source(Some(recovered), false),
|
||||
Some(ReplySource::Bound),
|
||||
"and sends from its bind even if one turned up"
|
||||
);
|
||||
}
|
||||
|
||||
/// Which truncation is fatal to whom. A cut payload always is; cut control
|
||||
/// data is fatal only where it may have taken the destination with it.
|
||||
/// Turning a lost *timestamp* into a lost packet would trade a real client
|
||||
/// request for optional precision.
|
||||
#[test]
|
||||
fn control_truncation_only_condemns_a_datagram_whose_destination_it_may_have_taken() {
|
||||
for (flags, require_destination, unusable, why) in [
|
||||
(MsgFlags::empty(), true, false, "a clean wildcard receive"),
|
||||
(MsgFlags::empty(), false, false, "a clean explicit receive"),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC,
|
||||
false,
|
||||
true,
|
||||
"a cut payload is not what the client sent",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC,
|
||||
true,
|
||||
true,
|
||||
"and no less so for a wildcard listener",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_CTRUNC,
|
||||
true,
|
||||
true,
|
||||
"cut control data may have taken the destination",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_CTRUNC,
|
||||
false,
|
||||
false,
|
||||
"but for an explicit listener it can only have cost a timestamp",
|
||||
),
|
||||
(
|
||||
MsgFlags::MSG_TRUNC | MsgFlags::MSG_CTRUNC,
|
||||
false,
|
||||
true,
|
||||
"the payload rule still applies",
|
||||
),
|
||||
] {
|
||||
assert_eq!(is_unusable(flags, require_destination), unusable, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A representable kernel reading becomes an instant; anything else becomes
|
||||
/// absent metadata. Note what is *not* rejected here: a reading far from
|
||||
/// now is structurally fine, and judging it belongs to whoever measures
|
||||
/// with it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg_attr(target_env = "musl", allow(deprecated))]
|
||||
#[test]
|
||||
fn a_kernel_timestamp_converts_only_where_it_describes_an_instant() {
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(1, 2))
|
||||
.unwrap()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap(),
|
||||
Duration::new(1, 2)
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(0, 0)),
|
||||
Some(UNIX_EPOCH),
|
||||
"the epoch itself is representable"
|
||||
);
|
||||
|
||||
assert_eq!(system_time_from_timespec(TimeSpec::new(-1, 0)), None);
|
||||
assert_eq!(system_time_from_timespec(TimeSpec::new(0, -1)), None);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(0, 1_000_000_000)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
system_time_from_timespec(TimeSpec::new(libc::time_t::MIN, libc::c_long::MIN)),
|
||||
None,
|
||||
"and the extremes convert rather than panic"
|
||||
);
|
||||
let _ = system_time_from_timespec(TimeSpec::new(libc::time_t::MAX, 999_999_999));
|
||||
}
|
||||
}
|
||||
@@ -149,36 +149,3 @@ fn unsupported_traffic_class(_traffic_class: u32, option: &'static str) -> io::R
|
||||
format!("{option} are unsupported on this target"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Which listeners mark with the IPv4 option.
|
||||
///
|
||||
/// The mapped forms are the point: such a socket is `AF_INET6` but sends
|
||||
/// IPv4, and `IPV6_TCLASS` does not reach those packets. Linux marks them
|
||||
/// through `IP_TOS`; the platforms that would reject that keep the IPv6
|
||||
/// option, because an unappliable marking stops the send and a dropped
|
||||
/// reply is worse than an unmarked one.
|
||||
#[test]
|
||||
fn a_mapped_listener_marks_with_the_ipv4_option_where_that_option_works() {
|
||||
let mapped = cfg!(target_os = "linux");
|
||||
|
||||
for (addr, is_ipv4) in [
|
||||
("0.0.0.0:2112", true),
|
||||
("127.0.0.1:2112", true),
|
||||
("[::ffff:0.0.0.0]:2112", mapped),
|
||||
("[::ffff:127.0.0.1]:2112", mapped),
|
||||
("[::]:2112", false),
|
||||
("[::1]:2112", false),
|
||||
("[2001:db8::1]:2112", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
marks_with_ipv4_option(addr.parse().unwrap()),
|
||||
is_ipv4,
|
||||
"{addr} would mark with the wrong option"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! The structural admission boundary.
|
||||
//!
|
||||
//! A datagram that never decodes as a request is discarded before any request
|
||||
//! kind is chosen, so it can reach neither the session table nor an echo's
|
||||
//! receive state.
|
||||
|
||||
use super::support::{client_params, core_for, open_request, open_session, peer, ScriptedTokens};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
#[test]
|
||||
fn structurally_invalid_datagrams_are_discarded() {
|
||||
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
|
||||
open_session(&mut core, peer(), None);
|
||||
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
let mut bad_magic = open_request(&client_params(), None);
|
||||
bad_magic[0] ^= 0x01;
|
||||
|
||||
let mut reserved_flag = open_request(&client_params(), None);
|
||||
reserved_flag[3] |= 0x10;
|
||||
|
||||
let mut reply_flag = open_request(&client_params(), None);
|
||||
reply_flag[3] |= irtt_proto::FLAG_REPLY;
|
||||
|
||||
for (name, packet) in [
|
||||
("empty datagram", Vec::new()),
|
||||
("datagram shorter than the header", vec![0x14, 0xa7, 0x5b]),
|
||||
("bad magic", bad_magic),
|
||||
("reserved flag bit", reserved_flag),
|
||||
("reply flag", reply_flag),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be discarded"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||
}
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user