Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -322,170 +322,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,479 @@
|
||||
#[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;
|
||||
let args = ClientArgs::try_parse_from([
|
||||
"irtt-client",
|
||||
"--hmac",
|
||||
"global-test-key",
|
||||
"inherit=127.0.0.1:2112",
|
||||
"override=127.0.0.1:2113@hmac=target-test-key",
|
||||
"disable=127.0.0.1:2114@hmac=",
|
||||
])
|
||||
.unwrap();
|
||||
let setup = args.prepare().unwrap();
|
||||
assert_eq!(
|
||||
setup.managed_config().client.hmac_key.as_deref(),
|
||||
Some(b"global-test-key".as_slice())
|
||||
);
|
||||
let targets = setup.managed_targets();
|
||||
let authentication: Vec<_> = targets
|
||||
.iter()
|
||||
.map(|target| target.auth.as_ref().map(|auth| auth.hmac_key.as_deref()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
authentication,
|
||||
[None, Some(Some(b"target-test-key".as_slice())), Some(None)]
|
||||
);
|
||||
}
|
||||
|
||||
// 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,6 +22,13 @@ 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.
|
||||
|
||||
|
||||
@@ -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
@@ -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,51 +148,74 @@ 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.
|
||||
@@ -294,37 +228,6 @@ impl Client {
|
||||
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 +243,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 +277,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!(
|
||||
@@ -450,17 +350,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 +375,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 +398,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 +441,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 +458,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 +487,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 +500,6 @@ impl Client {
|
||||
) -> Result<OpenOutcome, ClientError> {
|
||||
let PreparedClientOpen {
|
||||
machine,
|
||||
schedule,
|
||||
recv_buffer_len,
|
||||
negotiated_traffic_class,
|
||||
previous_traffic_class,
|
||||
@@ -755,17 +520,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 +537,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 +571,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 +580,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 +613,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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,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.
|
||||
///
|
||||
@@ -231,6 +237,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 +279,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
|
||||
|
||||
@@ -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;
|
||||
@@ -449,199 +449,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,154 @@
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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,
|
||||
@@ -1243,7 +1138,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 +1253,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 +1281,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 +1296,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 +1312,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 +1390,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 +1471,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 +1478,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 +1507,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 +1543,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 +1561,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 +1572,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 +1583,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 +1605,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 +1632,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 +1678,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 +1693,7 @@ impl ManagedClientTask {
|
||||
SendResult::Ready { accepted }
|
||||
}
|
||||
}
|
||||
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.targets[index].send_waiting = false;
|
||||
self.begin_open_session_failure(
|
||||
@@ -1863,10 +1788,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 +1835,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 +1845,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 +1927,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 {
|
||||
@@ -2032,24 +1940,21 @@ impl ManagedClientTask {
|
||||
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();
|
||||
}
|
||||
@@ -2349,6 +2254,7 @@ fn build_task(
|
||||
}
|
||||
})?;
|
||||
runtimes.push(TargetRuntime {
|
||||
schedule: None,
|
||||
instance: TargetInstance {
|
||||
id: target.id.clone(),
|
||||
generation,
|
||||
@@ -2416,6 +2322,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 +2346,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 +2353,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 +2395,39 @@ fn close_timeout_failure() -> ManagedTargetFailure {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
+59
-298
@@ -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,35 +238,6 @@ 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)]
|
||||
@@ -313,31 +288,11 @@ impl TimedOutMap {
|
||||
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()
|
||||
}
|
||||
|
||||
#[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() {
|
||||
@@ -420,263 +375,69 @@ 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));
|
||||
}
|
||||
|
||||
// Public loss/reply events cannot reveal leaked retention bookkeeping.
|
||||
#[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))),
|
||||
);
|
||||
}
|
||||
|
||||
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_eq!(map.linkage(), (Some(4), Some(4)));
|
||||
assert!(map.remove(4).is_some());
|
||||
assert_eq!(map.linkage(), (None, None));
|
||||
}
|
||||
|
||||
#[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_stays_bounded_after_repeated_removals() {
|
||||
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.map.len() <= 4);
|
||||
assert!(map.insertion_order.len() <= 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+69
-336
@@ -2,8 +2,7 @@ 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();
|
||||
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);
|
||||
@@ -17,191 +16,6 @@ fn open_fails_after_close() {
|
||||
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)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_probe_fails_after_close() {
|
||||
let params = default_params();
|
||||
@@ -232,135 +46,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 +92,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 +122,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 +180,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 +275,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 +339,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 +397,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 +422,66 @@ 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);
|
||||
|
||||
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,76 @@
|
||||
use super::*;
|
||||
|
||||
#[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 = 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.hmac_key = Some(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.hmac_key = Some(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;
|
||||
+7
-51
@@ -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();
|
||||
@@ -148,7 +104,7 @@ fn send_probe_fails_after_no_test_completed() {
|
||||
fn open_fails_after_no_test_completed() {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.run_mode = RunMode::NoTest;
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let params = default_params();
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
@@ -0,0 +1,254 @@
|
||||
use super::*;
|
||||
|
||||
// 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,678 @@
|
||||
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,
|
||||
hmac_key: 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();
|
||||
@@ -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,506 @@
|
||||
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::{managed::*, ClientConfig};
|
||||
use irtt_client::{ClientAuthConfig, RunMode};
|
||||
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 = Some(ClientAuthConfig {
|
||||
hmac_key: Some(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 = Some(ClientAuthConfig {
|
||||
hmac_key: Some(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)
|
||||
));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
//! Client-initiated close.
|
||||
//!
|
||||
//! A close request that matches a live token *and* arrived from that session's
|
||||
//! exact endpoint releases the session and is answered with nothing. Every
|
||||
//! other close — unknown, stale or zero token, foreign endpoint, failed
|
||||
//! authentication — is the same silence with the session left live, so these
|
||||
//! tests assert the table as well as the reply.
|
||||
|
||||
use std::net::{SocketAddr, SocketAddrV6};
|
||||
|
||||
use irtt_proto::{encode_request, RequestToEncode};
|
||||
|
||||
use super::support::{
|
||||
client_params, close_request, close_request_with_trailing, core_for, core_with_tokens,
|
||||
open_request, open_session, other_peer, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
#[test]
|
||||
fn a_valid_close_removes_the_session_and_answers_nothing() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_for(hmac_key, tokens.clone());
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
// Exactly `None`, not an empty or acknowledging packet: version 1 has
|
||||
// no close reply, so the client learns nothing about the outcome.
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(core.session(token), None);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
0,
|
||||
"a close must not draw from the token source"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_close_is_a_stale_token_and_changes_nothing() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
let close = close_request(token, hmac_key);
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
|
||||
assert_eq!(core.session_count(), 0, "nothing may be resurrected");
|
||||
assert_eq!(core.session(token), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_after_a_close_is_a_stale_token() {
|
||||
// The same request would have been answered a moment earlier: releasing the
|
||||
// session takes its token with it, so there is nothing left to reach.
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
let params = core.session(token).expect("session must exist").params();
|
||||
let echo = encode_request(
|
||||
RequestToEncode::Echo {
|
||||
token,
|
||||
sequence: 0,
|
||||
params,
|
||||
payload: &[],
|
||||
},
|
||||
hmac_key,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
core.handle_datagram(peer(), &close_request(token, hmac_key))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &echo).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(core.session(token), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_from_any_other_endpoint_leaves_the_session_live() {
|
||||
// Possessing the token is not authority to close. The first four pairs
|
||||
// differ in an established identity component; the last differs only in
|
||||
// IPv6 zone, which is `irtt-rs` policy rather than observed behavior — see
|
||||
// `same_endpoint`. It is pinned so the choice cannot drift silently, not
|
||||
// because evidence settled it.
|
||||
for (bound, foreign) in [
|
||||
("198.51.100.7:41234", "198.51.100.7:41235"),
|
||||
("198.51.100.7:41234", "198.51.100.9:41234"),
|
||||
("198.51.100.7:41234", "[::ffff:198.51.100.7]:41234"),
|
||||
("[2001:db8::1]:2112", "[2001:db8::2]:2112"),
|
||||
("[fe80::1%3]:2112", "[fe80::1%4]:2112"),
|
||||
] {
|
||||
let bound = bound.parse().unwrap();
|
||||
let foreign = foreign.parse().unwrap();
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, bound, None);
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
let close = close_request(token, None);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(foreign, &close).unwrap(),
|
||||
None,
|
||||
"a close from {foreign} must be silent"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(
|
||||
core.session(token),
|
||||
Some(&session),
|
||||
"a close from {foreign} must not remove the session bound to {bound}"
|
||||
);
|
||||
|
||||
// The same request from the session's own endpoint does close it, so
|
||||
// the request itself was never the reason for the silence.
|
||||
assert_eq!(core.handle_datagram(bound, &close).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_flow_information_is_not_part_of_endpoint_identity() {
|
||||
// Identity is family, address, port and scope. The flow label is routing
|
||||
// metadata that may vary within one flow, so letting `SocketAddrV6`
|
||||
// equality decide would strand a session its own client could never close.
|
||||
let ip = "2001:db8::1".parse().unwrap();
|
||||
let unlabeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0, 0));
|
||||
let labeled = SocketAddr::V6(SocketAddrV6::new(ip, 2112, 0x000f_1234, 0));
|
||||
assert_ne!(
|
||||
unlabeled, labeled,
|
||||
"the two must differ only in flow information"
|
||||
);
|
||||
|
||||
for (bound, closing) in [(unlabeled, labeled), (labeled, unlabeled)] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, bound, None);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(closing, &close_request(token, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
0,
|
||||
"a close from {closing} must release the session opened from {bound}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_zero_token_closes_nothing() {
|
||||
// Zero is reserved for no-test replies and is never issued to a session, so
|
||||
// it can only ever miss the table.
|
||||
for (name, token) in [("an unknown token", UNKNOWN_TOKEN), ("a zero token", 0)] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let live = open_session(&mut core, peer(), None);
|
||||
let session = core.session(live).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, None))
|
||||
.unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "{name} must remove nothing");
|
||||
assert_eq!(core.session(live), Some(&session));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_that_fails_authentication_leaves_the_session_live() {
|
||||
// Authentication runs before the session is looked up, so a close that
|
||||
// fails it is indistinguishable from one bearing an unknown token — and it
|
||||
// must not be able to tear down an authenticated session.
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), Some(KEY));
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
|
||||
let mut corrupted = close_request(token, Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
|
||||
for (name, packet) in [
|
||||
(
|
||||
"a close with no MAC field at all",
|
||||
close_request(token, None),
|
||||
),
|
||||
(
|
||||
"a close signed with a different key",
|
||||
close_request(token, Some(OTHER_KEY)),
|
||||
),
|
||||
("a close with one bit flipped in the MAC", corrupted),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "{name} must not remove a session");
|
||||
assert_eq!(core.session(token), Some(&session));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_ignores_an_authenticated_close() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), None);
|
||||
let session = core.session(token).cloned().expect("session must exist");
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(token, Some(KEY)))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(token), Some(&session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_bytes_after_the_token_do_not_prevent_the_close() {
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let token = open_session(&mut core, peer(), hmac_key);
|
||||
|
||||
let close = close_request_with_trailing(token, &[0xde, 0xad, 0xbe, 0xef], hmac_key);
|
||||
|
||||
assert_eq!(core.handle_datagram(peer(), &close).unwrap(), None);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_removes_only_the_session_it_names() {
|
||||
// Repeated opens from one endpoint are independent sessions, so a close
|
||||
// must be resolved by token, never by endpoint.
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
|
||||
);
|
||||
let first = open_session(&mut core, peer(), None);
|
||||
let second = open_session(&mut core, peer(), None);
|
||||
let survivor = core.session(second).cloned().expect("session must exist");
|
||||
assert_eq!(core.session_count(), 2);
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(first, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 1, "only one session may be released");
|
||||
assert_eq!(core.session(first), None);
|
||||
assert_eq!(core.session(second), Some(&survivor));
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &close_request(second, None))
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_frees_session_capacity_immediately() {
|
||||
// The session table is the only record of how full the server is, so a
|
||||
// removal is reclaimed at once with no separate counter to drift.
|
||||
let tokens = ScriptedTokens::new([TOKEN_A, TOKEN_B]);
|
||||
let mut core = core_with_tokens(ServerConfig::default().with_max_sessions(1), tokens.clone());
|
||||
let request = open_request(&client_params(), None);
|
||||
|
||||
let first = open_session(&mut core, peer(), None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(other_peer(), &request).unwrap(),
|
||||
None,
|
||||
"the bound is reached, so the second open is refused"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a refused open must not commit a token"
|
||||
);
|
||||
|
||||
core.handle_datagram(peer(), &close_request(first, None))
|
||||
.unwrap();
|
||||
assert_eq!(core.session_count(), 0);
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"a close must not draw from the token source"
|
||||
);
|
||||
|
||||
let second = open_session(&mut core, other_peer(), None);
|
||||
assert_eq!(second, TOKEN_B);
|
||||
assert_ne!(second, 0);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(second).unwrap().peer(), other_peer());
|
||||
}
|
||||
@@ -1,818 +0,0 @@
|
||||
//! Normal echo processing.
|
||||
//!
|
||||
//! An echo request that authenticates, fits the configured maximum, names a
|
||||
//! live session and arrives from that session's endpoint is answered from the
|
||||
//! session's negotiated parameters. Everything else is silence that leaves the
|
||||
//! receive state alone, which these tests prove from the wire — the count and
|
||||
//! window in the *next* accepted reply are what show that a rejected datagram
|
||||
//! changed nothing.
|
||||
//!
|
||||
//! The count and window vectors come from the clean
|
||||
//! `test-vectors/SERVER_BEHAVIORAL_VECTORS.md` Section 1 and are hard-coded
|
||||
//! here rather than recomputed, so that replacing the observed semantics with a
|
||||
//! conventional selective-acknowledgement bitmap fails these tests.
|
||||
|
||||
use std::net::{SocketAddr, SocketAddrV6};
|
||||
|
||||
use irtt_proto::{
|
||||
echo_packet_len, verify_packet_hmac, Clock, Params, ReceivedStats, StampAt, TimestampFields,
|
||||
};
|
||||
|
||||
use super::support::{
|
||||
core_for, core_with_sources, core_with_tokens, echo_params, echo_request, expect_echo_reply,
|
||||
open_negotiated, other_peer, peer, sample, unthrottled, ScriptedClock, ScriptedTokens, KEY,
|
||||
OTHER_KEY,
|
||||
};
|
||||
use crate::{clock::ClockSample, ServerConfig, TimestampAllowance};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const UNKNOWN_TOKEN: u64 = 0x2122_2324_2526_2728;
|
||||
|
||||
/// The lifecycle instant an open consumes before any echo is sent.
|
||||
///
|
||||
/// The core reads the clock once per authenticated request, to judge idle expiry
|
||||
/// and to start the new session's deadlines. It is earlier than every echo
|
||||
/// sample below, as a real monotonic reading would be, and no reply ever carries
|
||||
/// it: an open reply has no timestamp fields.
|
||||
fn open_sample() -> ClockSample {
|
||||
sample(900, 9_000)
|
||||
}
|
||||
|
||||
/// Params selecting no optional field at all, so the echo layout is the
|
||||
/// mandatory field block and nothing else.
|
||||
fn bare_params() -> Params {
|
||||
echo_params(
|
||||
ReceivedStats::None,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
/* length */ 0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Params reporting both statistics, which is what most vectors are stated in.
|
||||
fn counting_params(length: i64) -> Params {
|
||||
echo_params(
|
||||
ReceivedStats::Both,
|
||||
StampAt::None,
|
||||
Clock::Unspecified,
|
||||
length,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens one session and returns the core, the token and the negotiated params.
|
||||
///
|
||||
/// Rate limiting is disabled: these tests drive a whole vector of echoes at once
|
||||
/// against the production clock, and what they are about is the reply, not the
|
||||
/// allowance. `rate` covers the limiter.
|
||||
fn session_with(params: &Params) -> (crate::ServerCore, u64, Params) {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), params, None);
|
||||
(core, token, negotiated)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_valid_echo_is_answered_from_the_negotiated_params() {
|
||||
let (mut core, token, negotiated) = session_with(&bare_params());
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 42, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!(reply.token, token, "the session token is copied through");
|
||||
assert_eq!(
|
||||
reply.sequence, 42,
|
||||
"the sequence number is copied unchanged"
|
||||
);
|
||||
assert_eq!(reply.recv_count, None, "no statistics were negotiated");
|
||||
assert_eq!(reply.recv_window, None);
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields::default(),
|
||||
"no timestamps were negotiated, so none are emitted"
|
||||
);
|
||||
assert!(reply.payload.is_empty());
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
16,
|
||||
"magic, flags, token and sequence only"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
core.session_count(),
|
||||
1,
|
||||
"an echo neither creates nor releases a session"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_sized_by_the_session_not_by_the_request() {
|
||||
// A request may be shorter or longer than the negotiated length, and its
|
||||
// bytes past the sequence number are opaque either way.
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(64));
|
||||
assert_eq!(negotiated.length, 64);
|
||||
|
||||
let short = Params {
|
||||
length: 0,
|
||||
..negotiated.clone()
|
||||
};
|
||||
// Requesting no statistics moves this request's payload region up to where
|
||||
// the *session's* reply keeps its count and window, so the opaque bytes
|
||||
// land squarely on fields a server must not read from a request.
|
||||
let long = Params {
|
||||
length: 100,
|
||||
received_stats: ReceivedStats::None,
|
||||
..negotiated.clone()
|
||||
};
|
||||
|
||||
for (sequence, name, sizing, payload, expected) in [
|
||||
(
|
||||
0,
|
||||
"a request shorter than the negotiated length",
|
||||
&short,
|
||||
&[][..],
|
||||
(1, 0x1),
|
||||
),
|
||||
(
|
||||
1,
|
||||
"a request longer than the negotiated length, with opaque bytes",
|
||||
&long,
|
||||
&[0xa5; 40][..],
|
||||
(2, 0x3),
|
||||
),
|
||||
] {
|
||||
let request = echo_request(token, sequence, sizing, payload, None);
|
||||
assert_ne!(request.len(), 64, "{name} must not be the reply's length");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
64,
|
||||
"{name}: the reply is the negotiated length"
|
||||
);
|
||||
assert_eq!(reply.sequence, sequence);
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(expected.0), Some(expected.1)),
|
||||
"{name}: the opaque tail must not influence the reply"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_over_the_configured_maximum_is_dropped_before_anything_advances() {
|
||||
let config = ServerConfig::default().with_max_packet_length(64);
|
||||
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(64), None);
|
||||
assert_eq!(negotiated.length, 64);
|
||||
|
||||
let at_maximum = echo_request(token, 0, &negotiated, &[], None);
|
||||
assert_eq!(at_maximum.len(), 64);
|
||||
let over_maximum = echo_request(
|
||||
token,
|
||||
1,
|
||||
&Params {
|
||||
length: 65,
|
||||
..negotiated.clone()
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(over_maximum.len(), 65);
|
||||
|
||||
// The comparison is strict, so exactly the maximum is served.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &at_maximum)
|
||||
.unwrap()
|
||||
.expect("a request of exactly the maximum must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &over_maximum).unwrap(),
|
||||
None,
|
||||
"one byte over the maximum must be answered with silence"
|
||||
);
|
||||
|
||||
// The next admissible request is what proves the drop advanced nothing: had
|
||||
// the oversized one counted, this would report 3 rather than 2.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 1, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo after a dropped one must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(2), Some(0x3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_that_owns_no_session_here_is_answered_with_silence() {
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(0));
|
||||
|
||||
for (name, endpoint, request_token) in [
|
||||
("an unknown token", peer(), UNKNOWN_TOKEN),
|
||||
("a zero token", peer(), 0),
|
||||
(
|
||||
"a valid token from another source port",
|
||||
other_peer(),
|
||||
token,
|
||||
),
|
||||
] {
|
||||
let request = echo_request(request_token, 0, &negotiated, &[], None);
|
||||
assert_eq!(
|
||||
core.handle_datagram(endpoint, &request).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
}
|
||||
|
||||
// Count 1 and window 0x1 are the first-request values, so none of the three
|
||||
// rejected datagrams touched this session's receive state.
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("the session's own echo must still be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echo_resolves_ipv6_endpoints_by_the_same_identity_rule_as_a_close() {
|
||||
// The comparator itself is pinned by the close tests; this is the echo path
|
||||
// reaching the same one rather than growing a second rule.
|
||||
let global = "2001:db8::1".parse().unwrap();
|
||||
let link_local = "fe80::1".parse().unwrap();
|
||||
let unlabeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0, 0));
|
||||
let labeled = SocketAddr::V6(SocketAddrV6::new(global, 2112, 0x000f_1234, 0));
|
||||
let zone_three = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 3));
|
||||
let zone_four = SocketAddr::V6(SocketAddrV6::new(link_local, 2112, 0, 4));
|
||||
|
||||
for (bound, sender, answered) in [
|
||||
// Flow information is routing metadata that identifies no endpoint.
|
||||
(unlabeled, labeled, true),
|
||||
(labeled, unlabeled, true),
|
||||
// The zone is identity by `irtt-rs` policy, not by observed behavior.
|
||||
(zone_three, zone_four, false),
|
||||
] {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, bound, &counting_params(0), None);
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(sender, &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reply.is_some(),
|
||||
answered,
|
||||
"an echo from {sender} on a session opened from {bound}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn received_count_and_window_follow_the_observed_vectors() {
|
||||
// Every row is `(request sequence, reply count, reply window)` exactly as
|
||||
// measured in Section 1 of the behavioral vectors.
|
||||
for (name, vectors) in [
|
||||
(
|
||||
"1.1 sequential, no loss",
|
||||
&[
|
||||
(0, 1, 0x1),
|
||||
(1, 2, 0x3),
|
||||
(2, 3, 0x7),
|
||||
(3, 4, 0xf),
|
||||
(4, 5, 0x1f),
|
||||
(5, 6, 0x3f),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.2 one gap",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (3, 3, 0xd), (4, 4, 0x1b)][..],
|
||||
),
|
||||
(
|
||||
"1.3 multiple gaps",
|
||||
&[(0, 1, 0x1), (3, 2, 0x9), (7, 3, 0x91), (8, 4, 0x123)][..],
|
||||
),
|
||||
(
|
||||
// Filling a gap late discards the history rather than setting the
|
||||
// bit the gap left clear.
|
||||
"1.4 later gap fill",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (5, 3, 0x31), (3, 4, 0x1)][..],
|
||||
),
|
||||
(
|
||||
"1.5 duplicate sequence number",
|
||||
&[(0, 1, 0x1), (1, 2, 0x3), (1, 3, 0x3), (2, 4, 0x7)][..],
|
||||
),
|
||||
(
|
||||
// Row 5 rebuilds from the reset: bit 1 stays clear even though
|
||||
// sequence 2 was received before the reordering.
|
||||
"1.6 reordering",
|
||||
&[
|
||||
(0, 1, 0x1),
|
||||
(1, 2, 0x3),
|
||||
(2, 3, 0x7),
|
||||
(1, 4, 0x1),
|
||||
(3, 5, 0x5),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.7 strictly descending sequence numbers",
|
||||
&[
|
||||
(5, 1, 0x1),
|
||||
(4, 2, 0x1),
|
||||
(3, 3, 0x1),
|
||||
(2, 4, 0x1),
|
||||
(1, 5, 0x1),
|
||||
(0, 6, 0x1),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"1.8 a gap of exactly 63",
|
||||
&[(0, 1, 0x1), (63, 2, 0x8000_0000_0000_0001)][..],
|
||||
),
|
||||
("1.8 a gap of 64", &[(0, 1, 0x1), (64, 2, 0x1)][..]),
|
||||
("1.8 a gap of 65", &[(0, 1, 0x1), (65, 2, 0x1)][..]),
|
||||
("1.8 a gap of 1000", &[(0, 1, 0x1), (1000, 2, 0x1)][..]),
|
||||
(
|
||||
"1.9 a non-zero starting sequence number",
|
||||
&[(100, 1, 0x1), (101, 2, 0x3), (102, 3, 0x7)][..],
|
||||
),
|
||||
(
|
||||
"1.10 32-bit sequence wraparound",
|
||||
&[
|
||||
(0xffff_fffd, 1, 0x1),
|
||||
(0xffff_fffe, 2, 0x3),
|
||||
(0xffff_ffff, 3, 0x7),
|
||||
(0, 4, 0xf),
|
||||
(1, 5, 0x1f),
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let (mut core, token, negotiated) = session_with(&counting_params(0));
|
||||
|
||||
for &(sequence, count, window) in vectors {
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{name}: sequence {sequence} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.recv_count,
|
||||
Some(count),
|
||||
"{name}: count after sequence {sequence}"
|
||||
);
|
||||
assert_eq!(
|
||||
reply.recv_window,
|
||||
Some(window),
|
||||
"{name}: window after sequence {sequence}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_negotiated_received_statistics_are_reported() {
|
||||
// Field presence only; the transition itself is pinned by the vectors
|
||||
// above, so two in-order requests are enough here.
|
||||
for (stats, count, window) in [
|
||||
(ReceivedStats::None, None, None),
|
||||
(ReceivedStats::Count, Some(2), None),
|
||||
(ReceivedStats::Window, None, Some(0x3)),
|
||||
(ReceivedStats::Both, Some(2), Some(0x3)),
|
||||
] {
|
||||
let params = echo_params(stats, StampAt::None, Clock::Unspecified, 0);
|
||||
let (mut core, token, negotiated) = session_with(¶ms);
|
||||
|
||||
let mut reply = None;
|
||||
for sequence in 0..2 {
|
||||
let packet = core
|
||||
.handle_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, &negotiated, &[], None),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stats:?}: sequence {sequence} must be answered"));
|
||||
reply = Some(expect_echo_reply(&packet, &negotiated, None));
|
||||
}
|
||||
|
||||
let reply = reply.expect("two requests were sent");
|
||||
assert_eq!(reply.recv_count, count, "count presence for {stats:?}");
|
||||
assert_eq!(reply.recv_window, window, "window presence for {stats:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticated_session_answers_only_authenticated_echoes() {
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &counting_params(0), Some(KEY));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 3, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("a correctly authenticated echo must be answered");
|
||||
assert!(
|
||||
verify_packet_hmac(KEY, packet.bytes()).is_ok(),
|
||||
"the reply must carry a MAC that verifies with the server's key"
|
||||
);
|
||||
// `expect_echo_reply` pins the HMAC flag and verifies the MAC again while
|
||||
// decoding, so a reply that authenticated but omitted the flag would fail
|
||||
// here — the failure mode that silently breaks upstream clients.
|
||||
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(reply.token, token);
|
||||
assert_eq!(reply.sequence, 3);
|
||||
assert_eq!((reply.recv_count, reply.recv_window), (Some(1), Some(0x1)));
|
||||
|
||||
let mut corrupted = echo_request(token, 4, &negotiated, &[], Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
for (name, packet) in [
|
||||
(
|
||||
"an echo with no MAC field at all",
|
||||
echo_request(token, 4, &negotiated, &[], None),
|
||||
),
|
||||
(
|
||||
"an echo signed with a different key",
|
||||
echo_request(token, 4, &negotiated, &[], Some(OTHER_KEY)),
|
||||
),
|
||||
("an echo with one bit flipped in the MAC", corrupted),
|
||||
] {
|
||||
assert_eq!(
|
||||
core.handle_datagram(peer(), &packet).unwrap(),
|
||||
None,
|
||||
"{name} must be answered with silence"
|
||||
);
|
||||
}
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 4, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an authenticated echo after failed ones must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, Some(KEY));
|
||||
assert_eq!(
|
||||
(reply.recv_count, reply.recv_window),
|
||||
(Some(2), Some(0x3)),
|
||||
"none of the packets that failed authentication may be counted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_negotiated_timestamps_are_emitted() {
|
||||
let received = sample(1_000, 10_000);
|
||||
let sent = sample(1_200, 10_300);
|
||||
|
||||
for (stamp_at, clock, expected) in [
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
send_wall: Some(1_200),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let clock_source = ScriptedClock::new([open_sample(), received, sent]);
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock_source.clone(),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, stamp_at, clock, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
assert_eq!(
|
||||
clock_source.remaining(),
|
||||
2,
|
||||
"an open consumes exactly its own lifecycle sample"
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
// Equality over the whole struct, so an unselected field appearing is a
|
||||
// failure as much as a selected one carrying the wrong instant.
|
||||
assert_eq!(reply.timestamps, expected, "{stamp_at:?} on {clock:?}");
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{stamp_at:?} on {clock:?} must be the negotiated layout's length"
|
||||
);
|
||||
assert_eq!(
|
||||
clock_source.remaining(),
|
||||
0,
|
||||
"{stamp_at:?} on {clock:?}: one receive and one send sample"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backward_wall_step_never_reports_receive_after_send() {
|
||||
// A reply's receive instant must not be later than its send instant, and
|
||||
// the wall clock can be stepped backwards between the two readings by NTP,
|
||||
// a hypervisor or an administrator. The pair settles on the later reading,
|
||||
// and only the wall domain moves: the monotonic one was already ordered.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(990),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(990),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
"the receive wall value is held back to the send reading, and only it"
|
||||
);
|
||||
assert!(reply.timestamps.recv_wall <= reply.timestamps.send_wall);
|
||||
assert!(reply.timestamps.recv_mono <= reply.timestamps.send_mono);
|
||||
|
||||
// A midpoint drawn from the same stepped pair is ordered for the same
|
||||
// reason: it is the mean of two values that are no longer inverted.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(990, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, Clock::Wall, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
reply.timestamps,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(990),
|
||||
..TimestampFields::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_clock_midpoint_emits_exactly_one_field() {
|
||||
// A guard against reproducing upstream 0.9.1's dual-field midpoint, which
|
||||
// emits both midpoint fields whatever the negotiated clock. `irtt-proto`
|
||||
// still decodes that form from a peer; this is about what this server
|
||||
// sends.
|
||||
for (clock, expected) in [
|
||||
(
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Midpoint, clock, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("a midpoint echo on {clock:?} must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{clock:?}: the negotiated length, not the compatibility one"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
24,
|
||||
"{clock:?}: one 8-byte midpoint field, not the upstream two"
|
||||
);
|
||||
assert_eq!(reply.timestamps, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_payload_is_server_generated_and_never_reflects_the_request() {
|
||||
// The inbound tail is opaque, and ServerFill describes the *server's* bytes:
|
||||
// a request's payload is neither echoed, mixed in, nor used to seed
|
||||
// anything. This session expressed no fill preference, so the region
|
||||
// carries the server's default fill; `fill` covers the modes themselves.
|
||||
let params = echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 64);
|
||||
let (mut core, token, negotiated) = session_with(¶ms);
|
||||
|
||||
let request = echo_request(token, 0, &negotiated, &[0xa5; 48], None);
|
||||
assert!(
|
||||
request[16..].iter().all(|byte| *byte == 0xa5),
|
||||
"the request must carry non-zero payload bytes to reflect"
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
assert_eq!(packet.bytes().len(), 64);
|
||||
assert_eq!(reply.payload.len(), 48);
|
||||
assert!(
|
||||
!reply.payload.contains(&0xa5),
|
||||
"no request byte may appear in the reply payload"
|
||||
);
|
||||
assert!(
|
||||
reply
|
||||
.payload
|
||||
.chunks(4)
|
||||
.all(|chunk| chunk == &b"irtt"[..chunk.len()]),
|
||||
"the region carries the server's default fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restricted_session_emits_the_restricted_timestamps_and_not_the_requested_ones() {
|
||||
// Timestamp generation follows the session's *negotiated* placement, which
|
||||
// is the point of restricting it during negotiation: a client asking for
|
||||
// both instants from both clocks gets one midpoint field per clock under a
|
||||
// single-timestamp allowance, and nothing at all under none — with the reply
|
||||
// laid out and sized from the restriction either way, not from the request.
|
||||
for (allowance, expected_stamp_at, expected, expected_len) in [
|
||||
(
|
||||
TimestampAllowance::Single,
|
||||
StampAt::Midpoint,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
32,
|
||||
),
|
||||
(
|
||||
TimestampAllowance::None,
|
||||
StampAt::None,
|
||||
TimestampFields::default(),
|
||||
16,
|
||||
),
|
||||
] {
|
||||
let mut core = core_with_sources(
|
||||
unthrottled().with_timestamp_allowance(allowance),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), sample(1_000, 10_000), sample(1_200, 10_300)]),
|
||||
);
|
||||
let requested = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, None);
|
||||
assert_eq!(negotiated.stamp_at, expected_stamp_at, "{allowance:?}");
|
||||
assert_eq!(negotiated.clock, Clock::Both, "{allowance:?}");
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{allowance:?}: an admissible echo must be answered"));
|
||||
let reply = expect_echo_reply(&packet, &negotiated, None);
|
||||
|
||||
// Equality over the whole struct, so a leftover receive or send field is
|
||||
// a failure as much as a missing midpoint one.
|
||||
assert_eq!(reply.timestamps, expected, "{allowance:?}");
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
echo_packet_len(false, &negotiated).unwrap(),
|
||||
"{allowance:?}: the restricted layout's length"
|
||||
);
|
||||
assert_eq!(
|
||||
packet.bytes().len(),
|
||||
expected_len,
|
||||
"{allowance:?}: no empty slot is kept for a restricted field"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
//! Server fill: what an open reply reports, what the session will fill with,
|
||||
//! and the bytes an echo reply actually carries.
|
||||
//!
|
||||
//! The first two are deliberately different questions. A client that expressed
|
||||
//! no preference keeps its absent or empty descriptor in the negotiated
|
||||
//! parameters while the session uses this server's default fill, and only a
|
||||
//! descriptor the server could not honor is rewritten. Asserting the reply alone
|
||||
//! would not separate those, so the negotiation tests read the session's
|
||||
//! effective mode too, and the payload tests then pin the bytes on the wire.
|
||||
//!
|
||||
//! Payload lengths here are derived from `irtt-proto`'s own layout rather than
|
||||
//! written as offsets, and are deliberately not multiples of the pattern length.
|
||||
|
||||
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_for, core_with_tokens, echo_params, echo_request, expect_echo_reply,
|
||||
expect_no_test_reply, expect_normal_open_reply, no_test_request, open_negotiated, open_request,
|
||||
other_peer, peer, unthrottled, ScriptedTokens, KEY,
|
||||
};
|
||||
use crate::{core::ServerCore, fill::FillMode};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
|
||||
|
||||
/// Request payload bytes an echo carries, so a reply reflecting them would be
|
||||
/// obvious.
|
||||
const REQUEST_FILLER: u8 = 0xa5;
|
||||
|
||||
/// Params requesting `descriptor` as the server fill.
|
||||
fn requesting(descriptor: Option<&str>) -> Params {
|
||||
Params {
|
||||
server_fill: descriptor.map(|value| ServerFill {
|
||||
value: value.to_owned(),
|
||||
}),
|
||||
..client_params()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a session requesting `descriptor` and returns the descriptor the reply
|
||||
/// carried together with the fill the session actually holds.
|
||||
fn negotiated(descriptor: Option<&str>) -> (Option<String>, FillMode) {
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&requesting(descriptor), None))
|
||||
.unwrap()
|
||||
.expect("the open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
let fill = effective_fill(&core, reply.token);
|
||||
(reply.params.server_fill.map(|fill| fill.value), fill)
|
||||
}
|
||||
|
||||
/// The effective fill of the session `token` names.
|
||||
fn effective_fill(core: &ServerCore, token: u64) -> FillMode {
|
||||
core.session(token)
|
||||
.expect("the open must have created a session")
|
||||
.fill()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The descriptor a no-test open reports for `descriptor`, which creates no
|
||||
/// session and so retains no fill.
|
||||
fn no_test_negotiated(descriptor: Option<&str>) -> Option<String> {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(unthrottled(), tokens.clone());
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &no_test_request(&requesting(descriptor), None))
|
||||
.unwrap()
|
||||
.expect("the no-test open must be answered");
|
||||
|
||||
let reply = expect_no_test_reply(&packet, None);
|
||||
assert_eq!(core.session_count(), 0, "no-test creates no session");
|
||||
assert_eq!(tokens.remaining(), 1, "no-test draws no session token");
|
||||
reply.params.server_fill.map(|fill| fill.value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_preference_keeps_an_absent_descriptor_and_uses_the_default_fill() {
|
||||
// The important half is the first: a client that asked for nothing must be
|
||||
// answered with nothing, or a strict client rejects a restriction the
|
||||
// server never actually imposed. The server's own default is an internal
|
||||
// choice and stays internal.
|
||||
let (returned, fill) = negotiated(None);
|
||||
assert_eq!(returned, None, "an absent request stays absent");
|
||||
assert_eq!(fill, FillMode::default_fill());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicitly_empty_descriptor_is_preserved_and_uses_the_default_fill() {
|
||||
// A low-level peer can send this even though `ClientConfig` rejects an
|
||||
// empty fill before it reaches the wire. The clean evidence groups empty
|
||||
// with absent as "no preference", so it is neither refused nor rewritten:
|
||||
// the requested wire value comes back exactly, and the server picks its own
|
||||
// behavior behind it.
|
||||
let (returned, fill) = negotiated(Some(""));
|
||||
assert_eq!(
|
||||
returned.as_deref(),
|
||||
Some(""),
|
||||
"an empty request stays empty"
|
||||
);
|
||||
assert_eq!(fill, FillMode::default_fill());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_valid_descriptor_is_returned_exactly_as_requested() {
|
||||
for (descriptor, expected) in [
|
||||
("none", FillMode::None),
|
||||
("rand", FillMode::Random),
|
||||
("pattern:00", FillMode::Pattern(vec![0x00])),
|
||||
("pattern:ff00", FillMode::Pattern(vec![0xff, 0x00])),
|
||||
// Returned with its own hexadecimal case, not normalized to the bytes
|
||||
// it decodes to: rewriting it would report a restriction that did not
|
||||
// happen, and a strict client would reject the session for it.
|
||||
("pattern:AaBb", FillMode::Pattern(vec![0xaa, 0xbb])),
|
||||
] {
|
||||
let (returned, fill) = negotiated(Some(descriptor));
|
||||
assert_eq!(returned.as_deref(), Some(descriptor), "{descriptor}");
|
||||
assert_eq!(fill, expected, "{descriptor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_malformed_descriptor_is_replaced_by_the_default() {
|
||||
// Here the server really did change what was asked for, so the reply says
|
||||
// so and a strict client may refuse the session. The requested string is
|
||||
// not retained anywhere.
|
||||
for descriptor in [
|
||||
"bogus",
|
||||
"RAND",
|
||||
"None",
|
||||
"Pattern:aabb",
|
||||
"pattern:",
|
||||
"pattern:f",
|
||||
"pattern:abc",
|
||||
"pattern:zz",
|
||||
"pattern:0g",
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
] {
|
||||
let (returned, fill) = negotiated(Some(descriptor));
|
||||
assert_eq!(
|
||||
returned.as_deref(),
|
||||
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR),
|
||||
"{descriptor}"
|
||||
);
|
||||
assert_eq!(fill, FillMode::default_fill(), "{descriptor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_test_open_negotiates_fill_exactly_as_a_normal_open_does() {
|
||||
// Same negotiation, no session, and therefore no effective mode to retain.
|
||||
assert_eq!(no_test_negotiated(None), None);
|
||||
assert_eq!(no_test_negotiated(Some("")).as_deref(), Some(""));
|
||||
assert_eq!(
|
||||
no_test_negotiated(Some("pattern:AaBb")).as_deref(),
|
||||
Some("pattern:AaBb")
|
||||
);
|
||||
assert_eq!(no_test_negotiated(Some("none")).as_deref(), Some("none"));
|
||||
assert_eq!(
|
||||
no_test_negotiated(Some("pattern:zz")).as_deref(),
|
||||
Some(crate::fill::DEFAULT_FILL_DESCRIPTOR)
|
||||
);
|
||||
}
|
||||
|
||||
/// Params for a session requesting `descriptor` whose echo replies have exactly
|
||||
/// `payload_len` payload bytes.
|
||||
///
|
||||
/// The negotiated length is the mandatory field block plus the region wanted,
|
||||
/// asked of `irtt-proto` rather than written as a constant: the block grows with
|
||||
/// the negotiated statistics, the timestamps and authentication's 16 bytes, and
|
||||
/// a test that hard-coded an offset would silently drift from the encoder.
|
||||
fn payload_params(descriptor: Option<&str>, payload_len: usize, hmac: bool) -> Params {
|
||||
let mut params = Params {
|
||||
server_fill: requesting(descriptor).server_fill,
|
||||
..echo_params(ReceivedStats::None, StampAt::None, Clock::Unspecified, 0)
|
||||
};
|
||||
params.length = (echo_header_len(hmac, ¶ms) + payload_len) as i64;
|
||||
params
|
||||
}
|
||||
|
||||
/// The payload regions of `count` successive echo replies on one session
|
||||
/// requesting `descriptor`.
|
||||
///
|
||||
/// Every request carries [`REQUEST_FILLER`] payload bytes, so a reply that
|
||||
/// reflected its request would be visible in any of these tests rather than only
|
||||
/// in the one about it.
|
||||
fn echo_payloads(
|
||||
descriptor: Option<&str>,
|
||||
payload_len: usize,
|
||||
hmac_key: Option<&[u8]>,
|
||||
count: u32,
|
||||
) -> Vec<Vec<u8>> {
|
||||
let requested = payload_params(descriptor, payload_len, hmac_key.is_some());
|
||||
let mut core = core_for(hmac_key, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &requested, hmac_key);
|
||||
|
||||
// The request fills its own payload region, which is the same size as the
|
||||
// reply's: the strongest shape for "a reply never reflects its request".
|
||||
let request_payload = vec![REQUEST_FILLER; payload_len];
|
||||
(0..count)
|
||||
.map(|sequence| {
|
||||
let request = echo_request(token, sequence, &negotiated, &request_payload, hmac_key);
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &request)
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
let reply = expect_echo_reply(&packet, &negotiated, hmac_key);
|
||||
assert_eq!(
|
||||
packet.bytes().len() as i64,
|
||||
negotiated.length,
|
||||
"a reply is exactly the negotiated length"
|
||||
);
|
||||
assert_eq!(reply.payload.len(), payload_len, "payload region length");
|
||||
reply.payload
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The payload region of one echo reply on a session requesting `descriptor`.
|
||||
fn echo_payload(descriptor: Option<&str>, payload_len: usize) -> Vec<u8> {
|
||||
echo_payloads(descriptor, payload_len, None, 1).remove(0)
|
||||
}
|
||||
|
||||
/// `pattern` repeated to `len` bytes, starting at its first byte.
|
||||
fn repeated(pattern: &[u8], len: usize) -> Vec<u8> {
|
||||
pattern.iter().copied().cycle().take(len).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_preference_fills_with_the_default_pattern() {
|
||||
// The wire consequence of the negotiation test above: absent descriptor,
|
||||
// default bytes. 7 is not a multiple of 4, so the last repeat is partial.
|
||||
assert_eq!(echo_payload(None, 7), b"irttirt".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_preference_fills_with_the_default_pattern() {
|
||||
assert_eq!(echo_payload(Some(""), 7), b"irttirt".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_or_malformed_descriptor_fills_with_the_default_pattern() {
|
||||
for descriptor in ["bogus", "RAND", "pattern:", "pattern:abc", "pattern:zz"] {
|
||||
assert_eq!(
|
||||
echo_payload(Some(descriptor), 7),
|
||||
b"irttirt".to_vec(),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fill_produces_a_zero_payload_and_never_the_request() {
|
||||
// `irtt-rs` policy, and a deliberate divergence: the clean evidence records
|
||||
// the reference server leaving this region as unspecified residual buffer
|
||||
// content — returning the requester's own payload, or fragments of earlier
|
||||
// traffic from another client. Zeroes disclose nothing.
|
||||
let payload = echo_payload(Some("none"), 7);
|
||||
assert_eq!(payload, vec![0; 7]);
|
||||
assert!(!payload.contains(&REQUEST_FILLER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_repeat_from_their_first_byte() {
|
||||
for (descriptor, expected) in [
|
||||
("pattern:00", &[0x00][..]),
|
||||
("pattern:ff00", &[0xff, 0x00][..]),
|
||||
// The reply follows the bytes the descriptor decodes to, whatever case
|
||||
// it was written in.
|
||||
("pattern:AaBb", &[0xaa, 0xbb][..]),
|
||||
("pattern:69727474", b"irtt"),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_payload(Some(descriptor), 7),
|
||||
repeated(expected, 7),
|
||||
"{descriptor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_restarts_for_every_reply() {
|
||||
// Deliberate `irtt-rs` policy. The clean evidence records the reference
|
||||
// implementation advancing one phase continuously across replies, sessions
|
||||
// and listeners, and states that payload phase carries no protocol meaning
|
||||
// and that a conforming client must not depend on it — so this server keeps
|
||||
// no fill state at all and every reply starts at byte zero.
|
||||
let payloads = echo_payloads(Some("pattern:010203"), 5, None, 2);
|
||||
assert_eq!(payloads[0], vec![0x01, 0x02, 0x03, 0x01, 0x02]);
|
||||
assert_eq!(
|
||||
payloads[1], payloads[0],
|
||||
"the phase resets rather than continuing at 03 01 02 03 01"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_fill_independently() {
|
||||
// Interleaved on one core, so a shared cursor or a global phase would show
|
||||
// up as one session's bytes drifting into the other's.
|
||||
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A, TOKEN_B]));
|
||||
let a_requested = payload_params(Some("pattern:aabb"), 5, false);
|
||||
let b_requested = payload_params(Some("pattern:112233"), 5, false);
|
||||
let (a_token, a) = open_negotiated(&mut core, peer(), &a_requested, None);
|
||||
let (b_token, b) = open_negotiated(&mut core, other_peer(), &b_requested, None);
|
||||
|
||||
let mut payload_of = |endpoint, token, sequence, params: &Params| {
|
||||
let packet = core
|
||||
.handle_datagram(endpoint, &echo_request(token, sequence, params, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
expect_echo_reply(&packet, params, None).payload
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
payload_of(peer(), a_token, 0, &a),
|
||||
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa]
|
||||
);
|
||||
assert_eq!(
|
||||
payload_of(other_peer(), b_token, 0, &b),
|
||||
vec![0x11, 0x22, 0x33, 0x11, 0x22]
|
||||
);
|
||||
assert_eq!(
|
||||
payload_of(peer(), a_token, 1, &a),
|
||||
vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa],
|
||||
"session A is unaffected by session B's reply"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_random_session_fills_the_whole_region() {
|
||||
// Deterministic assertions only: the region is the negotiated one and the
|
||||
// reply decodes and authenticates. What the operating system's random
|
||||
// source returns is not a property this suite can assert without flipping a
|
||||
// coin, and `fill`'s unit tests cover propagation and the failure fallback.
|
||||
for hmac_key in [None, Some(KEY)] {
|
||||
// `echo_payloads` already asserts the datagram length and the region
|
||||
// length of each reply, and decoding verifies the MAC. Nothing is
|
||||
// asserted about the byte values, deliberately: a random payload
|
||||
// containing any particular byte is chance, not behavior.
|
||||
assert_eq!(echo_payloads(Some("rand"), 7, hmac_key, 2).len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_consumes_payload_space_at_the_same_negotiated_length() {
|
||||
// One negotiated length, two field blocks: authentication's 16 bytes come
|
||||
// out of the payload region, not out of the datagram. Every remaining
|
||||
// payload byte is still filled, and the authenticated reply's MAC covers
|
||||
// those bytes — `expect_echo_reply` verifies it while decoding.
|
||||
let unauthenticated = payload_params(Some("pattern:aa"), 48, false);
|
||||
let length = unauthenticated.length;
|
||||
let authenticated = Params {
|
||||
length,
|
||||
..payload_params(Some("pattern:aa"), 0, true)
|
||||
};
|
||||
|
||||
let plain = {
|
||||
let mut core = core_for(None, ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &unauthenticated, None);
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
assert_eq!(packet.bytes().len() as i64, length);
|
||||
expect_echo_reply(&packet, &negotiated, None).payload
|
||||
};
|
||||
let signed = {
|
||||
let mut core = core_for(Some(KEY), ScriptedTokens::new([TOKEN_A]));
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), &authenticated, Some(KEY));
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("an admissible echo must be answered");
|
||||
assert_eq!(packet.bytes().len() as i64, length);
|
||||
expect_echo_reply(&packet, &negotiated, Some(KEY)).payload
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
signed.len() + irtt_proto::HMAC_SIZE,
|
||||
plain.len(),
|
||||
"authentication takes its 16 bytes from the payload region"
|
||||
);
|
||||
assert!(plain.iter().chain(&signed).all(|byte| *byte == 0xaa));
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
use irtt_proto::{verify_packet_hmac, FLAG_HMAC};
|
||||
|
||||
use super::support::{
|
||||
client_params, core_with_tokens, expect_normal_open_reply, open_request,
|
||||
open_request_with_raw_params, param_int, peer, ScriptedTokens, KEY, OTHER_KEY,
|
||||
};
|
||||
use crate::ServerConfig;
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
|
||||
/// Asserts a datagram is silently discarded, leaving no trace at all: no reply,
|
||||
/// no session, and no session token drawn.
|
||||
fn assert_discarded(config: ServerConfig, name: &str, packet: &[u8]) {
|
||||
let tokens = ScriptedTokens::new([TOKEN_A]);
|
||||
let mut core = core_with_tokens(config, tokens.clone());
|
||||
|
||||
let reply = core
|
||||
.handle_datagram(peer(), packet)
|
||||
.expect("a rejected datagram is not a server error");
|
||||
|
||||
assert!(reply.is_none(), "{name} must be answered with silence");
|
||||
assert_eq!(core.session_count(), 0, "{name} must create no session");
|
||||
assert_eq!(
|
||||
tokens.remaining(),
|
||||
1,
|
||||
"{name} must not consume session capacity or a token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticating_server_discards_everything_that_does_not_authenticate() {
|
||||
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with no MAC field at all",
|
||||
&open_request(&client_params(), None),
|
||||
);
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open signed with a different key",
|
||||
&open_request(&client_params(), Some(OTHER_KEY)),
|
||||
);
|
||||
|
||||
let mut corrupted = open_request(&client_params(), Some(KEY));
|
||||
corrupted[5] ^= 0x80;
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with one bit flipped in the MAC",
|
||||
&corrupted,
|
||||
);
|
||||
|
||||
let mut zeroed = open_request(&client_params(), Some(KEY));
|
||||
zeroed[4..20].fill(0);
|
||||
assert_discarded(config.clone(), "open with an all-zero MAC", &zeroed);
|
||||
|
||||
let mut truncated = open_request(&client_params(), Some(KEY));
|
||||
truncated.drain(4..12);
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"open with a truncated MAC field",
|
||||
&truncated,
|
||||
);
|
||||
|
||||
let mut flag_cleared = open_request(&client_params(), Some(KEY));
|
||||
flag_cleared[3] &= !FLAG_HMAC;
|
||||
assert_discarded(
|
||||
config,
|
||||
"open with a valid MAC but the HMAC flag cleared",
|
||||
&flag_cleared,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_discards_authenticated_requests() {
|
||||
assert_discarded(
|
||||
ServerConfig::default(),
|
||||
"authenticated open to a server with no key",
|
||||
&open_request(&client_params(), Some(KEY)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_authenticating_server_answers_a_valid_open_with_an_authenticated_reply() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("a correctly authenticated open must be answered");
|
||||
|
||||
let reply = expect_normal_open_reply(&packet, Some(KEY));
|
||||
assert_eq!(reply.token, TOKEN_A);
|
||||
assert_ne!(
|
||||
packet.bytes()[3] & FLAG_HMAC,
|
||||
0,
|
||||
"the reply must set FLAG_HMAC"
|
||||
);
|
||||
verify_packet_hmac(KEY, packet.bytes()).expect("the reply must carry a valid MAC");
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_authenticating_server_answers_an_ordinary_open() {
|
||||
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &open_request(&client_params(), None))
|
||||
.unwrap()
|
||||
.expect("an unauthenticated open must be answered");
|
||||
|
||||
expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(packet.bytes()[3] & FLAG_HMAC, 0);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_is_checked_before_parameters_are_decoded() {
|
||||
// Both of these are dropped, and neither reveals which stage rejected it.
|
||||
// The pairing is the point: bad parameters under a valid MAC still drop,
|
||||
// and a bad MAC drops without the parameters ever being parsed.
|
||||
let config = ServerConfig::default().with_hmac_key(KEY);
|
||||
let bad_params = param_int(2, 0);
|
||||
|
||||
assert_discarded(
|
||||
config.clone(),
|
||||
"authenticated open with an explicit zero duration",
|
||||
&open_request_with_raw_params(&bad_params, Some(KEY)),
|
||||
);
|
||||
assert_discarded(
|
||||
config,
|
||||
"wrongly keyed open with an explicit zero duration",
|
||||
&open_request_with_raw_params(&bad_params, Some(OTHER_KEY)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_authentication_leaves_a_live_session_untouched() {
|
||||
let mut core = core_with_tokens(
|
||||
ServerConfig::default().with_hmac_key(KEY),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
);
|
||||
core.handle_datagram(peer(), &open_request(&client_params(), Some(KEY)))
|
||||
.unwrap()
|
||||
.expect("the session-creating open must succeed");
|
||||
let session = core.session(TOKEN_A).cloned().expect("session must exist");
|
||||
|
||||
for bad in [
|
||||
open_request(&client_params(), None),
|
||||
open_request(&client_params(), Some(OTHER_KEY)),
|
||||
] {
|
||||
assert_eq!(core.handle_datagram(peer(), &bad).unwrap(), None);
|
||||
}
|
||||
|
||||
assert_eq!(core.session_count(), 1);
|
||||
assert_eq!(core.session(TOKEN_A), Some(&session));
|
||||
}
|
||||
@@ -1,668 +0,0 @@
|
||||
//! Consuming a kernel-observed receive timestamp.
|
||||
//!
|
||||
//! A Linux listener reports the kernel's arrival time for each datagram, and an
|
||||
//! echo reply may report it as the wall-clock instant the request was received.
|
||||
//! These tests pin what that is allowed to change and — mostly — what it is
|
||||
//! not: only `recv_wall`, only under [`StampAt::Receive`] and [`StampAt::Both`],
|
||||
//! only on a clock that reports wall time, and never a monotonic value, a
|
||||
//! midpoint, or any lifecycle, rate or deadline decision.
|
||||
//!
|
||||
//! Whether the transport actually captures a timestamp is settled by the socket
|
||||
//! layer's own tests; nothing here recertifies `SCM_TIMESTAMPNS`. These drive
|
||||
//! the core with fabricated values, so they run identically on every target.
|
||||
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
|
||||
|
||||
use super::support::{
|
||||
close_request, core_with_sources, echo_at, echo_params, echo_request,
|
||||
expect_closing_echo_reply, expect_echo_reply, expect_normal_open_reply, manual_core,
|
||||
open_negotiated, open_request, peer, sample, ManualClock, ScriptedClock, ScriptedTokens,
|
||||
};
|
||||
use crate::{
|
||||
clock::ClockSample,
|
||||
core::{preferred_receive_wall_ns, OutboundDatagram, ServerCore, MAX_KERNEL_RX_LAG},
|
||||
ServerConfig,
|
||||
};
|
||||
|
||||
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
|
||||
const MS: i64 = 1_000_000;
|
||||
const SECOND: i64 = 1_000 * MS;
|
||||
|
||||
/// The lifecycle instant the session-creating open consumes.
|
||||
fn open_sample() -> ClockSample {
|
||||
sample(900_000, 9_000)
|
||||
}
|
||||
|
||||
/// The core's own receive reading for the echo every matrix test sends.
|
||||
fn received() -> ClockSample {
|
||||
sample(1_000_000, 10_000)
|
||||
}
|
||||
|
||||
/// The core's send reading for the same echo.
|
||||
fn sent() -> ClockSample {
|
||||
sample(1_200_000, 10_300)
|
||||
}
|
||||
|
||||
/// A kernel arrival reading 100 µs before [`received`], which is the ordinary
|
||||
/// shape: earlier than the server's own sample and well inside the bound.
|
||||
fn kernel_rx() -> SystemTime {
|
||||
at_epoch_ns(900_000)
|
||||
}
|
||||
|
||||
/// The [`SystemTime`] `ns` nanoseconds from the Unix epoch, before it when
|
||||
/// negative.
|
||||
fn at_epoch_ns(ns: i64) -> SystemTime {
|
||||
let magnitude = Duration::from_nanos(ns.unsigned_abs());
|
||||
if ns.is_negative() {
|
||||
UNIX_EPOCH - magnitude
|
||||
} else {
|
||||
UNIX_EPOCH + magnitude
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one echo carrying `kernel_rx_timestamp` through the runtime's entry
|
||||
/// point and returns the reply's timestamp fields.
|
||||
fn echo_timestamps(
|
||||
stamp_at: StampAt,
|
||||
clock: Clock,
|
||||
kernel_rx_timestamp: Option<SystemTime>,
|
||||
) -> TimestampFields {
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), received(), sent()]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, stamp_at, clock, /* length */ 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None),
|
||||
kernel_rx_timestamp,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("{stamp_at:?} on {clock:?} must be answered"));
|
||||
expect_echo_reply(&packet, &negotiated, None).timestamps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preferred_receive_wall_is_the_kernel_reading_only_when_it_is_plausible() {
|
||||
let userspace = received();
|
||||
let userspace_wall = userspace.wall_ns;
|
||||
let bound_ns = i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap();
|
||||
|
||||
// No observation at all: the server's own reading, which is the whole of
|
||||
// the pre-existing behavior and what every non-Linux target sees.
|
||||
assert_eq!(preferred_receive_wall_ns(None, userspace), userspace_wall);
|
||||
|
||||
// Earlier, and inside the bound.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(kernel_rx()), userspace),
|
||||
900_000
|
||||
);
|
||||
|
||||
// Equal is accepted: the two clocks may simply have read the same
|
||||
// nanosecond.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// Later than the sample that observed the same datagram is impossible, so
|
||||
// it is metadata this server will not measure against. This is the shape a
|
||||
// backward wall step between the kernel's reading and the core's sample
|
||||
// produces.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall + 1)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// The lag bound is inclusive.
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns)), userspace),
|
||||
userspace_wall - bound_ns
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(userspace_wall - bound_ns - 1)), userspace),
|
||||
userspace_wall
|
||||
);
|
||||
|
||||
// A pre-epoch reading is representable as a negative value and is judged
|
||||
// by exactly the same rule, so against a host clock set anywhere near the
|
||||
// present it simply lags far too much. Nothing here special-cases the sign.
|
||||
let present = sample(1_700_000_000 * SECOND, 10_000);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(UNIX_EPOCH - Duration::from_nanos(1)), present),
|
||||
present.wall_ns
|
||||
);
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(at_epoch_ns(present.wall_ns - bound_ns)), present),
|
||||
present.wall_ns - bound_ns,
|
||||
"and a realistic wall clock accepts a reading at the bound like any other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrepresentable_kernel_reading_falls_back_without_panicking() {
|
||||
// Far outside the wire's signed-nanosecond field in both directions, and
|
||||
// judged against a userspace sample at the field's own limits, so nothing
|
||||
// here can be rescued by a lucky comparison. A conversion that saturated
|
||||
// instead of reporting failure would turn one of these into an ordinary
|
||||
// instant.
|
||||
let beyond = Duration::from_nanos(u64::MAX) + Duration::from_secs(1);
|
||||
|
||||
for userspace_wall in [0, i64::MAX, i64::MIN] {
|
||||
let userspace = sample(userspace_wall, 10_000);
|
||||
for kernel in [UNIX_EPOCH + beyond, UNIX_EPOCH - beyond] {
|
||||
assert_eq!(
|
||||
preferred_receive_wall_ns(Some(kernel), userspace),
|
||||
userspace_wall,
|
||||
"an unrepresentable reading is never a measurement endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_receive_wall_field_can_come_from_the_kernel() {
|
||||
// The whole StampAt x Clock matrix, each asserted as one struct so that a
|
||||
// field appearing where it should not is as loud as a wrong instant. Every
|
||||
// expectation below is what the same session emits with no kernel metadata,
|
||||
// except `recv_wall` under Receive and Both on a wall-reporting clock.
|
||||
for (stamp_at, clock, expected) in [
|
||||
// A: the kernel reading is the reported arrival time.
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// B: and the monotonic half of the same arrival stays the core's own.
|
||||
// The two fields deliberately name different physical instants.
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// C: nothing about the departure side moves, in either domain.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// D: wall only. The interval between these two now includes the
|
||||
// kernel-to-userspace part of the server's residency, which is the one
|
||||
// intended behavior change.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(900_000),
|
||||
send_wall: Some(1_200_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// E: no wall field exists, so there is nothing to improve.
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
recv_mono: Some(10_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// F: a reply reporting only its departure never learns of an arrival
|
||||
// observation.
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Send,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
// G: no timestamp at all.
|
||||
(StampAt::None, Clock::Both, TimestampFields::default()),
|
||||
// H and I: a midpoint is the mean of the core's own paired samples and
|
||||
// stays exactly that. Substituting only the wall arrival endpoint would
|
||||
// pull `midpoint_wall` to 1_050_000 — half the kernel-to-userspace
|
||||
// delay early — improving apparent upstream delay by precisely as much
|
||||
// as it worsened apparent downstream delay, and leaving the two
|
||||
// midpoint fields describing different midpoints.
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Monotonic,
|
||||
TimestampFields {
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(stamp_at, clock, Some(kernel_rx())),
|
||||
expected,
|
||||
"{stamp_at:?} on {clock:?} with a plausible kernel reading"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_datagram_with_no_kernel_reading_emits_what_it_always_did() {
|
||||
// The same matrix without metadata, which is every non-Linux target and
|
||||
// every Linux listener whose kernel offered nothing. `recv_wall` is the
|
||||
// core's own 1_000_000 throughout, and the two paths are otherwise the same
|
||||
// implementation.
|
||||
for (stamp_at, clock, expected) in [
|
||||
(
|
||||
StampAt::Receive,
|
||||
Clock::Wall,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Both,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
StampAt::Midpoint,
|
||||
Clock::Both,
|
||||
TimestampFields {
|
||||
midpoint_wall: Some(1_100_000),
|
||||
midpoint_mono: Some(10_150),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(stamp_at, clock, None),
|
||||
expected,
|
||||
"{stamp_at:?} on {clock:?} without kernel metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_implausible_kernel_reading_falls_back_to_the_servers_own_sample() {
|
||||
// Case A of the wall-step analysis: the wall clock stepped *backwards*
|
||||
// between the kernel's reading and the core's sample, so the kernel value
|
||||
// is later than a sample that observed the same datagram. Case B: it
|
||||
// stepped forwards so far that the reading lags beyond the bound. Neither
|
||||
// is a reason to drop the datagram or to answer differently in any other
|
||||
// respect.
|
||||
let userspace_wall = received().wall_ns;
|
||||
for kernel in [
|
||||
at_epoch_ns(userspace_wall + 1),
|
||||
at_epoch_ns(userspace_wall - i64::try_from(MAX_KERNEL_RX_LAG.as_nanos()).unwrap() - 1),
|
||||
] {
|
||||
assert_eq!(
|
||||
echo_timestamps(StampAt::Both, Clock::Both, Some(kernel)),
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backward_wall_step_after_receive_never_reports_receive_after_send() {
|
||||
// The kernel's reading and the send sample no longer come from one paired
|
||||
// source, so the ordering rule has to reach the reported arrival value on
|
||||
// its own: an accepted kernel reading of 900_000, against a send sample the
|
||||
// wall clock stepped back to 850_000, must still settle on the send
|
||||
// reading.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([
|
||||
open_sample(),
|
||||
sample(1_000_000, 10_000),
|
||||
sample(850_000, 10_300),
|
||||
]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, 0, &negotiated, &[], None),
|
||||
Some(at_epoch_ns(900_000)),
|
||||
)
|
||||
.unwrap()
|
||||
.expect("a backward wall step must not stop the echo being answered");
|
||||
let timestamps = expect_echo_reply(&packet, &negotiated, None).timestamps;
|
||||
|
||||
assert_eq!(
|
||||
timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(850_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(850_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
"the reported arrival wall value is held back to the send reading"
|
||||
);
|
||||
assert!(timestamps.recv_wall <= timestamps.send_wall);
|
||||
// The monotonic pair is ordered by its own source and owes nothing to what
|
||||
// the wall clock did.
|
||||
assert_eq!(timestamps.recv_mono, Some(10_000));
|
||||
assert!(timestamps.recv_mono <= timestamps.send_mono);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_public_core_path_emits_exactly_the_userspace_receive_wall() {
|
||||
// A direct `ServerCore` user has no transport metadata and must see the
|
||||
// behavior it always did, from the same implementation the runtime path
|
||||
// uses.
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
ScriptedClock::new([open_sample(), received(), sent()]),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
let (token, negotiated) = open_negotiated(&mut core, peer(), ¶ms, None);
|
||||
|
||||
let packet = core
|
||||
.handle_datagram(peer(), &echo_request(token, 0, &negotiated, &[], None))
|
||||
.unwrap()
|
||||
.expect("the public path must answer an ordinary echo");
|
||||
|
||||
assert_eq!(
|
||||
expect_echo_reply(&packet, &negotiated, None).timestamps,
|
||||
TimestampFields {
|
||||
recv_wall: Some(1_000_000),
|
||||
recv_mono: Some(10_000),
|
||||
send_wall: Some(1_200_000),
|
||||
send_mono: Some(10_300),
|
||||
..TimestampFields::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// One echo's outcome, in the terms a lifecycle test cares about: whether it was
|
||||
/// answered at all, what it counted, and how much session state the server still
|
||||
/// holds afterwards.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Outcome {
|
||||
answered: bool,
|
||||
closing: bool,
|
||||
recv_count: Option<u32>,
|
||||
recv_window: Option<u64>,
|
||||
session_count: usize,
|
||||
recv_wall: Option<i64>,
|
||||
}
|
||||
|
||||
fn outcome_of(
|
||||
core: &ServerCore,
|
||||
reply: Option<OutboundDatagram>,
|
||||
params: &Params,
|
||||
closing: bool,
|
||||
) -> Outcome {
|
||||
let Some(reply) = reply else {
|
||||
return Outcome {
|
||||
answered: false,
|
||||
closing: false,
|
||||
recv_count: None,
|
||||
recv_window: None,
|
||||
session_count: core.session_count(),
|
||||
recv_wall: None,
|
||||
};
|
||||
};
|
||||
let decoded = if closing {
|
||||
expect_closing_echo_reply(&reply, params, None)
|
||||
} else {
|
||||
expect_echo_reply(&reply, params, None)
|
||||
};
|
||||
Outcome {
|
||||
answered: true,
|
||||
closing,
|
||||
recv_count: decoded.recv_count,
|
||||
recv_window: decoded.recv_window,
|
||||
session_count: core.session_count(),
|
||||
recv_wall: decoded.timestamps.recv_wall,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends one echo as it would arrive at `at_ns`, with the kernel having observed
|
||||
/// it 1 µs earlier when `with_kernel_rx` is set.
|
||||
fn echo_at_with_kernel_rx(
|
||||
core: &mut ServerCore,
|
||||
clock: &ManualClock,
|
||||
at_ns: i64,
|
||||
token: u64,
|
||||
sequence: u32,
|
||||
params: &Params,
|
||||
with_kernel_rx: bool,
|
||||
) -> Option<OutboundDatagram> {
|
||||
if !with_kernel_rx {
|
||||
return echo_at(core, clock, at_ns, token, sequence, params, None);
|
||||
}
|
||||
clock.set(at_ns);
|
||||
core.handle_received_datagram(
|
||||
peer(),
|
||||
&echo_request(token, sequence, params, &[], None),
|
||||
Some(at_epoch_ns(at_ns - 1_000)),
|
||||
)
|
||||
.expect("a rejected or rate-limited echo is not an internal error")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kernel_metadata_changes_no_lifecycle_rate_or_deadline_decision() {
|
||||
// Two identical runs over one scripted arrival schedule, one with a
|
||||
// plausible kernel reading on every echo and one with none. The schedule
|
||||
// crosses a rate-limit burst, a refill, an idle refresh that only a
|
||||
// rate-limited request can have produced, and the maximum-duration
|
||||
// boundary — and the two runs must agree on every one of those outcomes.
|
||||
//
|
||||
// The session reports its receive wall, so the one permitted difference is
|
||||
// visible rather than merely assumed absent.
|
||||
let params = Params {
|
||||
interval_ns: 100 * MS,
|
||||
..echo_params(ReceivedStats::Both, StampAt::Receive, Clock::Wall, 0)
|
||||
};
|
||||
let config = ServerConfig::default()
|
||||
.with_min_send_interval(Duration::from_millis(100))
|
||||
.with_burst_allowance(2)
|
||||
.with_idle_timeout(Duration::from_secs(2))
|
||||
.with_max_test_duration(Duration::from_secs(1));
|
||||
|
||||
let run = |with_kernel_rx: bool| {
|
||||
let (mut core, clock, token, negotiated) = manual_core(
|
||||
config.clone(),
|
||||
¶ms,
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
None,
|
||||
);
|
||||
let mut outcomes = Vec::new();
|
||||
let mut send = |core: &mut ServerCore, at_ns: i64, sequence: u32, closing: bool| {
|
||||
let reply = echo_at_with_kernel_rx(
|
||||
core,
|
||||
&clock,
|
||||
at_ns,
|
||||
token,
|
||||
sequence,
|
||||
&negotiated,
|
||||
with_kernel_rx,
|
||||
);
|
||||
outcomes.push(outcome_of(core, reply, &negotiated, closing));
|
||||
};
|
||||
|
||||
// A burst of three against an allowance of two: the third is
|
||||
// rate-limited, answers nothing and advances no statistic, but does
|
||||
// refresh the idle deadline.
|
||||
send(&mut core, 10 * MS, 0, false);
|
||||
send(&mut core, 10 * MS, 1, false);
|
||||
send(&mut core, 10 * MS, 2, false);
|
||||
// Past the refill, and out of order, so the window transition is
|
||||
// exercised too.
|
||||
send(&mut core, 200 * MS, 4, false);
|
||||
send(&mut core, 400 * MS, 3, false);
|
||||
// The idle timeout is 2 s and the last activity was at 400 ms, so this
|
||||
// request at 2.3 s finds the session alive only because activity — not
|
||||
// the open — is what the deadline runs from.
|
||||
send(&mut core, 2_300 * MS, 5, false);
|
||||
// The maximum-duration deadline is the first served echo plus one
|
||||
// second plus the two-second grace: 10 ms + 3 s. This crosses it and
|
||||
// carries the close, releasing the session.
|
||||
send(&mut core, 3_100 * MS, 6, true);
|
||||
// And every later request is an unknown token.
|
||||
send(&mut core, 3_200 * MS, 7, false);
|
||||
assert_eq!(core.session_count(), 0);
|
||||
outcomes
|
||||
};
|
||||
|
||||
let without = run(false);
|
||||
let with = run(true);
|
||||
|
||||
assert_eq!(
|
||||
without.len(),
|
||||
8,
|
||||
"the schedule must actually have run to the end"
|
||||
);
|
||||
for (index, (without, with)) in without.iter().zip(&with).enumerate() {
|
||||
assert_eq!(
|
||||
(
|
||||
with.answered,
|
||||
with.closing,
|
||||
with.recv_count,
|
||||
with.recv_window,
|
||||
with.session_count
|
||||
),
|
||||
(
|
||||
without.answered,
|
||||
without.closing,
|
||||
without.recv_count,
|
||||
without.recv_window,
|
||||
without.session_count
|
||||
),
|
||||
"echo {index}: kernel metadata may not change any same-host decision"
|
||||
);
|
||||
}
|
||||
|
||||
// The permitted difference, and proof the runs were not simply identical:
|
||||
// every answered reply reported the kernel's reading rather than the
|
||||
// server's own, which is 1 µs later.
|
||||
let reported: Vec<_> = with
|
||||
.iter()
|
||||
.filter_map(|outcome| outcome.recv_wall)
|
||||
.collect();
|
||||
let userspace: Vec<_> = without
|
||||
.iter()
|
||||
.filter_map(|outcome| outcome.recv_wall)
|
||||
.collect();
|
||||
assert_eq!(reported.len(), 6, "six of the eight echoes were answered");
|
||||
assert_eq!(
|
||||
reported,
|
||||
userspace
|
||||
.iter()
|
||||
.map(|wall| wall - 1_000)
|
||||
.collect::<Vec<_>>(),
|
||||
"each answered reply reported the kernel's arrival reading"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_or_close_ignores_kernel_metadata_entirely() {
|
||||
// Neither emits a receive timestamp, and neither may take a different
|
||||
// lifecycle decision for having been observed. The clock script is what
|
||||
// pins that: an open consumes exactly its own lifecycle sample whatever the
|
||||
// transport reported.
|
||||
let clock = ScriptedClock::new([open_sample(), sample(1_000_000, 10_000)]);
|
||||
let mut core = core_with_sources(
|
||||
ServerConfig::default(),
|
||||
ScriptedTokens::new([TOKEN_A]),
|
||||
clock.clone(),
|
||||
);
|
||||
let params = echo_params(ReceivedStats::None, StampAt::Both, Clock::Both, 0);
|
||||
|
||||
let packet = core
|
||||
.handle_received_datagram(peer(), &open_request(¶ms, None), Some(kernel_rx()))
|
||||
.unwrap()
|
||||
.expect("an open must be answered");
|
||||
let reply = expect_normal_open_reply(&packet, None);
|
||||
assert_eq!(
|
||||
clock.remaining(),
|
||||
1,
|
||||
"an open consumes one lifecycle sample"
|
||||
);
|
||||
assert_eq!(core.session_count(), 1);
|
||||
|
||||
let closed: SocketAddr = peer();
|
||||
assert!(core
|
||||
.handle_received_datagram(closed, &close_request(reply.token, None), Some(kernel_rx()))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert_eq!(core.session_count(), 0, "the close released its session");
|
||||
assert_eq!(
|
||||
clock.remaining(),
|
||||
0,
|
||||
"and a close consumes one of its own, whatever the transport observed"
|
||||
);
|
||||
}
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user