Compare commits

...
Author SHA1 Message Date
Nils Andreas Svee d334df883c tests: rework the actually useful tests to live in the integration crate 2026-10-02 13:28:29 +07:00
Nils Andreas Svee 74e7532965 tests: clean up more tests across the project
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 33s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 33s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 22s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 27s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m3s
2026-10-02 12:47:27 +07:00
Nils Andreas Svee dc5b19059e tests(client): cleanup unnecessary tests and hooks
Rust CI / cargo fmt (push) Successful in 14s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 10s
Rust CI / cargo doc (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 32s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 5s
Supply chain / actionlint (push) Successful in 2s
Netem Interop / netem interop (push) Successful in 1m3s
2026-10-02 08:33:49 +07:00
Nils Andreas Svee 50fe83a4c8 docs(irtt-client): document caller-owned cadence
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 31s
Rust CI / cargo clippy (push) Successful in 33s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Failing after 33s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo doc (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 33s
Supply chain / cargo-deny advisories (push) Successful in 9s
Supply chain / cargo-deny bans licenses sources (push) Successful in 7s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m4s
2026-10-02 00:31:17 +07:00
Nils Andreas Svee f2fce2f330 refactor(irtt-client): move cadence into managed runner 2026-10-02 00:31:17 +07:00
renovate Bot f350c82dae chore(deps): maintain cargo lockfile
Rust CI / cargo fmt (push) Successful in 18s
Rust CI / cargo clippy (push) Successful in 32s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 30s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 48s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 24s
Rust CI / cargo doc (push) Successful in 23s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Supply chain / cargo-deny advisories (push) Successful in 6s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m4s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 5m37s
2026-10-01 23:25:15 +07:00
renovate Bot 22d3f6cb27 fix(deps): update rust crate thiserror to 2.0.21 2026-10-01 23:25:15 +07:00
renovate Bot 85238682b8 chore(deps): update github actions to 2892aa5 2026-10-01 23:25:05 +07:00
Nils Andreas Svee 9240152e4b chore(release): irtt-client 0.5.3
Rust CI / cargo fmt (push) Successful in 17s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 34s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 7s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 51s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 23s
Supply chain / cargo-deny advisories (push) Successful in 8s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 29s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m3s
Semver / cargo-semver-checks (push) Failing after 48s
Add socket routing configuration for interface binding, packet marks, and FreeBSD FIB selection.
2026-09-28 14:37:30 +07:00
Nils Andreas Svee 7bbc89e1c0 feat(socket): support advanced routing configuration for client socket
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 35s
Rust CI / cargo clippy (push) Successful in 36s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 51s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 8s
Rust CI / cargo doc (push) Successful in 21s
Supply chain / cargo-deny advisories (push) Successful in 9s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 30s
Supply chain / cargo-deny bans licenses sources (push) Successful in 8s
Supply chain / actionlint (push) Successful in 2s
Netem Interop / netem interop (push) Successful in 1m5s
Enable binding to a specific network interface, applying a firewall mark, or selecting a routing table (FIB) for outgoing packets on supported platforms.
2026-09-28 14:24:21 +07:00
Nils Andreas Svee b6043e4961 refactor(stats): use shared measurement statistics
Rust CI / cargo fmt (push) Successful in 16s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 32s
Rust CI / cargo clippy (push) Successful in 34s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 48s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 21s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 28s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 2s
Netem Interop / netem interop (push) Successful in 1m2s
2026-09-27 00:03:35 +07:00
Nils Andreas Svee b36842ee98 chore(release): publish docs for irtt-proto, irtt-server, irtt-stats
Rust CI / cargo fmt (push) Successful in 13s
Rust CI / cargo clippy (push) Successful in 26s
Rust CI / cargo check (x86_64-unknown-linux-musl) (push) Failing after 12s
Rust CI / cargo check (x86_64-unknown-linux-gnu) (push) Successful in 25s
Rust CI / cargo test (x86_64-unknown-linux-gnu) (push) Successful in 44s
Rust CI / cargo test (x86_64-unknown-linux-musl) (push) Failing after 9s
Rust CI / cargo doc (push) Successful in 26s
Fuzz Smoke / cargo fuzz smoke (push) Failing after 31s
Supply chain / cargo-deny advisories (push) Successful in 7s
Supply chain / cargo-deny bans licenses sources (push) Successful in 6s
Supply chain / actionlint (push) Successful in 3s
Netem Interop / netem interop (push) Successful in 1m2s
Semver / cargo-semver-checks (push) Successful in 54s
Version the remaining public crates independently so their crate-specific READMEs, examples, and API doctests are represented in release history.
2026-09-25 00:32:11 +07:00
125 changed files with 3190 additions and 24408 deletions

No files matched your search

+1 -1
View File
@@ -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
+24
View File
@@ -10,6 +10,12 @@ release. The format is loosely based on
## irtt-proto
### 0.5.1
#### Added
- Added a crate-specific README and runnable API doctests to make the published crate's protocol types and codecs easier to discover and use.
### 0.5.0
#### Added
@@ -28,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
@@ -62,6 +74,12 @@ release. The format is loosely based on
## irtt-server
### 0.5.3
#### Added
- Added a crate-specific README and usage examples for single- and multi-listener servers, plus runnable API doctests.
### 0.5.2
#### Changed
@@ -101,6 +119,12 @@ release. The format is loosely based on
## irtt-stats
### 0.5.1
#### Added
- Added a crate-specific README, a finite-versus-continuous statistics example, and runnable API doctests for the published crate.
### 0.5.0
#### Fixed
Generated
+89 -84
View File
@@ -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,25 @@ 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",
]
@@ -738,13 +736,13 @@ dependencies = [
[[package]]
name = "irtt-proto"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"hmac",
"md-5",
"proptest",
"subtle",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -764,21 +762,22 @@ dependencies = [
[[package]]
name = "irtt-server"
version = "0.5.2"
version = "0.5.3"
dependencies = [
"getrandom 0.4.3",
"irtt-proto",
"nix 0.31.3",
"socket2",
"thiserror 2.0.20",
"thiserror 2.0.21",
"tokio",
]
[[package]]
name = "irtt-stats"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"irtt-client",
"measurement-stats",
"proptest",
]
@@ -805,9 +804,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 +821,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror 2.0.20",
"thiserror 2.0.21",
]
[[package]]
@@ -855,7 +854,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 +886,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 +913,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 +964,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 +977,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 +1123,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 +1133,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 +1143,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 +1156,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 +1259,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 +1372,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 +1382,7 @@ dependencies = [
"palette",
"serde",
"strum",
"thiserror 2.0.20",
"thiserror 2.0.21",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
@@ -1432,7 +1437,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 +1457,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 +1500,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 +1574,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -1616,9 +1621,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 +1633,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 +1710,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 +1738,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 +1774,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
"base64",
"bitflags 2.13.1",
"bitflags 2.13.2",
"fancy-regex",
"filedescriptor",
"finl_unicode",
@@ -1814,11 +1819,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 +1839,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 +1891,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -1909,9 +1914,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 +1949,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 +2000,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 +2013,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 +2023,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 +2160,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",
-145
View File
@@ -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"));
}
}
-192
View File
@@ -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());
}
}
+2 -272
View File
@@ -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());
}
}
-318
View File
@@ -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",
]
);
}
}
-254
View File
@@ -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"));
}
}
-90
View File
@@ -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");
}
}
-391
View File
@@ -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
}
-2
View File
@@ -1,6 +1,4 @@
pub mod args;
#[cfg(test)]
pub(crate) mod env_lock;
pub mod run;
pub use args::ServerArgs;
-102
View File
@@ -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"
);
}
}
-212
View File
@@ -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>"));
}
}
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());
}
}
+89
View File
@@ -0,0 +1,89 @@
#![cfg(all(feature = "client", feature = "server", feature = "tui"))]
#[path = "../../irtt-integration-tests/tests/support/in_tree_server.rs"]
mod in_tree_server;
use irtt_server::ServerConfig;
use std::process::Command;
// The actual binaries own dispatch, argument handling, exit policy and output.
// A real authenticated server also detects broken argument-to-config mapping.
#[test]
fn applets_dispatch_validate_arguments_and_export_a_real_client_run() {
let dispatcher = env!("CARGO_BIN_EXE_irtt-rs");
for binary in [
dispatcher,
env!("CARGO_BIN_EXE_irtt-client"),
env!("CARGO_BIN_EXE_irtt-server"),
env!("CARGO_BIN_EXE_irtt-tui"),
] {
assert!(Command::new(binary)
.arg("--help")
.output()
.unwrap()
.status
.success());
assert!(!Command::new(binary)
.arg("--invalid-option")
.output()
.unwrap()
.status
.success());
}
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 output = 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),
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.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\"")));
}
+2 -1
View File
@@ -127,7 +127,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.
+2 -9
View File
@@ -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]
@@ -42,10 +42,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"] }
+7
View File
@@ -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.
+34 -21
View File
@@ -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}"),
+37 -17
View File
@@ -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}"),
+35 -354
View File
@@ -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;
@@ -1,82 +0,0 @@
//! 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,
thread::{self, JoinHandle},
time::Duration,
};
use irtt_server::{Server, ServerConfig};
use tokio::sync::oneshot;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
pub struct InTreeServer {
pub addr: SocketAddr,
shutdown: Option<oneshot::Sender<()>>,
done: Option<JoinHandle<Result<(), irtt_server::ServerRuntimeError>>>,
}
impl InTreeServer {
pub fn start(config: ServerConfig) -> Self {
let (startup_tx, startup_rx) = mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let done = thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("failed to build in-tree test server runtime");
runtime.block_on(async move {
let bind_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let mut server = Server::bind(bind_addr, config).await?;
startup_tx
.send(server.local_addr()?)
.expect("test stopped waiting for in-tree server startup");
server
.run(async {
let _ = shutdown_rx.await;
})
.await
})
});
let addr = match startup_rx.recv_timeout(STARTUP_TIMEOUT) {
Ok(addr) => addr,
Err(error) => {
let _ = shutdown_tx.send(());
let result = done.join().expect("in-tree test server thread panicked");
panic!("in-tree test server failed before startup ({error}): {result:?}");
}
};
Self {
addr,
shutdown: Some(shutdown_tx),
done: Some(done),
}
}
fn stop_inner(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
if let Some(done) = self.done.take() {
done.join()
.expect("in-tree test server thread panicked")
.expect("in-tree test server stopped with an error");
}
}
}
impl Drop for InTreeServer {
fn drop(&mut self) {
self.stop_inner();
}
}
File diff suppressed because it is too large. Load diff
+81 -423
View File
@@ -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;
-318
View File
@@ -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)
);
}
}
-208
View File
@@ -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, &params, 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, &params, 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, &params, 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, &params, 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,
&params,
&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();
}
-225
View File
@@ -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, &params, 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, &params, None);
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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, &params, Some(&wrong_key));
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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, &params, 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, &params, 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,
&params,
&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, &params, 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, &params, &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(&params, &params, 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"
);
}
-522
View File
@@ -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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, &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, &params));
}
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, &params);
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 { .. })
));
}
}
+17 -1
View File
@@ -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,
+5 -4
View File
@@ -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.
+4 -1
View File
@@ -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
+5 -13
View File
@@ -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() {
+2 -2
View File
@@ -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;
-196
View File
@@ -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");
}
}
+154
View File
@@ -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)
}
+130 -244
View File
@@ -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.
@@ -717,24 +634,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 +644,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 +726,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 +883,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 +1136,25 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Opening { client, open };
false
}
Poll::Ready(Ok(OpenOutcome::Started { event, .. })) => {
Poll::Ready(Ok(OpenOutcome::Started {
event, negotiated, ..
})) => {
let ClientEvent::SessionStarted { at: opened_at, .. } = &event else {
unreachable!("Started carries SessionStarted");
};
match ProbeSchedule::new(opened_at.mono, &negotiated) {
Ok(schedule) => self.targets[index].schedule = Some(schedule),
Err(error) => {
return self.begin_open_session_failure(
index,
client,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Close,
)
}
}
self.publish_client_events(index, vec![event]);
if self.state == DriverState::Stopping
|| self.effective_retirement(index).is_some()
@@ -1340,7 +1251,15 @@ impl ManagedClientTask {
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
return false;
}
if client.is_run_complete() {
if self.targets[index]
.schedule
.as_mut()
.is_some_and(|schedule| {
schedule.permit_probe_at(now);
schedule.is_finished()
})
&& !client.has_pending_probes()
{
self.targets[index].sync_packets_sent(&client);
return self.begin_drain(
index,
@@ -1360,22 +1279,6 @@ impl ManagedClientTask {
mut cleanup_failure,
mut post_deadline_receives_remaining,
} => {
#[cfg(test)]
let defer_work = self.drain_test_hook.defer_work_until_deadline && now < deadline;
#[cfg(not(test))]
let defer_work = false;
if defer_work {
self.targets[index].state = TargetState::Draining {
client,
drain_started_at,
deadline,
primary_end,
cleanup_failure,
post_deadline_receives_remaining,
};
return false;
}
if client
.next_probe_timeout_deadline()
.is_some_and(|timeout| timeout <= now)
@@ -1391,10 +1294,6 @@ impl ManagedClientTask {
return true;
}
#[cfg(test)]
let injected_receive_failure = self.take_drain_receive_failure();
#[cfg(not(test))]
let injected_receive_failure = None;
let mut retained_state_changed = false;
let received = match client.poll_recv(cx) {
Poll::Pending => false,
@@ -1411,13 +1310,7 @@ impl ManagedClientTask {
return self.begin_close(index, client, primary_end, cleanup_failure, now);
}
};
if let Some(error) = injected_receive_failure {
self.targets[index].sync_packets_sent(&client);
cleanup_failure.get_or_insert_with(|| {
classify_client_error(ManagedTargetFailurePhase::Receiving, &error)
});
return self.begin_close(index, client, primary_end, cleanup_failure, now);
}
if client.is_peer_closed() {
self.targets[index].sync_packets_sent(&client);
self.finish_target(index, ManagedTargetEndReason::PeerClosed, cleanup_failure);
@@ -1495,17 +1388,7 @@ impl ManagedClientTask {
Poll::Ready(Ok(events)) => {
self.targets[index].sync_packets_sent(&client);
self.publish_client_events(index, events);
#[cfg(test)]
let injected_close_failure = self.take_drain_close_failure().map(|error| {
classify_client_error(ManagedTargetFailurePhase::Closing, &error)
});
#[cfg(not(test))]
let injected_close_failure = None;
self.finish_target(
index,
primary_end,
cleanup_failure.or(injected_close_failure),
);
self.finish_target(index, primary_end, cleanup_failure);
false
}
Poll::Ready(Err(error)) => {
@@ -1586,26 +1469,6 @@ impl ManagedClientTask {
true
}
#[cfg(test)]
fn take_drain_receive_failure(&mut self) -> Option<ClientError> {
if !mem::take(&mut self.drain_test_hook.fail_receive) {
return None;
}
Some(ClientError::Socket(std::io::Error::other(
"injected drain receive failure",
)))
}
#[cfg(test)]
fn take_drain_close_failure(&mut self) -> Option<ClientError> {
if !mem::take(&mut self.drain_test_hook.fail_close) {
return None;
}
Some(ClientError::Socket(std::io::Error::other(
"injected drain close failure",
)))
}
fn active_count(&self) -> usize {
self.targets
.iter()
@@ -1613,23 +1476,25 @@ impl ManagedClientTask {
.count()
}
fn active_stagger_spacing(&self) -> Option<(usize, Duration)> {
fn active_stagger_spacing(&self) -> Option<Duration> {
let mut active = 0;
let mut minimum: Option<Duration> = None;
for target in &self.targets {
if !target.desired || target.retirement.is_some() {
continue;
}
let TargetState::Active { client } = &target.state else {
let TargetState::Active { .. } = &target.state else {
continue;
};
active += 1;
let interval = client
.probe_interval()
.expect("active managed targets have a committed probe schedule");
let interval = target
.schedule
.as_ref()
.expect("active managed targets have a probe schedule")
.interval();
minimum = minimum.into_iter().chain(Some(interval)).min();
}
minimum.map(|interval| (active, stagger_spacing(interval, active)))
minimum.map(|interval| stagger_spacing(interval, active))
}
fn stagger_target_added(&mut self, now: Instant) {
@@ -1640,7 +1505,7 @@ impl ManagedClientTask {
let candidate = self
.last_stagger_send
.zip(self.active_stagger_spacing())
.and_then(|(last, (_, spacing))| last.checked_add(spacing))
.and_then(|(last, spacing)| last.checked_add(spacing))
.filter(|gate| *gate > now);
self.send_gate = candidate.map(|candidate| existing.min(candidate));
}
@@ -1676,21 +1541,17 @@ impl ManagedClientTask {
fn record_stagger_acceptance(
&mut self,
result: SendResult,
stagger_spacing: Option<(usize, Duration)>,
stagger_spacing: Option<Duration>,
accepted_at: Instant,
) {
if !result.accepted() {
return;
}
let Some((_active, spacing)) = stagger_spacing else {
let Some(spacing) = stagger_spacing else {
return;
};
self.last_stagger_send = Some(accepted_at);
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
#[cfg(test)]
if let Some(observations) = &self.stagger_observations {
observations.lock().unwrap().push((_active, spacing));
}
}
fn poll_one_send(
@@ -1698,7 +1559,7 @@ impl ManagedClientTask {
index: usize,
cx: &mut Context<'_>,
now: Instant,
stagger_spacing: Option<(usize, Duration)>,
stagger_spacing: Option<Duration>,
) -> SendResult {
if !self.targets[index].desired || self.targets[index].retirement.is_some() {
self.targets[index].send_waiting = false;
@@ -1709,6 +1570,9 @@ impl ManagedClientTask {
self.targets[index].state = state;
return SendResult::NotAttempted;
};
// A burst pass may span several slots; validate this target against
// fresh time rather than the pass's earlier scheduling snapshot.
let now = now.max(Instant::now());
if client
.next_probe_timeout_deadline()
.is_some_and(|deadline| deadline <= now)
@@ -1717,7 +1581,21 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted;
}
if client
let schedule = self.targets[index]
.schedule
.as_mut()
.expect("active managed targets have a probe schedule");
if !schedule.permit_probe_at(now) {
self.targets[index].send_waiting = false;
if client.has_pending_probes() {
self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted;
}
self.targets[index].sync_packets_sent(&client);
self.begin_drain(index, client, ManagedTargetEndReason::TestComplete, now);
return SendResult::Ready { accepted: false };
}
if schedule
.next_send_deadline()
.is_none_or(|deadline| deadline > now)
{
@@ -1725,8 +1603,25 @@ impl ManagedClientTask {
self.targets[index].state = TargetState::Active { client };
return SendResult::NotAttempted;
}
let schedule = self.targets[index].schedule.as_ref().unwrap();
let commit =
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
Ok(commit) => commit,
Err(error) => {
self.begin_open_session_failure(
index,
client,
ManagedTargetFailurePhase::Timing,
error,
now,
OpenSessionFailureCleanup::Drain,
);
return SendResult::Failed { accepted: false };
}
};
let scheduled_at = commit.scheduled_at;
let before = client.packets_sent();
let result = client.poll_send_probe(cx);
let mut result = client.poll_send_probe(cx);
let after = client.packets_sent();
let accepted = after > before;
let send_result = match &result {
@@ -1735,11 +1630,38 @@ impl ManagedClientTask {
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
};
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
let schedule_error = accepted
.then(|| client.skip_missed_probe_slots_at(Instant::now()))
.transpose()
.err();
self.targets[index].record_packets_sent(after);
if accepted {
self.targets[index]
.schedule
.as_mut()
.unwrap()
.commit(commit);
if let Poll::Ready(Ok(events)) = &mut result {
for event in events {
if let ClientEvent::EchoSent {
scheduled_at: intended,
sent_at,
timer_error,
..
} = event
{
*intended = Some(scheduled_at);
*timer_error = Some(instant_abs_diff(sent_at.mono, scheduled_at));
}
}
}
}
let schedule_error = if accepted {
self.targets[index]
.schedule
.as_mut()
.unwrap()
.skip_missed_slots_at(Instant::now())
.err()
} else {
None
};
self.targets[index].counters.packets_sent = after;
match result {
Poll::Pending => {
self.targets[index].send_waiting = true;
@@ -1754,7 +1676,7 @@ impl ManagedClientTask {
let TargetState::Active { client } =
mem::replace(&mut self.targets[index].state, TargetState::Terminal)
else {
unreachable!("probe sender remained active while publishing send events");
unreachable!("sender remained active")
};
self.begin_open_session_failure(
index,
@@ -1769,6 +1691,7 @@ impl ManagedClientTask {
SendResult::Ready { accepted }
}
}
Poll::Ready(Err(error)) => {
self.targets[index].send_waiting = false;
self.begin_open_session_failure(
@@ -1863,10 +1786,6 @@ impl ManagedClientTask {
}
let index = self.timeout_cursor;
self.timeout_cursor = (self.timeout_cursor + 1) % target_len;
#[cfg(test)]
{
self.timeout_inspections += 1;
}
if self.target_has_due_timeout(index, now) {
let generation = self.targets[index].instance.generation;
let step = self.poll_target_timeout(index, now);
@@ -1922,20 +1841,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 +1923,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 +1936,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 +2250,7 @@ fn build_task(
}
})?;
runtimes.push(TargetRuntime {
schedule: None,
instance: TargetInstance {
id: target.id.clone(),
generation,
@@ -2438,16 +2340,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 +2347,6 @@ fn build_task(
max_live_target_generations,
status: status_receiver,
events: weak_events,
#[cfg(test)]
update_submission_hook: None,
};
Ok((task, handle))
}
@@ -2497,7 +2387,3 @@ fn close_timeout_failure() -> ManagedTargetFailure {
message: Arc::from("best-effort close did not become writable before its deadline"),
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
File diff suppressed because it is too large. Load diff
-177
View File
@@ -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));
}
}
+14 -305
View File
@@ -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,17 @@ mod tests {
}
}
fn insert(map: &mut PendingMap, probe: PendingProbe) {
map.preflight_insert(probe.wire_seq).unwrap();
map.commit_insert(probe);
}
// Public loss/reply events cannot reveal leaked retention bookkeeping.
#[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",
..
}
));
}
#[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 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));
}
#[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);
}
}
}
-77
View File
@@ -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);
});
}
}
+4 -344
View File
@@ -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());
}
}
+15 -224
View File
@@ -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
)
}
}
+56 -357
View File
@@ -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)
)
}
}
+8
View File
@@ -0,0 +1,8 @@
use irtt_client::*;
use std::{
net::SocketAddr,
time::{Duration, Instant},
};
#[path = "client/mod.rs"]
mod client;
@@ -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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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,
&params,
&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, &params, 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,
&params,
&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(&params);
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, &params, 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,
&params,
&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();
}
@@ -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})"
);
}
}
+76
View File
@@ -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, &params, None);
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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, &params, Some(&wrong_key));
let valid = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, &params, 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();
}
+22
View File
@@ -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;
@@ -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, &params, 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();
+233
View File
@@ -0,0 +1,233 @@
use super::*;
#[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, &params, 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, &params, 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, &params, 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, &params, 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, &params, 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();
}
@@ -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;
@@ -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, &params, 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, &params, &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()
},
@@ -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()
},
@@ -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(&params);
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, &params));
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()
},
@@ -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, &params, &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()
},
@@ -6,7 +6,7 @@ fn poll_timeouts_emits_echo_loss() {
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 +57,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 +119,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()
},
@@ -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()
},
+22
View File
@@ -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 { .. })
));
}
}
@@ -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(
&params,
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_applies_negotiated_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(&params, 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);
}
@@ -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,169 @@
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(5), async {
let server = InTreeServer::start(ServerConfig::default());
for pacing in [ManagedPacing::Burst, ManagedPacing::Staggered] {
let config = ManagedClientConfig {
client: ClientConfig {
duration: Some(Duration::from_millis(200)),
interval: Duration::from_millis(20),
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();
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);
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(20).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);
}
}
})
.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,
..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() {
assert_eq!(outcome.target, first);
assert_eq!(outcome.end_reason, ManagedTargetEndReason::Removed);
assert_eq!(outcome.cleanup_failure, None);
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();
let outcome = run.await.unwrap();
stop.await;
assert_eq!(outcome.end_reason, ManagedEndReason::StopRequested);
assert_eq!(outcome.total_target_outcomes, 2);
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,553 @@
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 accepted_update_before_stop_resolves_stopping() {
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
let receipt = handle
.update_targets(vec![ManagedTargetConfig::new("queued", "127.0.0.1:9")])
.unwrap();
let stop = handle.stop();
let outcome = runtime().block_on(task);
runtime().block_on(stop);
assert_eq!(outcome.applied_command_sequence, 0);
assert!(matches!(
runtime().block_on(receipt),
Err(ManagedCommandApplyError::Stopping)
));
}
#[test]
fn accepted_update_before_driver_failure_resolves_driver_failed() {
let mut managed = config(ManagedPacing::Staggered);
managed.completion = ManagedCompletionPolicy::ExplicitStop;
let (task, handle) = ManagedClient::task(managed, vec![]).unwrap();
let receipt = handle
.update_targets(vec![ManagedTargetConfig::new("queued", "127.0.0.1:9")])
.unwrap();
let mut task = Box::pin(task);
let Poll::Ready(outcome) = poll_once(task.as_mut()) else {
panic!("first poll outside Tokio did not fail")
};
assert_eq!(
outcome.end_reason,
ManagedEndReason::DriverFailed(ManagedDriverFailure::NoTokioRuntime)
);
let mut receipt = Box::pin(receipt);
assert!(matches!(
poll_once(Pin::as_mut(&mut receipt)),
Poll::Ready(Err(ManagedCommandApplyError::DriverFailed(
ManagedDriverFailure::NoTokioRuntime
)))
));
assert!(matches!(
handle.update_targets(vec![]),
Err(ManagedCommandError::DriverClosed)
));
}
#[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)
));
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-proto"
version = "0.5.0"
version = "0.5.1"
description = "IRTT protocol encoding and decoding primitives"
edition.workspace = true
license.workspace = true
@@ -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"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "irtt-server"
version = "0.5.2"
version = "0.5.3"
description = "IRTT-compatible Rust server library"
edition.workspace = true
license.workspace = true
@@ -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
+4 -116
View File
@@ -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);
}
}
+5 -17
View File
@@ -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(),
}
}
+6 -122
View File
@@ -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());
}
}
+1 -2
View File
@@ -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`]
+1 -197
View File
@@ -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
-85
View File
@@ -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.
///
-186
View File
@@ -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));
}
}
-33
View File
@@ -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"
);
}
}
}
-42
View File
@@ -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));
}
-309
View File
@@ -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());
}
-818
View File
@@ -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(&params);
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(), &params, 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(), &params, 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(), &params, 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(), &params, 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(&params);
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"
);
}
}
-385
View File
@@ -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, &params) + 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));
}
-160
View File
@@ -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));
}
-668
View File
@@ -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(), &params, 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(), &params, 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(), &params, 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(),
&params,
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(&params, 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"
);
}
-621
View File
@@ -1,621 +0,0 @@
//! Session lifetime: idle expiry and the maximum-duration close.
//!
//! Time is a hand-moved monotonic clock, so every deadline here is exact and no
//! test sleeps, tolerates or races.
//!
//! Two of the behaviors pinned here are deliberate `irtt-rs` policy rather than
//! observed reference behavior, and each says so where it is tested: expiry is
//! immediate at the configured deadline with no reply of any kind, and the
//! deadline covers a session that has never carried an echo request. The clean
//! specification records the reference server's five-second grace, its final
//! lazy-release reply and its never-expiring unused sessions as upstream policy,
//! states that expiry is entirely a server's own business, and recommends
//! against reproducing the resource leak. The one interoperability constraint is
//! negative — a server must never *signal* expiry — and silence satisfies it.
use std::time::Duration;
use irtt_proto::{echo_header_len, Clock, Params, ReceivedStats, ServerFill, StampAt, FLAG_CLOSE};
use super::support::{
close_request, core_with_sources, echo_at, echo_params, echo_request,
expect_closing_echo_reply, expect_echo_reply, expect_no_test_reply, manual_core,
no_test_request, open_request, other_peer, peer, ManualClock, ScriptedTokens, KEY,
};
use crate::{OutboundDatagram, ServerConfig};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
const MS: i64 = 1_000_000;
const SECOND: i64 = 1_000_000_000;
/// Params reporting both statistics and requesting `interval_ns`.
fn params_at(interval_ns: i64) -> Params {
Params {
interval_ns,
..echo_params(
ReceivedStats::Both,
StampAt::None,
Clock::Unspecified,
/* length */ 0,
)
}
}
/// A configuration that expires after `idle` and rate-limits nothing.
fn idle_after(idle: Duration) -> ServerConfig {
ServerConfig::default()
.with_idle_timeout(idle)
.with_min_send_interval(Duration::ZERO)
}
fn count_of(reply: Option<OutboundDatagram>, params: &Params) -> Option<u32> {
let reply = reply?;
expect_echo_reply(reply, params, None).recv_count
}
#[test]
fn a_session_that_never_carried_an_echo_still_expires() {
// `irtt-rs` policy. The reference server starts the idle clock at the first
// echo request, so a session left unused never expires at all; a session is
// created by one unauthenticated datagram and opens are never deduplicated,
// which makes that an unbounded-state hazard rather than a compatibility
// feature. Here the deadline runs from the open.
//
// A one-session table makes the reclamation externally visible: the second
// open can only succeed if the first session was released before capacity
// was judged.
let config = idle_after(Duration::from_secs(2)).with_max_sessions(1);
let clock = ManualClock::at(0);
let mut core = core_with_sources(
config,
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
clock.clone(),
);
let requested = params_at(SECOND);
clock.set(0);
assert!(core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.is_some());
assert_eq!(core.session_count(), 1);
// Still inside the deadline: the table is full and a second open is refused.
clock.set(2 * SECOND - 1);
assert_eq!(
core.handle_datagram(other_peer(), &open_request(&requested, None))
.unwrap(),
None,
"a live session must still occupy the table"
);
assert_eq!(core.session_count(), 1);
// At the deadline the unused session is reclaimed, before capacity is
// judged, so the new open fits.
clock.set(2 * SECOND);
assert!(
core.handle_datagram(other_peer(), &open_request(&requested, None))
.unwrap()
.is_some(),
"an expired session must not deny capacity to a new one"
);
assert_eq!(core.session_count(), 1, "one replaced the other");
// And the reclaimed token names nothing.
assert!(
echo_at(&mut core, &clock, 2 * SECOND, TOKEN_A, 0, &requested, None).is_none(),
"the released token must be unusable"
);
}
#[test]
fn maintenance_reclaims_an_idle_session_without_traffic() {
let clock = ManualClock::at(0);
let mut core = core_with_sources(
idle_after(Duration::from_secs(2)),
ScriptedTokens::new([TOKEN_A]),
clock.clone(),
);
assert!(core
.handle_datagram(peer(), &open_request(&params_at(SECOND), None))
.unwrap()
.is_some());
assert_eq!(core.session_count(), 1);
clock.set(2 * SECOND);
core.maintain();
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_served_echo_moves_the_idle_deadline_along() {
let (mut core, clock, token, negotiated) = manual_core(
idle_after(Duration::from_secs(2)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
// Inside the deadline the open set, and past the one it would have had.
assert_eq!(
count_of(
echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None),
&negotiated
),
Some(1)
);
assert_eq!(
count_of(
echo_at(&mut core, &clock, 2_900 * MS, token, 1, &negotiated, None),
&negotiated
),
Some(2),
"the echo at 1 s moved the deadline from 2 s to 3 s"
);
// Exactly at the deadline the last served echo set: gone, and silently.
assert!(
echo_at(&mut core, &clock, 4_900 * MS, token, 2, &negotiated, None).is_none(),
"the deadline is inclusive"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn an_expired_session_answers_nothing_at_all() {
// `irtt-rs` policy, and the reason this is its own test. The reference
// server was observed answering the first otherwise-serviceable echo to
// reach an expired session — a final lazy-release reply — and only dropping
// the one after it, with the release boundary a further five seconds past
// the configured timeout. `irtt-rs` reproduces neither: at the deadline the
// session is simply gone, and the client observes silence, which is the only
// thing the specification actually requires of expiry.
let (mut core, clock, token, negotiated) = manual_core(
idle_after(Duration::from_secs(2)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(
count_of(
echo_at(
&mut core,
&clock,
2 * SECOND - 1,
token,
0,
&negotiated,
None
),
&negotiated
),
Some(1),
"one nanosecond short of the deadline is still served"
);
// Two seconds after that echo, to the nanosecond.
assert!(
echo_at(
&mut core,
&clock,
4 * SECOND - 1,
token,
1,
&negotiated,
None
)
.is_none(),
"no final reply: an expired session is simply gone"
);
assert_eq!(core.session_count(), 0);
assert!(
echo_at(&mut core, &clock, 4 * SECOND, token, 2, &negotiated, None).is_none(),
"and stays gone"
);
}
#[test]
fn a_rate_limited_echo_keeps_its_session_alive_without_counting() {
// The one drop class the clean evidence records as refreshing the idle
// deadline. It was established against every other tested class, which does
// not, so `irtt-rs` keeps it.
//
// A 2 s minimum interval against an 8 s idle timeout negotiates 2 s and
// refills every 2 s; a burst of one then makes the echo at 8.5 s — 1.5 s
// after the previous one — rate-limited.
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_secs(2))
.with_idle_timeout(Duration::from_secs(8))
.with_burst_allowance(1);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(2 * SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(negotiated.interval_ns, 2 * SECOND);
// Served, spending the burst. The idle deadline is now 15 s.
assert_eq!(
count_of(
echo_at(&mut core, &clock, 7 * SECOND, token, 0, &negotiated, None),
&negotiated
),
Some(1)
);
// Rate-limited, 1.5 s later: no reply, and the deadline moves to 16.5 s.
assert!(
echo_at(&mut core, &clock, 8_500 * MS, token, 1, &negotiated, None).is_none(),
"no allowance, so no reply"
);
// Past the deadline the served echo alone would have set, and short of the
// one the rate-limited echo set.
assert_eq!(
count_of(
echo_at(&mut core, &clock, 16 * SECOND, token, 2, &negotiated, None),
&negotiated
),
Some(2),
"the rate-limited echo kept the session alive, and was not counted"
);
}
#[test]
fn a_rejected_echo_does_not_move_the_idle_deadline() {
// Two representative classes; the admission rules themselves are pinned by
// the echo and hmac tests, and are not repeated here.
let config = idle_after(Duration::from_secs(2)).with_max_packet_length(64);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
let oversized = echo_request(
token,
0,
&Params {
length: 65,
..negotiated.clone()
},
&[],
None,
);
assert_eq!(oversized.len(), 65);
clock.set(2 * SECOND - 1);
assert_eq!(core.handle_datagram(peer(), &oversized).unwrap(), None);
assert_eq!(
core.handle_datagram(
other_peer(),
&echo_request(token, 0, &negotiated, &[], None)
)
.unwrap(),
None,
"a foreign endpoint is refused whatever the session's state"
);
assert_eq!(core.session_count(), 1, "neither released it early either");
// The deadline the open set, unmoved.
assert!(
echo_at(&mut core, &clock, 2 * SECOND, token, 0, &negotiated, None).is_none(),
"a rejected echo must not have extended the session"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn any_authenticated_request_reclaims_expired_sessions_including_a_no_test_open() {
// `irtt-rs` policy. Expiry is a decision about the table, so it runs before
// a request is dispatched and does not depend on what that request is for.
// The reference server was observed releasing an expired session on a burst
// of ordinary opens but not on no-test ones; a client cannot tell, since
// expiry is never signalled, and the distinction is not worth reproducing.
//
// A no-test open still creates nothing of its own, which the token source
// and the session count both check here.
let tokens = ScriptedTokens::new([TOKEN_A]);
let clock = ManualClock::at(0);
let mut core = core_with_sources(
idle_after(Duration::from_secs(2)),
tokens.clone(),
clock.clone(),
);
let requested = params_at(SECOND);
assert!(core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.is_some());
assert_eq!(core.session_count(), 1);
assert_eq!(tokens.remaining(), 0);
clock.set(2 * SECOND);
let packet = core
.handle_datagram(other_peer(), &no_test_request(&requested, None))
.unwrap()
.expect("a no-test open is answered whatever the table holds");
expect_no_test_reply(&packet, None);
assert_eq!(
core.session_count(),
0,
"the expired session was reclaimed, and no-test created none of its own"
);
}
#[test]
fn a_client_close_still_releases_its_session_without_a_reply() {
// Unchanged by this slice, and worth pinning next to the close the *server*
// sends: a client close is answered with nothing at all.
let (mut core, clock, token, negotiated) = manual_core(
idle_after(Duration::from_secs(60)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert!(echo_at(&mut core, &clock, SECOND, token, 0, &negotiated, None).is_some());
clock.set(2 * SECOND);
assert_eq!(
core.handle_datagram(peer(), &close_request(token, None))
.unwrap(),
None
);
assert_eq!(core.session_count(), 0);
}
/// A configuration whose sessions run for `maximum` and are never idle-expired
/// within a test's timescale.
fn max_duration_of(maximum: Duration) -> ServerConfig {
ServerConfig::default()
.with_max_test_duration(maximum)
.with_idle_timeout(Duration::from_secs(600))
}
#[test]
fn the_maximum_duration_deadline_starts_at_the_first_served_echo() {
// Measured behavior: opening a session well before its first echo does not
// move the deadline, and neither a rejected nor a rate-limited first request
// starts it.
let (mut core, clock, token, negotiated) = manual_core(
max_duration_of(Duration::from_secs(1)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(
negotiated.duration_ns, SECOND,
"the requested duration was reduced to the maximum"
);
// Ten seconds after the open — far past maximum + grace, had the open
// started the clock.
let packet = echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None)
.expect("the first echo must be answered");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(reply.recv_count, Some(1));
// The deadline is 13 s: this echo, three seconds after the open but two
// before the origin's own deadline, is ordinary.
let packet = echo_at(&mut core, &clock, 12_999 * MS, token, 1, &negotiated, None)
.expect("an echo inside the deadline must be answered");
assert_eq!(
expect_echo_reply(&packet, &negotiated, None).recv_count,
Some(2)
);
assert_eq!(core.session_count(), 1);
}
#[test]
fn the_first_echo_past_the_maximum_duration_carries_close_and_ends_the_session() {
// The session negotiates a fill and a payload region, so this covers the
// close's payload as well: a server close is an ordinary echo reply with a
// flag added, and it fills exactly as the session's other replies do.
let requested = Params {
server_fill: Some(ServerFill {
value: "pattern:aabb".to_owned(),
}),
length: (echo_header_len(false, &params_at(SECOND)) + 5) as i64,
..params_at(SECOND)
};
let patterned = vec![0xaa, 0xbb, 0xaa, 0xbb, 0xaa];
let (mut core, clock, token, negotiated) = manual_core(
max_duration_of(Duration::from_secs(1)),
&requested,
ScriptedTokens::new([TOKEN_A]),
None,
);
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
// One nanosecond short of maximum + 2 s grace, measured from that echo.
let packet = echo_at(
&mut core,
&clock,
13 * SECOND - 1,
token,
1,
&negotiated,
None,
)
.expect("short of the deadline must be answered normally");
let reply = expect_echo_reply(&packet, &negotiated, None);
assert_eq!(reply.flags & FLAG_CLOSE, 0, "not yet");
assert_eq!(reply.payload, patterned, "an ordinary reply's fill");
// At the deadline exactly.
let packet = echo_at(&mut core, &clock, 13 * SECOND, token, 2, &negotiated, None)
.expect("the deadline-crossing echo is answered, with Close");
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
assert_eq!(reply.token, token, "an ordinary reply in every other way");
assert_eq!(reply.sequence, 2, "the triggering sequence, copied through");
assert_eq!(
(reply.recv_count, reply.recv_window),
(Some(3), Some(0x7)),
"the triggering request is included in the statistics it reports"
);
assert_eq!(
reply.payload, patterned,
"the close keeps the session's fill; it is not a special packet"
);
assert_eq!(
packet.bytes().len(),
irtt_proto::echo_packet_len(false, &negotiated).unwrap(),
"the negotiated layout, unchanged by the flag"
);
assert_eq!(
i64::from(packet.traffic_class()),
negotiated.dscp,
"a server close is an echo reply and keeps the session's marking"
);
// The token is unusable from that reply onward.
assert_eq!(core.session_count(), 0);
assert!(
echo_at(
&mut core,
&clock,
13 * SECOND + 1,
token,
3,
&negotiated,
None
)
.is_none(),
"every later request is an unknown token"
);
}
#[test]
fn a_rate_limited_echo_at_the_deadline_defers_the_close_rather_than_losing_it() {
// The measured dropped-trigger case, and the reason rate allowance is
// judged before the deadline: a deadline-crossing echo with no allowance is
// silently dropped, the session stays usable, and the next echo that *is*
// served carries the close.
//
// A 5 s minimum interval against a 600 s idle timeout negotiates 5 s and
// refills every 5 s, so the echo three seconds after the first is limited.
let config = max_duration_of(Duration::from_secs(1))
.with_min_send_interval(Duration::from_secs(5))
.with_burst_allowance(1);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(5 * SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert_eq!(negotiated.interval_ns, 5 * SECOND);
assert_eq!(
count_of(
echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None),
&negotiated
),
Some(1),
"the origin, which also spends the burst"
);
assert!(
echo_at(&mut core, &clock, 13 * SECOND, token, 1, &negotiated, None).is_none(),
"past the deadline but with no allowance: dropped, not closed"
);
assert_eq!(core.session_count(), 1, "and the session is still usable");
// Two seconds later the allowance is back.
let packet = echo_at(&mut core, &clock, 15 * SECOND, token, 2, &negotiated, None)
.expect("the next served echo carries the close");
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
assert_eq!(
reply.recv_count,
Some(2),
"the rate-limited request was never counted"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_rejected_echo_past_the_deadline_neither_closes_nor_releases() {
// The close trigger runs only after ordinary admission and rate allowance
// succeed, so a datagram the server would refuse at any time refuses in the
// ordinary way here too. The specification records the corresponding
// upstream behavior as untested for classes other than rate limiting, so
// this is the deterministic `irtt-rs` reading rather than an observed one.
let (mut core, clock, token, negotiated) = manual_core(
max_duration_of(Duration::from_secs(1)),
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
None,
);
assert!(echo_at(&mut core, &clock, 10 * SECOND, token, 0, &negotiated, None).is_some());
clock.set(14 * SECOND);
assert_eq!(
core.handle_datagram(
other_peer(),
&echo_request(token, 1, &negotiated, &[], None)
)
.unwrap(),
None,
"a foreign endpoint gets silence, not a close"
);
assert_eq!(core.session_count(), 1, "and does not release the session");
let packet = echo_at(&mut core, &clock, 14_100 * MS, token, 2, &negotiated, None)
.expect("the owning endpoint still receives the close");
let reply = expect_closing_echo_reply(&packet, &negotiated, None);
assert_eq!(
reply.recv_count,
Some(2),
"the foreign request advanced nothing"
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn an_authenticated_session_closes_through_the_ordinary_reply_path() {
// No special close authentication exists, and none should: the flag rides on
// a reply the normal encoder and key path produced.
let config = max_duration_of(Duration::from_secs(1)).with_hmac_key(KEY);
let (mut core, clock, token, negotiated) = manual_core(
config,
&params_at(SECOND),
ScriptedTokens::new([TOKEN_A]),
Some(KEY),
);
assert!(echo_at(
&mut core,
&clock,
10 * SECOND,
token,
0,
&negotiated,
Some(KEY)
)
.is_some());
let packet = echo_at(
&mut core,
&clock,
13 * SECOND,
token,
1,
&negotiated,
Some(KEY),
)
.expect("the deadline-crossing echo is answered");
// Decoding verifies the MAC, and the flag comparison pins HMAC and Close
// together.
let reply = expect_closing_echo_reply(&packet, &negotiated, Some(KEY));
assert_eq!(reply.recv_count, Some(2));
irtt_proto::verify_packet_hmac(KEY, packet.bytes())
.expect("the close-flagged reply is authenticated");
assert_eq!(core.session_count(), 0);
}
+1 -26
View File
@@ -1,29 +1,4 @@
//! Deterministic tests for the server core.
//!
//! The core takes a source endpoint and datagram bytes and returns reply bytes,
//! so these drive it directly; no socket is bound. Requests are built with the
//! production encoders wherever the wire form is well-formed, and by hand only
//! where the point of the test is a payload a compliant encoder cannot produce.
//!
//! The two nondeterministic parts are injected. Tokens come from a scripted
//! source so session identity, collisions and allocation failure are all
//! assertable; the clock an echo reply's timestamps come from is scripted the
//! same way, so timestamp behavior is pinned exactly, with no sleeps, no
//! runtime and no timing tolerances.
//! Fault paths that cannot reasonably be induced through OS randomness.
mod admission;
mod close;
mod echo;
mod fill;
mod hmac;
mod kernel_rx;
mod lifecycle;
mod negotiation;
mod no_test;
mod open;
mod params;
mod rate;
mod sessions;
mod support;
mod tokens;
mod traffic_class;
-392
View File
@@ -1,392 +0,0 @@
//! Parameter restrictions the server applies to an open request.
//!
//! Every case drives a real open through the core and reads the restriction out
//! of the open reply, because the reply is the contract: a client enforces its
//! own restriction policy against exactly these values, and the server has to
//! honor what it returned.
use std::time::Duration;
use irtt_proto::{Clock, Params, StampAt};
use super::support::{
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
open_request, peer, unthrottled, ScriptedTokens,
};
use crate::{ServerConfig, TimestampAllowance};
const TOKEN_A: u64 = 0x2f45_9a01_c37d_6e88;
/// Negotiates one open against `config` and returns the restricted parameters.
fn negotiated(config: ServerConfig, requested: &Params) -> Params {
let mut core = core_with_tokens(config, ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(requested, None))
.unwrap()
.expect("the open must be answered");
expect_normal_open_reply(&packet, None).params
}
fn requesting(duration_ns: i64, interval_ns: i64) -> Params {
Params {
duration_ns,
interval_ns,
..client_params()
}
}
#[test]
fn a_requested_duration_is_reduced_to_the_configured_maximum() {
let config = || ServerConfig::default().with_max_test_duration(Duration::from_secs(2));
assert_eq!(
negotiated(config(), &requesting(10_000_000_000, 1_000_000_000)).duration_ns,
2_000_000_000,
"a longer duration is reduced to the maximum"
);
assert_eq!(
negotiated(config(), &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
1_000_000_000,
"a duration under the maximum is left alone"
);
// Duration zero is absent from the request, which means continuous. A
// server with a finite maximum is restricting continuous mode to a finite
// test, which is a reduction the client already models.
assert_eq!(
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
2_000_000_000,
"a continuous request takes the maximum"
);
}
#[test]
fn without_a_configured_maximum_a_duration_is_never_restricted() {
let config = || ServerConfig::default();
assert_eq!(
negotiated(config(), &requesting(86_400_000_000_000, 1_000_000_000)).duration_ns,
86_400_000_000_000,
"a day-long test is the operator's business, not negotiation's"
);
assert_eq!(
negotiated(config(), &requesting(0, 1_000_000_000)).duration_ns,
0,
"continuous stays continuous"
);
// A zero maximum is stored as no maximum rather than negotiated: Duration
// zero on the wire means continuous, so a finite maximum of zero could only
// be expressed by turning a client's finite test into an endless one.
let zero = ServerConfig::default().with_max_test_duration(Duration::ZERO);
assert_eq!(zero.max_test_duration(), None);
assert_eq!(
negotiated(zero, &requesting(1_000_000_000, 1_000_000_000)).duration_ns,
1_000_000_000
);
}
#[test]
fn a_requested_interval_is_raised_to_the_configured_minimum() {
let config = || ServerConfig::default().with_min_send_interval(Duration::from_millis(100));
assert_eq!(
negotiated(config(), &requesting(3_000_000_000, 20_000_000)).interval_ns,
100_000_000,
"a faster interval is raised to the minimum"
);
assert_eq!(
negotiated(config(), &requesting(3_000_000_000, 500_000_000)).interval_ns,
500_000_000,
"an interval above the minimum is left alone"
);
// An absent Interval is the wire default zero, not a refusal — an explicit
// zero is rejected during request admission and never reaches negotiation —
// so it is raised like any other value below the floor.
assert_eq!(
negotiated(config(), &requesting(3_000_000_000, 0)).interval_ns,
100_000_000,
"an absent interval takes the minimum"
);
}
#[test]
fn a_negotiated_interval_is_capped_at_a_quarter_of_the_idle_timeout() {
// Without the cap, a client sending at the interval it was handed would
// idle its own session out.
let config = ServerConfig::default().with_idle_timeout(Duration::from_secs(8));
assert_eq!(
negotiated(config, &requesting(3_000_000_000, 5_000_000_000)).interval_ns,
2_000_000_000
);
// A timeout whose quarter is past what the wire can express caps nothing,
// because no representable interval reaches it. The quarter is taken before
// the conversion to nanoseconds for exactly this reason: saturating first
// would put the cap at a quarter of `i64::MAX` and reduce a long interval
// the configured timeout never meant to touch.
let absurd = ServerConfig::default().with_idle_timeout(Duration::MAX);
assert_eq!(
negotiated(absurd, &requesting(3_000_000_000, i64::MAX)).interval_ns,
i64::MAX
);
}
#[test]
fn the_idle_cap_wins_over_a_larger_configured_minimum_interval() {
// The floor is applied first and the cap second, so a minimum above a
// quarter of the idle timeout produces a negotiated interval below that
// minimum. This is the configuration the specification records as an
// upstream hazard: upstream returns 2 s here and still replenishes reply
// allowance every 5 s, so a client obeying the reply is rate-limited.
//
// `irtt-rs` returns the same 2 s — the cap has to win, or the session
// cannot stay alive — and refills at 2 s instead. `rate` proves the
// enforcement half; this pins the value the client is told.
let config = ServerConfig::default()
.with_min_send_interval(Duration::from_secs(5))
.with_idle_timeout(Duration::from_secs(8));
assert_eq!(
negotiated(config, &requesting(3_000_000_000, 10_000_000)).interval_ns,
2_000_000_000
);
}
#[test]
fn a_zero_idle_timeout_caps_nothing() {
// A zero timeout expires a session at the first evaluation, so there is no
// liveness to protect and a quarter of zero must not be read as a cap of
// zero — which would negotiate an interval the client would reject.
let config = ServerConfig::default()
.with_idle_timeout(Duration::ZERO)
.with_min_send_interval(Duration::from_millis(100));
assert_eq!(
negotiated(config, &requesting(3_000_000_000, 3_600_000_000_000)).interval_ns,
3_600_000_000_000
);
}
#[test]
fn the_default_configuration_restricts_neither_capability() {
// Both controls are opt-in, so an existing configuration negotiates exactly
// what it did before they existed.
let config = ServerConfig::default();
assert_eq!(config.timestamp_allowance(), TimestampAllowance::Dual);
assert!(config.dscp_allowed());
assert_eq!(TimestampAllowance::default(), TimestampAllowance::Dual);
let restricted = config
.clone()
.with_timestamp_allowance(TimestampAllowance::Single)
.with_dscp_allowed(false);
assert_eq!(restricted.timestamp_allowance(), TimestampAllowance::Single);
assert!(!restricted.dscp_allowed());
assert_ne!(
restricted, config,
"the policy is part of the configuration"
);
}
#[test]
fn a_requested_stamp_at_is_reduced_to_the_configured_allowance() {
// The whole observed mapping, driven through real opens and read out of the
// reply, because the reply is what a client enforces its own restriction
// policy against. Only one row substitutes rather than removes: a request
// for both instants under a single-timestamp allowance is answered with the
// *midpoint*, which still describes both, rather than with whichever of the
// two the server preferred.
for (requested, allowance, expected) in [
(StampAt::None, TimestampAllowance::Dual, StampAt::None),
(StampAt::None, TimestampAllowance::Single, StampAt::None),
(StampAt::None, TimestampAllowance::None, StampAt::None),
(StampAt::Send, TimestampAllowance::Dual, StampAt::Send),
(StampAt::Send, TimestampAllowance::Single, StampAt::Send),
(StampAt::Send, TimestampAllowance::None, StampAt::None),
(StampAt::Receive, TimestampAllowance::Dual, StampAt::Receive),
(
StampAt::Receive,
TimestampAllowance::Single,
StampAt::Receive,
),
(StampAt::Receive, TimestampAllowance::None, StampAt::None),
(StampAt::Both, TimestampAllowance::Dual, StampAt::Both),
(StampAt::Both, TimestampAllowance::Single, StampAt::Midpoint),
(StampAt::Both, TimestampAllowance::None, StampAt::None),
(
StampAt::Midpoint,
TimestampAllowance::Dual,
StampAt::Midpoint,
),
(
StampAt::Midpoint,
TimestampAllowance::Single,
StampAt::Midpoint,
),
(StampAt::Midpoint, TimestampAllowance::None, StampAt::None),
] {
let params = Params {
stamp_at: requested,
clock: Clock::Both,
..client_params()
};
let negotiated = negotiated(
ServerConfig::default().with_timestamp_allowance(allowance),
&params,
);
assert_eq!(
negotiated.stamp_at, expected,
"{requested:?} under {allowance:?}"
);
assert_eq!(
negotiated.clock,
Clock::Both,
"{requested:?} under {allowance:?}: the allowance restricts placement, not the clock"
);
}
}
#[test]
fn a_timestamp_restriction_never_rewrites_the_requested_clock() {
// The clean evidence describes a timestamp *allowance*, and says nothing
// about restricting clock domains. A server that quietly changed the clock
// too would report a session the client never asked for — and a client
// enforcing strict negotiation would refuse an otherwise serviceable one.
let single = negotiated(
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::Single),
&Params {
stamp_at: StampAt::Both,
clock: Clock::Wall,
..client_params()
},
);
assert_eq!(single.stamp_at, StampAt::Midpoint);
assert_eq!(single.clock, Clock::Wall);
let none = negotiated(
ServerConfig::default().with_timestamp_allowance(TimestampAllowance::None),
&Params {
stamp_at: StampAt::Both,
clock: Clock::Both,
..client_params()
},
);
assert_eq!(none.stamp_at, StampAt::None);
assert_eq!(
none.clock,
Clock::Both,
"the clock tag stays exactly as requested, selecting nothing"
);
}
#[test]
fn an_allowed_dscp_is_negotiated_stored_and_transported_unchanged() {
// The default, and the behavioral compatibility this policy must not
// disturb: the raw byte reaches the reply, the session and the transport.
let requested = Params {
dscp: 0xbb,
..client_params()
};
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let open = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("the open must be answered");
let negotiated = expect_normal_open_reply(&open, None).params;
assert_eq!(negotiated.dscp, 0xbb);
assert_eq!(
core.session(TOKEN_A)
.expect("the session must be live")
.params()
.dscp,
0xbb
);
let reply = core
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
.unwrap()
.expect("an admissible echo must be answered");
expect_echo_reply(&reply, &negotiated, None);
assert_eq!(i64::from(reply.traffic_class()), 0xbb);
}
#[test]
fn a_disallowed_dscp_is_negotiated_to_zero_and_the_session_runs_unmarked() {
// The open is not refused: the server simply will not provide the marking,
// and says so in the value it returns. Nothing downstream needs a second
// record of the policy — the negotiated zero is what the transport reads.
let requested = Params {
dscp: 0xb8,
..client_params()
};
let mut core = core_with_tokens(
unthrottled().with_dscp_allowed(false),
ScriptedTokens::new([TOKEN_A]),
);
let open = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("a disallowed DSCP must not refuse the open");
let negotiated = expect_normal_open_reply(&open, None).params;
assert_eq!(negotiated.dscp, 0, "restricted to zero");
assert_eq!(
core.session(TOKEN_A)
.expect("the session must be live")
.params()
.dscp,
0,
"and stored as the value the session will be run with"
);
let reply = core
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
.unwrap()
.expect("an unmarked session is an ordinary session");
expect_echo_reply(&reply, &negotiated, None);
assert_eq!(reply.traffic_class(), 0);
}
#[test]
fn a_disallowed_dscp_restricts_a_value_no_socket_could_carry_too() {
// Restriction runs during negotiation, before the transport's
// "out-of-range means unmarked" fallback is relevant at all: these values
// never reach the session, so the reply is honest rather than merely
// harmless.
for dscp in [-1, 256, i64::MAX] {
let requested = Params {
dscp,
..client_params()
};
let mut core = core_with_tokens(
unthrottled().with_dscp_allowed(false),
ScriptedTokens::new([TOKEN_A]),
);
let open = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.unwrap_or_else(|| panic!("an open requesting DSCP {dscp} must still be answered"));
let negotiated = expect_normal_open_reply(&open, None).params;
assert_eq!(negotiated.dscp, 0, "requested {dscp}");
assert_eq!(
core.session(TOKEN_A)
.expect("the session must be live")
.params()
.dscp,
0,
"requested {dscp}"
);
let reply = core
.handle_datagram(peer(), &echo_request(TOKEN_A, 0, &negotiated, &[], None))
.unwrap()
.unwrap_or_else(|| panic!("the session must serve echoes after restricting {dscp}"));
expect_echo_reply(&reply, &negotiated, None);
assert_eq!(reply.traffic_class(), 0, "requested {dscp}");
}
}
-258
View File
@@ -1,258 +0,0 @@
use irtt_proto::{verify_packet_hmac, Clock, Params, StampAt};
use super::support::{
client_params, core_with_tokens, expect_no_test_reply, no_test_request, open_request,
open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
};
use crate::{ServerConfig, TimestampAllowance};
const TOKEN_A: u64 = 0x0102_0304_0506_0708;
const TOKEN_B: u64 = 0x1112_1314_1516_1718;
#[test]
fn a_no_test_open_replies_with_a_zero_token_and_creates_nothing() {
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let packet = core
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("a no-test open must be answered");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..client_params()
}
);
assert_eq!(core.session_count(), 0, "no-test creates no session");
assert_eq!(
tokens.remaining(),
1,
"no-test must not draw a session token"
);
}
#[test]
fn a_no_test_open_negotiates_exactly_as_a_normal_open_does() {
let requested = client_params();
let mut normal_core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let normal = normal_core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("normal open must be answered");
let mut no_test_core =
core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let no_test = no_test_core
.handle_datagram(peer(), &no_test_request(&requested, None))
.unwrap()
.expect("no-test open must be answered");
assert_eq!(
expect_no_test_reply(&no_test, None).params,
super::support::decode_reply(&normal, None).params
);
}
#[test]
fn a_no_test_open_is_rejected_on_the_same_parameters_a_normal_open_is() {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let request = {
let mut packet = no_test_request(&client_params(), None);
// Replace the parameter payload with an explicit zero duration.
packet.truncate(4);
packet.extend_from_slice(&param_int(2, 0));
packet
};
assert_eq!(core.handle_datagram(peer(), &request).unwrap(), None);
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_no_test_open_selecting_timestamps_without_a_clock_is_silently_refused() {
// No-test exists to tell a client what the session would be, so it must not
// report a session that could not exist. The effective-parameter check runs
// before the no-test path branches off, so both open forms refuse the same
// incoherent timestamp request.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let requested = Params {
clock: Clock::Unspecified,
stamp_at: StampAt::Both,
..client_params()
};
assert_eq!(
core.handle_datagram(peer(), &no_test_request(&requested, None))
.expect("an unusable timestamp request is not a server error"),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
}
#[test]
fn a_no_test_open_reports_the_reduced_packet_length() {
// No-test answers what the session *would* be, so it has to report the same
// reduced length a session-creating open would enforce — while still
// creating nothing and drawing no token.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default().with_max_packet_length(64),
tokens.clone(),
);
let requested = Params {
length: 1000,
..client_params()
};
let packet = core
.handle_datagram(peer(), &no_test_request(&requested, None))
.unwrap()
.expect("a reducible length must still be answered");
assert_eq!(expect_no_test_reply(&packet, None).params.length, 64);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
}
#[test]
fn a_no_test_open_is_refused_when_its_echo_field_block_exceeds_the_maximum() {
// A maximum below the mandatory echo block describes a session that could
// not exist at all, so no-test must not report one. Both open forms run the
// same effective-parameter check before no-test branches off.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default().with_max_packet_length(0),
tokens.clone(),
);
assert_eq!(
core.handle_datagram(peer(), &no_test_request(&client_params(), None))
.expect("an unservable packet size is not a server error"),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
}
#[test]
fn a_no_test_open_is_still_answered_when_no_session_could_be_created() {
// No-test consumes no capacity, so it must keep working exactly where a
// session-creating open would be refused.
let mut refuses_everything = core_with_tokens(
ServerConfig::default().with_max_sessions(0),
ScriptedTokens::new([TOKEN_A]),
);
assert_eq!(
refuses_everything
.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap(),
None,
"max_sessions 0 refuses a session-creating open"
);
let packet = refuses_everything
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("no-test must be served at max_sessions 0");
expect_no_test_reply(&packet, None);
let mut full = core_with_tokens(
ServerConfig::default().with_max_sessions(1),
ScriptedTokens::new([TOKEN_A, TOKEN_B]),
);
full.handle_datagram(peer(), &open_request(&client_params(), None))
.unwrap()
.expect("the first open fills the table");
assert_eq!(full.session_count(), 1);
let packet = full
.handle_datagram(peer(), &no_test_request(&client_params(), None))
.unwrap()
.expect("no-test must be served with a full session table");
expect_no_test_reply(&packet, None);
assert_eq!(full.session_count(), 1, "no-test disturbed the table");
}
#[test]
fn an_authenticated_no_test_reply_is_authenticated_and_still_creates_nothing() {
let mut core = core_with_tokens(
ServerConfig::default().with_hmac_key(KEY),
ScriptedTokens::new([TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &no_test_request(&client_params(), Some(KEY)))
.unwrap()
.expect("an authenticated no-test open must be answered");
expect_no_test_reply(&packet, Some(KEY));
verify_packet_hmac(KEY, packet.bytes()).expect("the no-test reply must carry a valid MAC");
assert_eq!(core.session_count(), 0);
}
#[test]
fn an_empty_no_test_open_is_answered() {
// No interval floor, so the reply shows the wire defaults an empty payload
// decoded to rather than the timing policy negotiation would apply to them.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let mut request = open_request_with_raw_params(&[], None);
request[3] |= irtt_proto::FLAG_CLOSE;
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.expect("an empty no-test open must be answered");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
assert_eq!(core.session_count(), 0);
}
#[test]
fn a_no_test_open_reports_the_restricted_capabilities() {
// No-test exists to tell a client what the session would be, so it reports
// exactly what the same open would have negotiated under this policy —
// while still creating nothing and drawing no token. Capability policy is
// not skipped merely because no measurement session follows.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default()
.with_timestamp_allowance(TimestampAllowance::Single)
.with_dscp_allowed(false),
tokens.clone(),
);
let requested = Params {
stamp_at: StampAt::Both,
clock: Clock::Both,
dscp: 184,
..client_params()
};
let packet = core
.handle_datagram(peer(), &no_test_request(&requested, None))
.unwrap()
.expect("a no-test open must be answered under a restricting policy");
let reply = expect_no_test_reply(&packet, None);
assert_eq!(reply.params.stamp_at, StampAt::Midpoint);
assert_eq!(reply.params.clock, Clock::Both, "the clock is untouched");
assert_eq!(reply.params.dscp, 0);
assert_eq!(reply.token, 0);
assert_eq!(core.session_count(), 0, "no-test creates no session");
assert_eq!(tokens.remaining(), 1, "no-test must not draw a token");
}
-458
View File
@@ -1,458 +0,0 @@
use irtt_proto::{Clock, Params, ReceivedStats, ServerFill, StampAt, TimestampFields};
use super::support::{
client_params, core_with_tokens, echo_request, expect_echo_reply, expect_normal_open_reply,
open_request, open_request_with_raw_params, param_int, peer, unthrottled, ScriptedTokens, KEY,
};
use crate::{ServerConfig, TimestampAllowance, DEFAULT_MAX_PACKET_LENGTH};
const TOKEN_A: u64 = 0x7896_b6ab_8771_5213;
#[test]
fn an_ordinary_open_is_answered_and_creates_one_session() {
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 ordinary open must be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.token, TOKEN_A);
assert_eq!(core.session_count(), 1);
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(session.params(), &reply.params);
}
#[test]
fn a_session_records_the_exact_source_endpoint() {
// Address family, address, port and — where present — the IPv6 scope all
// form part of session identity, so the endpoint is stored verbatim rather
// than normalized to an address.
for endpoint in [
"198.51.100.7:41234",
"198.51.100.7:41235",
"[2001:db8::1]:2112",
"[fe80::1%3]:2112",
] {
let endpoint = endpoint.parse().unwrap();
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
core.handle_datagram(endpoint, &open_request(&client_params(), None))
.unwrap()
.expect("open must be answered");
assert_eq!(core.session(TOKEN_A).unwrap().peer(), endpoint);
}
}
#[test]
fn any_requested_protocol_version_is_accepted_and_answered_with_one() {
// Version mismatch is detected client-side only: the server rewrites the
// version and creates a normal session whatever was asked for.
for requested in [Some(1), Some(0), Some(2), Some(-1), None] {
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let params = Params {
protocol_version: requested.unwrap_or(0),
..client_params()
};
let request = match requested {
// An explicit zero cannot be encoded by `Params::encode`, which
// omits zero-valued integers, so encode that tag by hand.
Some(0) => open_request_with_raw_params(&param_int(1, 0), None),
Some(_) => open_request(&params, None),
None => open_request(
&Params {
protocol_version: 0,
..params
},
None,
),
};
let packet = core
.handle_datagram(peer(), &request)
.unwrap()
.unwrap_or_else(|| panic!("protocol version {requested:?} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.protocol_version, 1);
assert_eq!(core.session_count(), 1);
}
}
#[test]
fn an_empty_parameter_payload_is_answered_with_only_a_version() {
// The interval floor is disabled here so that the reply carries nothing but
// the version: with one configured, an absent Interval negotiates up to it
// and is encoded, which is interval policy rather than what this test is
// about.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request_with_raw_params(&[], None))
.unwrap()
.expect("an empty parameter payload is a valid open");
// header (4) + token (8) + ProtocolVersion=1 (2). Every other parameter
// negotiates to its wire-default zero and is omitted entirely, which is
// only reachable if absent parameters really did decode as zero.
assert_eq!(packet.bytes().len(), 14);
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
// An unspecified clock is not itself a defect. Only pairing it with a
// selected `stamp_at` is, so a session requesting no timestamps at all is
// fully valid with no Clock tag.
assert_eq!(reply.params.stamp_at, StampAt::None);
assert_eq!(reply.params.clock, Clock::Unspecified);
assert_eq!(core.session_count(), 1);
}
#[test]
fn unknown_parameter_tags_are_ignored_and_not_reflected_in_the_reply() {
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let mut payload = param_int(1, 1);
payload.extend_from_slice(&param_int(42, 7));
payload.extend_from_slice(&param_int(200, 9));
let packet = core
.handle_datagram(peer(), &open_request_with_raw_params(&payload, None))
.unwrap()
.expect("unknown tags must not prevent an open");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..Params::default()
}
);
// The reply is the same 14 bytes an empty payload produces: no unknown tag
// was copied through.
assert_eq!(packet.bytes().len(), 14);
assert_eq!(core.session_count(), 1);
}
#[test]
fn selecting_timestamps_without_a_clock_is_silently_refused() {
// Timestamps from no clock at all describe a session the server could not
// run: the negotiated layout carries no timestamp field to fill. The open
// is discarded rather than repaired — synthesizing a clock would answer
// with a session the client never asked for, and dropping `stamp_at` would
// silently discard the measurement it did ask for.
//
// Only an *absent* Clock tag reaches this: an explicit zero is already out
// of range for the decoder, and a conforming client always sends a clock
// when it selects timestamps.
//
// A server whose timestamp allowance is none accepts the very same request,
// because then no timestamp is negotiated to be unproducible; see
// `a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock`.
for stamp_at in [
StampAt::Send,
StampAt::Receive,
StampAt::Both,
StampAt::Midpoint,
] {
let requested = Params {
clock: Clock::Unspecified,
stamp_at,
..client_params()
};
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(ServerConfig::default(), tokens.clone());
let reply = core
.handle_datagram(peer(), &open_request(&requested, None))
.expect("an unusable timestamp request is not a server error");
assert!(
reply.is_none(),
"{stamp_at:?} without a clock must be silent"
);
assert_eq!(
core.session_count(),
0,
"{stamp_at:?} without a clock must create no session"
);
assert_eq!(
tokens.remaining(),
1,
"{stamp_at:?} without a clock must not consume a token"
);
}
}
#[test]
fn a_timestamp_disallowing_server_accepts_a_request_that_omitted_its_clock() {
// The same request the test above refuses, against a server that provides no
// timestamps at all. The restriction runs before the effective-session
// check, so `stamp_at` is already none by the time executability is judged:
// the absent clock then selects nothing that was going to be reported, and
// the session is one the server can run exactly as negotiated.
//
// This is what the ordering exists for. Validating the *request* instead
// would refuse an open that this policy makes perfectly serviceable.
let requested = Params {
clock: Clock::Unspecified,
stamp_at: StampAt::Both,
..client_params()
};
let mut core = core_with_tokens(
unthrottled().with_timestamp_allowance(TimestampAllowance::None),
ScriptedTokens::new([TOKEN_A]),
);
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("an omitted clock is safe once no timestamps are negotiated");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.stamp_at, StampAt::None);
assert_eq!(
reply.params.clock,
Clock::Unspecified,
"the absent clock tag stays absent"
);
assert_eq!(core.session_count(), 1);
let echo = core
.handle_datagram(
peer(),
&echo_request(reply.token, 0, &reply.params, &[], None),
)
.unwrap()
.expect("the session must serve echoes");
assert_eq!(
expect_echo_reply(&echo, &reply.params, None).timestamps,
TimestampFields::default(),
"no timestamps were negotiated, so the reply carries none"
);
}
#[test]
fn current_non_length_parameters_survive_negotiation_unchanged() {
// Statistics, timestamps and DSCP are what the capability restrictions
// change when they are configured; this is the default configuration, which
// restricts none of them. Version, an oversized packet
// length, the duration maximum and the interval floor and cap are already
// restricted and have their own tests; the length here is deliberately
// under the default maximum, and the configuration below leaves duration
// and interval unrestricted, so this test says nothing about either policy.
//
// The server fill below is a *valid* descriptor, which is what survives
// unchanged. Fill negotiation has its own restriction rule and its own
// tests in `fill`.
//
// This is about what *negotiation* rewrites, which is a separate question
// from whether the core will acknowledge the result: an open whose
// effective parameters are incoherent is still discarded, as
// `selecting_timestamps_without_a_clock_is_silently_refused` shows.
let unrestricted = Params {
protocol_version: 2,
duration_ns: 3_600_000_000_000,
interval_ns: 1,
length: 1472,
received_stats: ReceivedStats::Window,
stamp_at: StampAt::Midpoint,
clock: Clock::Monotonic,
dscp: 184,
server_fill: Some(ServerFill {
value: "pattern:abcd".to_owned(),
}),
};
// No duration maximum is configured by default, and the interval floor is
// disabled, so a one-nanosecond interval is left where it was asked for.
let mut core = core_with_tokens(unthrottled(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&unrestricted, None))
.unwrap()
.expect("open must be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(
reply.params,
Params {
protocol_version: 1,
..unrestricted
}
);
}
#[test]
fn a_positive_length_above_the_configured_maximum_is_reduced_to_it() {
// The reduced value is what the client is told and what the session holds,
// because it is what the server will actually emit. Both have to agree: an
// upstream client treats a reply shorter than the negotiated length as
// fatal, so an honest reduction at open is the only serviceable answer.
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default().with_max_packet_length(64),
tokens.clone(),
);
let requested = Params {
length: 1000,
..client_params()
};
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("a reducible length must still be answered");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.length, 64);
assert_eq!(core.session_count(), 1);
assert_eq!(tokens.remaining(), 0, "one token was consumed");
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(
session.params().length,
64,
"the session must enforce the reduced length"
);
}
#[test]
fn the_default_maximum_reduces_an_absurd_requested_length() {
// The largest length the wire can carry is representable as a `usize` on a
// 64-bit target, so packet sizing would happily have turned it into an
// allocation; the configured maximum is what stops it. Restriction happens
// during negotiation, before any sizing, so this holds on a 32-bit target
// too — the request never reaches a conversion that could fail.
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let requested = Params {
length: i64::MAX,
..client_params()
};
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.expect("an absurd length is reduced, not refused");
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.length, DEFAULT_MAX_PACKET_LENGTH as i64);
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(session.params().length, DEFAULT_MAX_PACKET_LENGTH as i64);
}
#[test]
fn an_open_whose_echo_field_block_exceeds_the_maximum_is_silently_refused() {
// Length itself satisfies the policy — zero asks for nothing beyond the
// mandatory fields — but the received-statistics fields alone make the
// smallest compliant echo reply larger than 20 bytes. Capping the Length
// parameter is therefore not enough on its own: this session could never be
// answered, so it is never acknowledged.
let requested = Params {
protocol_version: 1,
length: 0,
received_stats: ReceivedStats::Both,
stamp_at: StampAt::None,
clock: Clock::Unspecified,
..Params::default()
};
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut core = core_with_tokens(
ServerConfig::default().with_max_packet_length(20),
tokens.clone(),
);
assert_eq!(
core.handle_datagram(peer(), &open_request(&requested, None))
.expect("an unservable packet size is not a server error"),
None
);
assert_eq!(core.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
}
#[test]
fn authentication_counts_toward_the_executable_echo_size() {
// An authentication field adds 16 bytes to the mandatory echo block, so a
// maximum between the two floors admits the same minimal session on an
// unauthenticated server and refuses it on an authenticated one. The sizes
// themselves come from `irtt_proto`; what this pins is that the server asks
// about its own authentication mode rather than assuming the smaller floor.
let minimal = Params {
protocol_version: 1,
..Params::default()
};
let tokens = ScriptedTokens::new([TOKEN_A]);
let mut authenticated = core_with_tokens(
ServerConfig::default()
.with_hmac_key(KEY)
.with_max_packet_length(31),
tokens.clone(),
);
assert_eq!(
authenticated
.handle_datagram(peer(), &open_request(&minimal, Some(KEY)))
.expect("an unservable packet size is not a server error"),
None
);
assert_eq!(authenticated.session_count(), 0);
assert_eq!(tokens.remaining(), 1, "no token may be drawn");
let mut unauthenticated = core_with_tokens(
ServerConfig::default().with_max_packet_length(31),
ScriptedTokens::new([TOKEN_A]),
);
let packet = unauthenticated
.handle_datagram(peer(), &open_request(&minimal, None))
.unwrap()
.expect("the same session fits without an authentication field");
expect_normal_open_reply(&packet, None);
assert_eq!(unauthenticated.session_count(), 1);
}
#[test]
fn negative_and_out_of_range_length_and_dscp_survive_negotiation() {
// These lengths are all at or below the default maximum, and DSCP is
// accepted as decoded, including values a socket could never carry:
// negotiation restricts neither, and a value that is not a byte is simply
// transported unmarked (see `traffic_class`).
//
// A negative length in particular stays negative in both the reply and the
// stored session; the packet-length policy reduces oversized *positive*
// lengths and touches nothing else. It does not need clamping here to be
// executable: echo packet sizing in `irtt-proto` floors it at the required
// field block, so the session this creates can be run as negotiated.
for (length, dscp) in [(-1, -1), (0, 0), (1472, 184), (-4096, 512)] {
let requested = Params {
protocol_version: 1,
length,
dscp,
..Params::default()
};
let mut core = core_with_tokens(ServerConfig::default(), ScriptedTokens::new([TOKEN_A]));
let packet = core
.handle_datagram(peer(), &open_request(&requested, None))
.unwrap()
.unwrap_or_else(|| panic!("length {length} dscp {dscp} must be answered"));
let reply = expect_normal_open_reply(&packet, None);
assert_eq!(reply.params.length, length);
assert_eq!(reply.params.dscp, dscp);
assert_eq!(core.session_count(), 1);
let session = core.session(TOKEN_A).expect("the session must be live");
assert_eq!(
session.params(),
&reply.params,
"the session must hold the negotiated values verbatim"
);
}
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more