Compare commits
552
Commits
v0.2.0
...
b98d1001de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b98d1001de | ||
|
|
8e4424764d | ||
|
|
1e61f4168d | ||
|
|
22cead20dc | ||
|
|
e8de15d60e | ||
|
|
0a928dcf8e | ||
|
|
e13802ccdb | ||
|
|
d81784f0f6 | ||
|
|
b1b0b62848 | ||
|
|
5067ceeb1c | ||
|
|
66abb792ce | ||
|
|
1f7df1ebc9 | ||
|
|
1eed942423 | ||
|
|
0e32c76c78 | ||
|
|
e65d415c7d | ||
|
|
e7de8bdb30 | ||
|
|
b9cc3e2779 | ||
|
|
b7890f1190 | ||
|
|
bc9ea0fe99 | ||
|
|
2a2dd47e38 | ||
|
|
236b80db1d | ||
|
|
2cd8a7d60d | ||
|
|
784ce77354 | ||
|
|
6ebecaec85 | ||
|
|
664ef56b6e | ||
|
|
5cffd2e56a | ||
|
|
c9e539933b | ||
|
|
a6ba335302 | ||
|
|
eebe83986a | ||
|
|
d467211f41 | ||
|
|
f62e6a9953 | ||
|
|
20960e1b8d | ||
|
|
c436bfb686 | ||
|
|
156db4c720 | ||
|
|
9c703ecff2 | ||
|
|
798efaf360 | ||
|
|
4b05c3027b | ||
|
|
596dd20bcf | ||
|
|
ebc1f2d0b1 | ||
|
|
a45396c236 | ||
|
|
743a050416 | ||
|
|
524d56f72c | ||
|
|
ccdf4ea39c | ||
|
|
1d211c81ad | ||
|
|
ded61b58e2 | ||
|
|
bdc5d152d4 | ||
|
|
8986caecb0 | ||
|
|
edff75e55a | ||
|
|
48a50d7f3a | ||
|
|
74e7532965 | ||
|
|
dc5b19059e | ||
|
|
50fe83a4c8 | ||
|
|
f2fce2f330 | ||
|
|
f350c82dae | ||
|
|
22d3f6cb27 | ||
|
|
85238682b8 | ||
|
|
9240152e4b | ||
|
|
7bbc89e1c0 | ||
|
|
b6043e4961 | ||
|
|
b36842ee98 | ||
|
|
cf48988170 | ||
|
|
41aee2a551 | ||
|
|
722539c8d7 | ||
|
|
8369ec3502 | ||
|
|
1df69969db | ||
|
|
4908921fb8 | ||
|
|
0d69255e9e | ||
|
|
d7d6e76682 | ||
|
|
328ff17901 | ||
|
|
19782dd3fd | ||
|
|
b3241958d9 | ||
|
|
8644b70b89 | ||
|
|
196512068b | ||
|
|
e861de5247 | ||
|
|
d335ad78c9 | ||
|
|
f650b6220a | ||
|
|
865c166828 | ||
|
|
bc2bf34ed4 | ||
|
|
4864357486 | ||
|
|
188d6e5354 | ||
|
|
2882beb2c9 | ||
|
|
ed805e854f | ||
|
|
f2e9c72b22 | ||
|
|
4563816873 | ||
|
|
70a2998f8c | ||
|
|
4ddefb2101 | ||
|
|
04291a0b7b | ||
|
|
c426884fd1 | ||
|
|
b9c2d024d9 | ||
|
|
7ee7474605 | ||
|
|
c18903b097 | ||
|
|
b397916ec6 | ||
|
|
9f6ef340f9 | ||
|
|
3a29b4efb2 | ||
|
|
a2f542bb97 | ||
|
|
5dd478e81a | ||
|
|
1ba4f61072 | ||
|
|
31fd76f78d | ||
|
|
344533f8b6 | ||
|
|
e8b3f64292 | ||
|
|
c98452aa2c | ||
|
|
807d8f2846 | ||
|
|
0cc6b1ea80 | ||
|
|
0ccca9b0ab | ||
|
|
df0d69e35d | ||
|
|
9ab1b78637 | ||
|
|
45351e0ba1 | ||
|
|
e2b70ac50a | ||
|
|
95037f7fa0 | ||
|
|
614579b8b6 | ||
|
|
46d7fdfe30 | ||
|
|
8602f73728 | ||
|
|
5d07853893 | ||
|
|
ccb31a59e7 | ||
|
|
56a2a653d0 | ||
|
|
1ec82d8c56 | ||
|
|
e9e1ff4218 | ||
|
|
2521959364 | ||
|
|
1754b77ea5 | ||
|
|
cab1fd1aab | ||
|
|
c9269ca0ee | ||
|
|
98e7231ede | ||
|
|
a0b51032f2 | ||
|
|
13a907bc28 | ||
|
|
1ce65c0db4 | ||
|
|
90178cfb75 | ||
|
|
4928661467 | ||
|
|
65eb494f7a | ||
|
|
08a9bc4e63 | ||
|
|
3c676edb38 | ||
|
|
e936d8147d | ||
|
|
d51e154783 | ||
|
|
02bad76676 | ||
|
|
0e23017100 | ||
|
|
d71503e4fd | ||
|
|
7bea3dd54e | ||
|
|
7129ee0bc5 | ||
|
|
75aaf68fea | ||
|
|
f470d4d7a6 | ||
|
|
2cdc084ef1 | ||
|
|
5c7d75e39e | ||
|
|
6a4dce68b3 | ||
|
|
0d9826800b | ||
|
|
2dee315a6e | ||
|
|
369e166a6c | ||
|
|
6df16e0205 | ||
|
|
7c190058f4 | ||
|
|
3e2cf3bad7 | ||
|
|
a055e80fd3 | ||
|
|
2634649852 | ||
|
|
b30cf9b3a4 | ||
|
|
5ce5f4fcda | ||
|
|
719073df1c | ||
|
|
9b70bd8b9d | ||
|
|
7b7cb6042e | ||
|
|
444ec3dd6a | ||
|
|
3f1ac88989 | ||
|
|
1753e91d5f | ||
|
|
e120cca9bc | ||
|
|
d66b5ed8de | ||
|
|
6722093120 | ||
|
|
046e1f52f1 | ||
|
|
a598dbdc5c | ||
|
|
ca2389174f | ||
|
|
e3815fdeed | ||
|
|
0793a7e1c5 | ||
|
|
c1d75ba4cc | ||
|
|
1d799c4ca0 | ||
|
|
00bba12746 | ||
|
|
a03004b22f | ||
|
|
79d3b9350c | ||
|
|
e4e6d63ef1 | ||
|
|
6d7adf52a7 | ||
|
|
aac5247f01 | ||
|
|
e7b0632239 | ||
|
|
a3896376da | ||
|
|
0ca36f2192 | ||
|
|
1066d5df72 | ||
|
|
1dc18588e7 | ||
|
|
9e3477e617 | ||
|
|
504d290bd2 | ||
|
|
12b4816e14 | ||
|
|
97b094539f | ||
|
|
10e0a38a48 | ||
|
|
d3f93a9370 | ||
|
|
e6491a1f70 | ||
|
|
1b0fc2cabe | ||
|
|
c80edb460b | ||
|
|
c28929bb78 | ||
|
|
277fbfcb57 | ||
|
|
435315a8c0 | ||
|
|
7df85767ad | ||
|
|
ce462fd37b | ||
|
|
71b5819366 | ||
|
|
db9bd515bb | ||
|
|
76dc904da1 | ||
|
|
b93e6d0ab7 | ||
|
|
44026068cf | ||
|
|
0c3acce36a | ||
|
|
006add3648 | ||
|
|
50e4633c7a | ||
|
|
c2666d776e | ||
|
|
ed8d212a17 | ||
|
|
61fdff5c98 | ||
|
|
c1a8d70931 | ||
|
|
17ade8e6cb | ||
|
|
c12617a669 | ||
|
|
e1bdd7920e | ||
|
|
dd68608d56 | ||
|
|
2062535c20 | ||
|
|
665f839333 | ||
|
|
150ee4fd3b | ||
|
|
5506220f67 | ||
|
|
17d3fed7c5 | ||
|
|
e0dec9ac5f | ||
|
|
69b90dd64a | ||
|
|
e3003c348f | ||
|
|
90c04b6569 | ||
|
|
88419d4144 | ||
|
|
25616969c4 | ||
|
|
446028671b | ||
|
|
fca7a2fcc6 | ||
|
|
cbef62be7a | ||
|
|
2171d02203 | ||
|
|
09f0d6e02e | ||
|
|
fabd698187 | ||
|
|
8607720219 | ||
|
|
5be224d1b2 | ||
|
|
8308c077eb | ||
|
|
58c04e8d04 | ||
|
|
a471fb3bfd | ||
|
|
e4239f45c5 | ||
|
|
36500e1ea0 | ||
|
|
24465bc1da | ||
|
|
aed7eef454 | ||
|
|
2d88939ed4 | ||
|
|
e154508cb1 | ||
|
|
3c554a78d7 | ||
|
|
265b857b84 | ||
|
|
047a842f2d | ||
|
|
965369ae2a | ||
|
|
1830429ce5 | ||
|
|
5420387846 | ||
|
|
a4a5219b9e | ||
|
|
68e7d18c12 | ||
|
|
565d7fb163 | ||
|
|
a672f78e86 | ||
|
|
ae23e410bb | ||
|
|
9d7566955d | ||
|
|
750e28200a | ||
|
|
02bdb495a1 | ||
|
|
a50edff624 | ||
|
|
ace3defe22 | ||
|
|
8d240e352d | ||
|
|
41e39bd5b9 | ||
|
|
85c432fcf8 | ||
|
|
4fb5ca6d15 | ||
|
|
d45f607b9c | ||
|
|
c7537b9c42 | ||
|
|
c2994f64a4 | ||
|
|
683b52d334 | ||
|
|
391748f907 | ||
|
|
cce2ff731a | ||
|
|
8abab7ee38 | ||
|
|
5901c52323 | ||
|
|
0e7d363f96 | ||
|
|
964e7d6339 | ||
|
|
3845142d30 | ||
|
|
c833b0663d | ||
|
|
ca2c3dcbd8 | ||
|
|
68ed5c6e2e | ||
|
|
2e2db4d3e0 | ||
|
|
58472dbf7b | ||
|
|
035fb5a385 | ||
|
|
6c34fd3d20 | ||
|
|
e79ea94318 | ||
|
|
d269f77c87 | ||
|
|
be66a6a61d | ||
|
|
b7e206f939 | ||
|
|
59f6e88005 | ||
|
|
93491e7762 | ||
|
|
78e503ffcc | ||
|
|
5a4272e7fd | ||
|
|
c91e7917f7 | ||
|
|
e380924414 | ||
|
|
7ea71f0811 | ||
|
|
db0bc282f3 | ||
|
|
0977db1fe2 | ||
|
|
e0332b4360 | ||
|
|
86c6422f14 | ||
|
|
554d197f14 | ||
|
|
d5c3e21a05 | ||
|
|
3d5a8fae14 | ||
|
|
347af39d74 | ||
|
|
e0e8d3870b | ||
|
|
454bbc2378 | ||
|
|
8a94bc7ca6 | ||
|
|
88dd4f2a62 | ||
|
|
3d197c2353 | ||
|
|
8472745b42 | ||
|
|
373bf32d0f | ||
|
|
3c6c0e396f | ||
|
|
8651e93351 | ||
|
|
17bbf72c6c | ||
|
|
17aeaf0b9b | ||
|
|
3df71e0b69 | ||
|
|
b555451448 | ||
|
|
8924cc531c | ||
|
|
e5c08997ed | ||
|
|
f0f9277469 | ||
|
|
186c6eba97 | ||
|
|
49826a5b3d | ||
|
|
d4cef6c2e7 | ||
|
|
8c31f14419 | ||
|
|
301542186a | ||
|
|
04ea4af079 | ||
|
|
1f69c04274 | ||
|
|
edb634146c | ||
|
|
ab9912b3d5 | ||
|
|
ec7baebe3a | ||
|
|
f65998ff70 | ||
|
|
4632e310d7 | ||
|
|
a67331a297 | ||
|
|
9769a1b49e | ||
|
|
fe3a4dc8e5 | ||
|
|
0bb75b04ef | ||
|
|
24d36d41d8 | ||
|
|
8d47dc9c6e | ||
|
|
dd396a8b5a | ||
|
|
dd1182d24d | ||
|
|
1c6b4be915 | ||
|
|
c8d7c7eda2 | ||
|
|
33fe01f252 | ||
|
|
36eaa90e2a | ||
|
|
c599c84715 | ||
|
|
1a9e8f28aa | ||
|
|
9de95b303c | ||
|
|
f3ea8e030a | ||
|
|
93fed6dce6 | ||
|
|
749306394c | ||
|
|
03ee6e2f28 | ||
|
|
53d29f4cf9 | ||
|
|
de902722e7 | ||
|
|
89ec57d48c | ||
|
|
87b41bb379 | ||
|
|
99b6ddfead | ||
|
|
8445c0cd99 | ||
|
|
1af8afcfae | ||
|
|
1cb6e07706 | ||
|
|
fd3c29e63a | ||
|
|
49b34bf85c | ||
|
|
a24898ada6 | ||
|
|
0f02bf54ec | ||
|
|
b67dcfb425 | ||
|
|
2f7932862a | ||
|
|
b1e32d543c | ||
|
|
62a9c8899e | ||
|
|
a542d4edf1 | ||
|
|
069da5cee9 | ||
|
|
56f54a7b4e | ||
|
|
04e7893e3e | ||
|
|
a2d9b5af81 | ||
|
|
3948e3dec9 | ||
|
|
ef19c45c1f | ||
|
|
357e1356ea | ||
|
|
da731edd0d | ||
|
|
d3a18b0331 | ||
|
|
185e0bf3e4 | ||
|
|
dd74f3800e | ||
|
|
e270183f5d | ||
|
|
ff27ec24c6 | ||
|
|
dad4d2a298 | ||
|
|
983aec5bab | ||
|
|
e642f962d4 | ||
|
|
8de478777d | ||
|
|
9bc2ac90b3 | ||
|
|
b5d0b1e52e | ||
|
|
1a189854e7 | ||
|
|
cabe0f6c24 | ||
|
|
cc7f7fc9b2 | ||
|
|
e908c4ce4f | ||
|
|
6ba76abca0 | ||
|
|
1920d5e33c | ||
|
|
22b8bb80ea | ||
|
|
561e405d18 | ||
|
|
1cb4d01746 | ||
|
|
01787362f5 | ||
|
|
549efa8330 | ||
|
|
4b4adc518c | ||
|
|
dc827908d9 | ||
|
|
dfe0534905 | ||
|
|
7cc55bd619 | ||
|
|
bbe3261eb3 | ||
|
|
dfe9adb36e | ||
|
|
6af4663b6e | ||
|
|
080d45ca67 | ||
|
|
83fb98dbfd | ||
|
|
58d11f1dec | ||
|
|
8ea4c2f8a4 | ||
|
|
6adec06442 | ||
|
|
41cd2a9589 | ||
|
|
5381cfc992 | ||
|
|
8e58c0f04b | ||
|
|
4398d25d4b | ||
|
|
97aac829ec | ||
|
|
3e0047e505 | ||
|
|
1bf44a1746 | ||
|
|
91db85cc63 | ||
|
|
40167ab51a | ||
|
|
7f797644ad | ||
|
|
6828adba0c | ||
|
|
a311b1e0e4 | ||
|
|
2ee97f281a | ||
|
|
b6c88794e6 | ||
|
|
bbb8a6a458 | ||
|
|
c895faa5d1 | ||
|
|
5b4bab79ca | ||
|
|
15abd7c3ad | ||
|
|
c779bf08b8 | ||
|
|
8f39ebe8aa | ||
|
|
68c7f92cb4 | ||
|
|
d2ae68a05f | ||
|
|
f9c6728855 | ||
|
|
b9f5367ed1 | ||
|
|
c483a12173 | ||
|
|
c5ca3d5a93 | ||
|
|
baa2a862f5 | ||
|
|
e987d31f2f | ||
|
|
6602b7496a | ||
|
|
a62c53a77e | ||
|
|
836a8abbeb | ||
|
|
2065fe5e11 | ||
|
|
69fd24ce64 | ||
|
|
a8622ee15a | ||
|
|
5dce8d55ee | ||
|
|
543f0e57c8 | ||
|
|
acffc3cd4e | ||
|
|
ac82537043 | ||
|
|
170f229e7b | ||
|
|
5b40593fda | ||
|
|
b33f01cfe1 | ||
|
|
f1d442d798 | ||
|
|
d05d6a941b | ||
|
|
9c63a03454 | ||
|
|
b02bc53cd8 | ||
|
|
4b408d3ab4 | ||
|
|
f9f7d35793 | ||
|
|
62e0f0cf9f | ||
|
|
193487ae64 | ||
|
|
cfbac69ac6 | ||
|
|
f72f8fda5a | ||
|
|
c7d03c0358 | ||
|
|
97dc1b3f8d | ||
|
|
e33ae1d0fc | ||
|
|
d4976fb811 | ||
|
|
74ceef6a70 | ||
|
|
fe776e6672 | ||
|
|
efaa60cbd4 | ||
|
|
dc65daec75 | ||
|
|
e3b74441a7 | ||
|
|
5595d6e78e | ||
|
|
fe1f1a9c5b | ||
|
|
afe07f0b9a | ||
|
|
352cebb5c3 | ||
|
|
58fd017d4f | ||
|
|
5efb8a1870 | ||
|
|
f41fd4a8e7 | ||
|
|
5de1bf7343 | ||
|
|
7e89d11e6e | ||
|
|
196fc29f76 | ||
|
|
ded8a5f87d | ||
|
|
133ff71b72 | ||
|
|
d7cdcc5cee | ||
|
|
c31ad07d61 | ||
|
|
ece9fd6db0 | ||
|
|
9b0cc2b395 | ||
|
|
b544df7cc9 | ||
|
|
5646365677 | ||
|
|
64be840c1e | ||
|
|
b8b0425c4e | ||
|
|
3e07732934 | ||
|
|
61a242c5d4 | ||
|
|
6bb4e70df3 | ||
|
|
c739764476 | ||
|
|
5c96024c4f | ||
|
|
5d653dac13 | ||
|
|
6f77d38a7f | ||
|
|
01c10e2e0d | ||
|
|
8fd0b803e9 | ||
|
|
614ad5b5cd | ||
|
|
2fcef0adc5 | ||
|
|
c7a0e5d055 | ||
|
|
e117813578 | ||
|
|
3b5336a487 | ||
|
|
0b16ee802d | ||
|
|
64da60d480 | ||
|
|
2d6581f31f | ||
|
|
bec56d4114 | ||
|
|
37de7c96a1 | ||
|
|
6c5972aa58 | ||
|
|
55034fc23c | ||
|
|
bed877760e | ||
|
|
87e1755a4b | ||
|
|
39d414d588 | ||
|
|
00fd0ebb15 | ||
|
|
ddd420cc21 | ||
|
|
a7288cc612 | ||
|
|
fdcdb3b9d7 | ||
|
|
bd9ed7eb88 | ||
|
|
5d471b77fb | ||
|
|
61c527eb26 | ||
|
|
f5c35e8142 | ||
|
|
9adab6882d | ||
|
|
67ef912ba0 | ||
|
|
f2f5728c72 | ||
|
|
c19dac5aef | ||
|
|
480cb4fa92 | ||
|
|
ea85384ced | ||
|
|
aa6c3f4fa6 | ||
|
|
dd1b08c51c | ||
|
|
a180388dc6 | ||
|
|
19e0e7aab9 | ||
|
|
c26008a0b6 | ||
|
|
1469a2d9e8 | ||
|
|
ebc0d46621 | ||
|
|
cc43b43f00 | ||
|
|
04d5b634a2 | ||
|
|
557c946796 | ||
|
|
bda1d83c27 | ||
|
|
820304f6eb | ||
|
|
f211845645 | ||
|
|
851825822d | ||
|
|
969d26d0f1 | ||
|
|
c1b2486046 | ||
|
|
90bb29cd75 | ||
|
|
0dd137a59e | ||
|
|
05f9732760 | ||
|
|
ce4b3c37ab | ||
|
|
f05b7cceb2 | ||
|
|
0a66f6fb49 | ||
|
|
5138dee29f | ||
|
|
542e67bf74 | ||
|
|
197903fb8e | ||
|
|
adfe8e7a6b | ||
|
|
72470bd196 | ||
|
|
fec14b2c7c | ||
|
|
3dfca1ab5e | ||
|
|
cbe91f2fe0 | ||
|
|
a7e10358fd | ||
|
|
4549d95d0a | ||
|
|
1b79c864d4 | ||
|
|
20a1d9dc8e |
No files matched your search
@@ -0,0 +1,163 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
components: rustfmt
|
||||
cache: false
|
||||
|
||||
- name: Check formatting
|
||||
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
|
||||
|
||||
clippy:
|
||||
name: cargo clippy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
components: clippy
|
||||
cache: false
|
||||
|
||||
- name: Run clippy
|
||||
run: |
|
||||
cargo clippy \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--all-features \
|
||||
--locked \
|
||||
-- -D warnings
|
||||
|
||||
- name: Run clippy without default features
|
||||
run: |
|
||||
cargo clippy \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--no-default-features \
|
||||
--locked \
|
||||
-- -D warnings
|
||||
|
||||
verify:
|
||||
name: cargo ${{ matrix.task }} (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: check
|
||||
target: x86_64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
target: x86_64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
target: x86_64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
target: x86_64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install musl toolchain
|
||||
if: matrix.musl
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends musl-tools
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache: false
|
||||
|
||||
- name: Run ${{ matrix.task }} with all features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--all-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
|
||||
- name: Run ${{ matrix.task }} without default features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--no-default-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
|
||||
docs:
|
||||
name: cargo doc
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
RUSTDOCFLAGS: "-D warnings"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Check docs
|
||||
run: |
|
||||
cargo doc \
|
||||
--workspace \
|
||||
--all-features \
|
||||
--no-deps \
|
||||
--locked
|
||||
|
||||
- name: Run doctests
|
||||
run: cargo test --workspace --doc --all-features --locked
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Publish irtt-server image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
# This repo tags each crate independently (irtt-proto/vX, irtt-client/vX,
|
||||
# ...); only the app package's tag corresponds to this image.
|
||||
- "irtt-rs/v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: irtt-server
|
||||
IMAGE_OWNER: lochnair
|
||||
GITEA_REGISTRY: git.lochnair.net
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
# Pinned so a floating "latest" release doesn't silently change the build.
|
||||
CRANE_VERSION: v0.20.2
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Determine image tags
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
version="${GITHUB_REF_NAME#irtt-rs/v}"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${version},latest" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Manual dispatch off a branch: tag by commit only, never touch
|
||||
# "latest", so an ad-hoc build can't clobber the last release.
|
||||
version="sha-$(git rev-parse --short HEAD)"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Cross-compilation is handled inside the Dockerfile via tonistiigi/xx
|
||||
# (native builds, no emulation), but buildx still needs a driver that
|
||||
# can assemble a multi-platform manifest list.
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
# The automatic per-job token comes back unauthorized for package
|
||||
# writes on this instance, so use a PAT (scope: write:package) instead.
|
||||
# (Secret names can't start with GITEA_/GITHUB_, hence REGISTRY_*.)
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Build tag list
|
||||
id: tags
|
||||
run: |
|
||||
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
|
||||
gitea_tags=""
|
||||
for t in "${tag_list[@]}"; do
|
||||
gitea_tags="${gitea_tags}${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t},"
|
||||
done
|
||||
echo "gitea=${gitea_tags%,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Gitea is the primary publish target: this is the only build. Every
|
||||
# other registry gets an exact digest copy of this same image (see
|
||||
# below), never a separate build, so the hash matches everywhere.
|
||||
- name: Build and push to Gitea
|
||||
id: build
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.gitea }}
|
||||
|
||||
- name: Install crane
|
||||
run: |
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C "${HOME}/.local/bin" crane
|
||||
echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Duplicate image to GHCR (same digest, no rebuild)
|
||||
run: |
|
||||
source_ref="${GITEA_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}@${{ steps.build.outputs.digest }}"
|
||||
IFS=',' read -ra tag_list <<< "${{ steps.meta.outputs.tags }}"
|
||||
for t in "${tag_list[@]}"; do
|
||||
crane copy "${source_ref}" "${GHCR_REGISTRY}/${IMAGE_OWNER}/${IMAGE_NAME}:${t}"
|
||||
done
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Fuzz Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Pinned for reproducibility; bump deliberately alongside `fuzz/Cargo.lock`.
|
||||
CARGO_FUZZ_VERSION: 0.13.1
|
||||
CARGO_FUZZ_SHA256: 86edc9b8f20c29ff07c3e5fcd67bc3f0784b3b960a5428635060f20d50555461
|
||||
FUZZ_TOOLCHAIN: nightly-x86_64-unknown-linux-gnu
|
||||
FUZZ_TARGET: x86_64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
fuzz-smoke:
|
||||
name: cargo fuzz smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install stable Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Install nightly Rust toolchain
|
||||
# Installed alongside, but deliberately not made the directory
|
||||
# default: cargo-fuzz itself is built with the default stable
|
||||
# toolchain below, and only `fuzz build`/`fuzz run` are run with
|
||||
# the GNU-host nightly below. Building cargo-fuzz's own dependency
|
||||
# tree under a nightly override can trip build scripts (e.g. rustix) that gate
|
||||
# unstable internal attributes on detecting a nightly compiler.
|
||||
run: rustup toolchain install "${FUZZ_TOOLCHAIN}" --profile minimal --no-self-update
|
||||
|
||||
- name: Install cargo-fuzz
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-fuzz-${CARGO_FUZZ_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/rust-fuzz/cargo-fuzz/releases/download/${CARGO_FUZZ_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_FUZZ_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" cargo-fuzz
|
||||
install -Dm755 cargo-fuzz "${RUNNER_TEMP}/bin/cargo-fuzz"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Build all fuzz targets
|
||||
# libFuzzer sanitizers require dynamically linked GNU binaries; the
|
||||
# runner's default Rust host can be static musl.
|
||||
run: RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz build --target "${FUZZ_TARGET}"
|
||||
working-directory: fuzz
|
||||
|
||||
- name: Run bounded fuzz smoke for every target
|
||||
# A single job building once and running each target sequentially
|
||||
# avoids recompiling the fuzz workspace per target. Ten seconds per
|
||||
# target is enough to catch a broken harness or an obvious
|
||||
# regression; this is not meant to be exhaustive fuzzing.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for target in decode_request decode_open_reply decode_echo_reply decode_params decode_varint server_core_datagrams; do
|
||||
echo "::group::fuzz smoke: ${target}"
|
||||
RUSTUP_TOOLCHAIN="${FUZZ_TOOLCHAIN}" cargo fuzz run --target "${FUZZ_TARGET}" "${target}" -- \
|
||||
-max_total_time=10 \
|
||||
-timeout=5
|
||||
echo "::endgroup::"
|
||||
done
|
||||
working-directory: fuzz
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Netem Interop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
GOBIN: ${{ github.workspace }}/.tools/bin
|
||||
IRTT_UPSTREAM_VERSION: v0.9.1
|
||||
|
||||
jobs:
|
||||
netem:
|
||||
name: netem interop
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
- name: Check host tools and network administration access
|
||||
run: |
|
||||
ip -V
|
||||
tc -V
|
||||
go version
|
||||
sudo -n true
|
||||
|
||||
- name: Install upstream irtt
|
||||
run: |
|
||||
mkdir -p "$GOBIN"
|
||||
go install "github.com/heistp/irtt/cmd/irtt@${IRTT_UPSTREAM_VERSION}"
|
||||
"$GOBIN/irtt" version
|
||||
|
||||
- name: Build irtt-rs client
|
||||
run: cargo build -p irtt-rs --bin irtt-client --locked
|
||||
|
||||
- name: Run netem interop comparisons
|
||||
run: |
|
||||
python3 scripts/run_netem_interop_ci.py \
|
||||
--all \
|
||||
--skip-build \
|
||||
--upstream-irtt "$GOBIN/irtt" \
|
||||
--irtt-rs-command "$PWD/target/debug/irtt-client"
|
||||
|
||||
- name: Append netem interop summary
|
||||
if: always()
|
||||
run: |
|
||||
latest_summary="$(find target/interop-netem -mindepth 2 -maxdepth 2 -name github-step-summary.md -type f -print 2>/dev/null | sort | tail -n 1)"
|
||||
if [ -n "$latest_summary" ]; then
|
||||
cat "$latest_summary" >> "$GITEA_STEP_SUMMARY"
|
||||
else
|
||||
echo "No netem interop github-step-summary.md found." >> "$GITEA_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload netem interop artifacts
|
||||
if: always()
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v7
|
||||
with:
|
||||
name: interop-netem
|
||||
path: target/interop-netem
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Semver
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "irtt-proto/v*.*.*"
|
||||
- "irtt-client/v*.*.*"
|
||||
- "irtt-server/v*.*.*"
|
||||
- "irtt-stats/v*.*.*"
|
||||
- "irtt-rs/v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_SEMVER_CHECKS_VERSION: 0.50.0
|
||||
CARGO_SEMVER_CHECKS_SHA256: 52a65dc88dc53fa8b57d6087954eb52cda149ca03bcfca78ce3fdecd23f893c4
|
||||
|
||||
jobs:
|
||||
semver:
|
||||
name: cargo-semver-checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
cache: false
|
||||
|
||||
# The CLI preserves the previous wrapper's `feature-group: all-features`
|
||||
# behavior without its GitHub API/cache dependency.
|
||||
- name: Install cargo-semver-checks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-semver-checks-x86_64-unknown-linux-gnu.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/obi1kenobi/cargo-semver-checks/releases/download/v${CARGO_SEMVER_CHECKS_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_SEMVER_CHECKS_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" cargo-semver-checks
|
||||
install -Dm755 cargo-semver-checks "${RUNNER_TEMP}/bin/cargo-semver-checks"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Check semver compatibility
|
||||
run: cargo semver-checks check-release --all-features
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Supply chain
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "41 2 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.7
|
||||
CARGO_DENY_VERSION: 0.20.2
|
||||
CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
|
||||
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
|
||||
|
||||
jobs:
|
||||
cargo-deny:
|
||||
name: cargo-deny ${{ matrix.checks }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Advisories can appear after code was merged. Keep this visible,
|
||||
# but don't let a surprise upstream advisory nuke every unrelated PR.
|
||||
- checks: advisories
|
||||
continue-on-error: true
|
||||
|
||||
# These are policy choices in this repo. They should block PRs.
|
||||
- checks: bans licenses sources
|
||||
continue-on-error: false
|
||||
|
||||
continue-on-error: ${{ matrix.continue-on-error }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${CARGO_DENY_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${CARGO_DENY_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" --strip-components=1 \
|
||||
"cargo-deny-${CARGO_DENY_VERSION}-x86_64-unknown-linux-musl/cargo-deny"
|
||||
install -Dm755 cargo-deny "${RUNNER_TEMP}/bin/cargo-deny"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run cargo-deny
|
||||
run: >-
|
||||
cargo deny --manifest-path Cargo.toml --all-features check
|
||||
${{ matrix.checks }}
|
||||
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install actionlint
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
curl --fail --show-error --location --retry 3 \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" \
|
||||
--output "$archive"
|
||||
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --status -
|
||||
tar -xzf "$archive" actionlint
|
||||
install -Dm755 actionlint "${RUNNER_TEMP}/bin/actionlint"
|
||||
echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: actionlint handwritten workflows
|
||||
# actionlint validates GitHub Actions syntax. It cannot parse Gitea's
|
||||
# required absolute action URL in interop-netem.yml, so that workflow
|
||||
# is checked by the Gitea runner instead.
|
||||
run: |
|
||||
actionlint \
|
||||
.gitea/workflows/ci.yml \
|
||||
.gitea/workflows/docker-publish.yml \
|
||||
.gitea/workflows/fuzz.yml \
|
||||
.gitea/workflows/semver.yml \
|
||||
.gitea/workflows/supply-chain.yml \
|
||||
.github/workflows/actions-security.yml \
|
||||
.github/workflows/ci-arm.yml \
|
||||
.github/workflows/osv-scanner.yml \
|
||||
.github/workflows/rust-clippy.yml
|
||||
|
||||
- name: actionlint generated release workflow
|
||||
run: >-
|
||||
actionlint -shellcheck= .github/workflows/irtt-rs-release.yml
|
||||
@@ -16,20 +16,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run actionlint
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
||||
|
||||
zizmor:
|
||||
name: zizmor
|
||||
runs-on: ubuntu-latest
|
||||
@@ -42,11 +28,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Zizmor's SARIF integration publishes GitHub Code Scanning results.
|
||||
# It remains here while portable actionlint runs with Gitea CI, but scans
|
||||
# both workflow directories through its GitHub Actions rule set.
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
with:
|
||||
persona: regular
|
||||
inputs: |
|
||||
.github/workflows
|
||||
.gitea/workflows
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Rust ARM CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: cargo ${{ matrix.task }} (${{ matrix.target }})
|
||||
# Gitea currently has only amd64 Linux runners; retain ARM coverage here.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: check
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: test
|
||||
target: aarch64-unknown-linux-gnu
|
||||
musl: false
|
||||
|
||||
- task: check
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
- task: test
|
||||
target: aarch64-unknown-linux-musl
|
||||
musl: true
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install musl toolchain
|
||||
if: matrix.musl
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends musl-tools
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
cache-key: ${{ matrix.task }}-${{ matrix.target }}
|
||||
|
||||
- name: Run ${{ matrix.task }} with all features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--all-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
|
||||
- name: Run ${{ matrix.task }} without default features
|
||||
run: |
|
||||
cargo ${{ matrix.task }} \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--no-default-features \
|
||||
--target ${{ matrix.target }} \
|
||||
--locked
|
||||
@@ -1,115 +0,0 @@
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
components: rustfmt
|
||||
cache-key: fmt
|
||||
|
||||
- name: Check formatting
|
||||
uses: actions-rust-lang/rustfmt@4066006ec54a31931b9b1fddfd38f2fdf2d27143 # v1
|
||||
|
||||
clippy:
|
||||
name: cargo clippy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
components: clippy
|
||||
cache-key: clippy
|
||||
|
||||
- name: Run clippy
|
||||
run: |
|
||||
cargo clippy \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--all-features \
|
||||
--locked \
|
||||
-- -D warnings
|
||||
|
||||
test:
|
||||
name: cargo test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
cache-key: test
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo test \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--all-features \
|
||||
--locked
|
||||
|
||||
docs:
|
||||
name: cargo doc
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
RUSTDOCFLAGS: "-D warnings"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
cache-key: docs
|
||||
|
||||
- name: Check docs
|
||||
run: |
|
||||
cargo doc \
|
||||
--workspace \
|
||||
--all-features \
|
||||
--no-deps \
|
||||
--locked
|
||||
@@ -42,7 +42,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
- 'irtt-rs**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
jobs:
|
||||
# Run 'dist plan' (or host) to determine what tasks we need to do
|
||||
@@ -64,9 +64,9 @@ jobs:
|
||||
# we specify bash to get pipefail; it guards against the `curl` command
|
||||
# failing. otherwise `sh` won't catch that `curl` returned non-0
|
||||
shell: bash
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
|
||||
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
|
||||
- name: Cache dist
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/dist
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
cat plan-dist-manifest.json
|
||||
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-plan-dist-manifest
|
||||
path: plan-dist-manifest.json
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
run: ${{ matrix.install_dist.run }}
|
||||
# Get the dist-manifest
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
|
||||
path: |
|
||||
@@ -180,14 +180,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
|
||||
- name: Fetch local artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
|
||||
- name: "Upload artifacts"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-build-global
|
||||
path: |
|
||||
@@ -230,14 +230,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Install cached dist
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cargo-dist-cache
|
||||
path: ~/.cargo/bin/
|
||||
- run: chmod +x ~/.cargo/bin/dist
|
||||
# Fetch artifacts from scratch-storage
|
||||
- name: Fetch artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: target/distrib/
|
||||
@@ -250,14 +250,14 @@ jobs:
|
||||
cat dist-manifest.json
|
||||
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: "Upload dist-manifest.json"
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Overwrite the previous copy
|
||||
name: artifacts-dist-manifest
|
||||
path: dist-manifest.json
|
||||
# Create a GitHub Release while uploading all files to it
|
||||
- name: "Download GitHub Artifacts"
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: artifacts
|
||||
@@ -3,8 +3,7 @@
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
|
||||
# in addition to a PR check which fails if new vulnerabilities are introduced.
|
||||
# Scan all dependencies on main and compare newly introduced vulnerabilities on PRs.
|
||||
#
|
||||
# For more examples and options, including how to ignore specific vulnerabilities,
|
||||
# see https://google.github.io/osv-scanner/github-action/
|
||||
@@ -20,6 +19,7 @@ on:
|
||||
- cron: "45 8 * * 1"
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
# Require writing security events to upload SARIF file to security tab
|
||||
@@ -29,21 +29,12 @@ permissions:
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Both pinned workflows default to a recursive scan of ./, including fuzz/.
|
||||
# OSV v2 excludes the root Git repository unless --include-git-root is set;
|
||||
# the old --skip-git flag is no longer supported.
|
||||
scan-scheduled:
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
./
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||
scan-pr:
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
|
||||
with:
|
||||
# Example of specifying custom arguments
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
./
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc" # v2.6.0
|
||||
@@ -24,12 +24,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2
|
||||
with:
|
||||
components: clippy
|
||||
cache-key: clippy-sarif
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
exit 0
|
||||
|
||||
- name: Upload Clippy SARIF
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
|
||||
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: rust-clippy-results.sarif
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Semver
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
semver:
|
||||
name: cargo-semver-checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check semver compatibility
|
||||
uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2
|
||||
with:
|
||||
feature-group: all-features
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Supply chain
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
schedule:
|
||||
- cron: "41 2 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cargo-deny:
|
||||
name: cargo-deny ${{ matrix.checks }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Advisories can appear after code was merged. Keep this visible,
|
||||
# but don't let a surprise upstream advisory nuke every unrelated PR.
|
||||
- checks: advisories
|
||||
continue-on-error: true
|
||||
|
||||
# These are policy choices in this repo. They should block PRs.
|
||||
- checks: bans licenses sources
|
||||
continue-on-error: false
|
||||
|
||||
continue-on-error: ${{ matrix.continue-on-error }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run cargo-deny
|
||||
uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1 # v2
|
||||
with:
|
||||
command: check
|
||||
arguments: --all-features
|
||||
command-arguments: ${{ matrix.checks }}
|
||||
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run actionlint
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2
|
||||
@@ -0,0 +1,206 @@
|
||||
# Repository guidance
|
||||
|
||||
Repository-wide guidance for contributors and coding agents working on `irtt-rs`.
|
||||
Before editing, also check for a more specific `AGENTS.md` in the target subtree.
|
||||
|
||||
## Clean-room boundary
|
||||
|
||||
`irtt-rs` is an independent Rust implementation of an IRTT-compatible protocol stack. It began as a clean-room client reimplementation and now contains project-specific architecture and behavior beyond that original compatibility baseline.
|
||||
|
||||
- Do not inspect, quote, copy, translate, or derive implementation details from upstream `heistp/irtt` source code or upstream tests.
|
||||
- Do not use contaminated notes or source-derived pseudocode as implementation input.
|
||||
- Use the local clean-room specification, black-box verification artifacts, current `irtt-rs` code, and current tests.
|
||||
- Black-box interoperability testing against an installed upstream `irtt` binary is allowed when relevant. Treat it as an external implementation, not source material.
|
||||
- If behavior remains ambiguous, state the ambiguity instead of guessing or consulting upstream implementation code.
|
||||
|
||||
Compatibility and interoperability references:
|
||||
|
||||
- `docs/protocol/IRTT_CLIENT_PROTOCOL_SPEC.md` — verified client-side
|
||||
compatibility baseline.
|
||||
- `docs/protocol/IRTT_SERVER_PROTOCOL_SPEC.md` — verified server-side
|
||||
compatibility baseline.
|
||||
- `docs/protocol/BLACKBOX_VERIFICATION_REPORT.md` — black-box evidence and
|
||||
corrections supporting both the client and server baselines.
|
||||
- `docs/protocol/test-vectors/README.md` — captured client-facing packet
|
||||
vectors.
|
||||
- `docs/protocol/test-vectors/SERVER_BEHAVIORAL_VECTORS.md` — black-box
|
||||
server behavioral vectors, edge cases, and session behavior.
|
||||
- `docs/protocol/captures/` — packet captures referenced by the verification
|
||||
report and behavioral vectors. Behavioral vectors and captures are
|
||||
evidence/artifacts, not architecture prescriptions.
|
||||
- `docs/INTEROP_COMPARISON.md` — procedures for black-box comparison against
|
||||
the upstream `irtt` executable.
|
||||
- `docs/clean-room/CLEANROOM_NOTES.md` — provenance only; not a normative
|
||||
protocol specification.
|
||||
|
||||
The verified client spec predates later `irtt-rs` architecture and project-specific features. Its scope statements, including server implementation being out of scope, describe that document's scope and are not permanent repository restrictions.
|
||||
|
||||
For compatibility questions, the verified spec and black-box observations define the baseline. For `irtt-rs`-specific APIs, runtime architecture, resource bounds, and later features, current code and current project documentation are authoritative.
|
||||
|
||||
## Architecture
|
||||
|
||||
Main boundary:
|
||||
|
||||
> `irtt-proto` — wire semantics shared by client/server. `irtt-client` — client/session behavior. `irtt-server` — server/session behavior and Tokio runtime/orchestration. `irtt-stats` — statistics over client-facing events/results as currently designed. `irtt-rs` (application package, `crates/irtt-app`) — presentation/orchestration.
|
||||
|
||||
- `irtt-proto`: pure protocol encoding/decoding, flags, layouts, params, varints/zigzag, HMAC-MD5, validation. No sockets, runtimes, lifecycle, stats, or presentation.
|
||||
- `irtt-client`: socket/session lifecycle, negotiation, probe send/receive, timing metadata, classification, bounded state, and events.
|
||||
- `Client`: runtime-free blocking/manual adapter.
|
||||
- `AsyncClient`: optional Tokio low-level adapter.
|
||||
- `ManagedClientTask` / `ManagedClientHandle`: unified Tokio managed driver/control surface.
|
||||
- `BlockingManagedClient`: synchronous owner using a dedicated current-thread Tokio runtime.
|
||||
- `irtt-server`: exists; see "Server architecture (`irtt-server`)" below.
|
||||
- `irtt-stats`: cumulative/rolling/finite statistics over client events. No wire/session state machine logic.
|
||||
- `irtt-rs` (application package, `crates/irtt-app`): argument parsing, managed orchestration, output, summaries, TUI, server applet orchestration, diagnostics, and exit behavior. Produces the `irtt-rs` multicall dispatcher plus the dedicated `irtt-client`, `irtt-tui`, and `irtt-server` binaries. Do not duplicate protocol/session state machines here.
|
||||
|
||||
Tokio must remain optional for `irtt-client`; default builds must remain runtime-free and Tokio-free unless an explicit architectural change says otherwise. This rule is specific to `irtt-client` — do not generalize it to `irtt-server`, which has the opposite policy below.
|
||||
|
||||
### Server architecture (`irtt-server`)
|
||||
|
||||
`irtt-server` is a first-class reusable server crate. It contains the deterministic OPEN/ECHO/session core — packet admission, authentication policy, open negotiation, the bounded session table, open reply construction, echo processing with its per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, the maximum-duration server-initiated close, and client-initiated close — plus a reusable Tokio UDP `Server` that runs one sequential `ServerCore` per listener with caller-controlled shutdown and scheduled idle maintenance. Logical expiry remains exact on authenticated, structurally valid requests, while the runtime also reclaims expired sessions once per second when no traffic arrives. Each reply carries the raw traffic class it must be sent with, which the runtime applies to the listener before every send. A wildcard listener additionally recovers each request's local destination from packet metadata and sends that request's reply from it, on Linux, macOS and FreeBSD; a wildcard bind on a target without that path is refused at construction rather than served from a routing-table source address. Explicit-address listeners are unaffected everywhere. Echo replies fill their payload from the session's negotiated server fill; the policy and its deliberate divergences are in `crates/irtt-server/AGENTS.md`. Two optional negotiation policies restrict what a session may ask the server to provide — a timestamp allowance and DSCP permission — both off by default and both settled during open negotiation rather than in the runtime. Above that one-listener primitive sits `ServerSet`, the service-level owner of one or more independent `Server`s: it binds them all or none, runs each in its own Tokio task, fans one external shutdown out to them, joins them all, and fails the group if any listener fails or stops early. Ordinary process-level polish and observability decisions are what remains of the server architecture.
|
||||
|
||||
- It shares wire semantics with the client through `irtt-proto` and follows the same clean-room boundary.
|
||||
- It must **not** depend on `irtt-client` merely to reuse protocol behavior.
|
||||
- Unlike `irtt-client`, the server has no product requirement to remain runtime-free: `irtt-server` is intentionally **Tokio-native**, and Tokio does not need to be optional for it.
|
||||
- There is currently no requirement for a blocking server API, alternate async runtimes, a runtime abstraction layer, or a runtime-free server feature. Do not build blocking/runtime variants merely for symmetry with the client, and do not invent a mirrored client-style API family (`BlockingServer`, `AsyncServer`, `ManagedServer`, `BlockingManagedServer`, or similar) ahead of agreement.
|
||||
- Protocol/session logic should be separated from socket/runtime orchestration where that materially improves deterministic testing, ownership clarity, state-machine reasoning, or maintainability. A `ServerCore`-like internal boundary is therefore desirable, but its exact name/API is not prescribed, it need not be public, it is not a promise of runtime independence, and simple code should not be tortured merely to make the core artificially pure.
|
||||
- Runtime/server orchestration belongs around that core and may freely use Tokio for UDP sockets, `recv_from`/`send_to`, IPv4/IPv6 listeners, timers, session expiry, max-duration deadlines, shutdown, bounded control/config channels where useful, and `select!`-style orchestration.
|
||||
- Ownership is layered and the layers do not blur: `Server` is the one-listener primitive (one socket, one `ServerCore`, one sequential loop), and `ServerSet` supervises one or more of them without owning protocol or session state itself. A common `ServerConfig` is cloned into each listener, so its bounds — `max_sessions` included — are per listener and not per process. Listeners share nothing else: no shared `ServerCore`, no global session table or token namespace, no shared clock, and one sequential sender per socket, which is what keeps the socket-wide reply traffic class race-free.
|
||||
- The server applet exists behind the `irtt-rs` application package's `server` feature, which is part of that crate's default build. It is thin orchestration/configuration over reusable `irtt-server` — one current-thread Tokio runtime, one repeatable `--bind`, one `ServerSet` — and must not grow a second server state machine. **It always uses `ServerSet`, including for a single bind**: there is no direct CLI-to-`Server` path and no bind-count branch, so startup, shutdown and listener-failure semantics are identical whatever was configured, and the singleton case exercises the same orchestration in normal use. Hostname resolution, interface expansion, daemonization, and configuration files are deliberately absent.
|
||||
- Resource policy: the server should use bounded session/resource policy. Upstream's observed lack of useful default session/per-peer bounds, and its never expiring a session that has not carried an echo request (see `IRTT_SERVER_PROTOCOL_SPEC.md`), are not interoperability requirements and must not be reproduced merely for compatibility.
|
||||
- Testing: prefer `irtt-server` for normal compliant fake-peer behavior where practical, as far as its implemented behavior reaches. Keep narrow adversarial/raw peers for intentionally malformed, ambiguous, or non-compliant wire behavior.
|
||||
|
||||
## Engineering rules
|
||||
|
||||
- Make the smallest coherent change that solves the requested problem.
|
||||
- Avoid unrelated cleanup and speculative abstractions.
|
||||
- Preserve public API and wire behavior unless the task explicitly changes them.
|
||||
- Treat resource bounds, wake behavior, blocking, cancellation, cleanup, and lifecycle termination as correctness concerns in long-running code.
|
||||
- Keep fallible preparation before irreversible side effects where transaction semantics require it.
|
||||
- Do not suppress compiler or Clippy warnings merely to make checks green.
|
||||
- If an apparent defect may encode an intentional design decision, inspect surrounding code/docs/history and ask rather than silently redesigning it.
|
||||
|
||||
## Testing policy
|
||||
|
||||
Tests protect durable behavior, compatibility, and important invariants. They do not exist to maximize test count or coverage percentage. Tests are maintained repository code: every persistent test adds review, CI, refactoring, and maintenance cost, so it must justify that cost.
|
||||
|
||||
### Test at the owning layer
|
||||
|
||||
Prefer production interfaces and component/integration tests at the lowest architectural layer that owns the complete behavior. Higher-level or process-level is not inherently better.
|
||||
|
||||
- `irtt-proto`: wire formats, codecs, validation, malformed input, and protocol boundaries.
|
||||
- `irtt-client`: client/session behavior, multi-target orchestration, burst/staggered pacing, scheduling, lifecycle, managed events, and target updates.
|
||||
- `irtt-server`: server/session behavior and server runtime/orchestration.
|
||||
- `irtt-stats`: statistics, loss/IPDV accounting, rolling windows, and retention bounds.
|
||||
- `irtt-rs` / `irtt-app`: behavior introduced by the application layer itself, such as argument-to-config mapping, presentation/formatting, app-specific diagnostics/exit policy, and genuine process/signal behavior.
|
||||
|
||||
Do not move library behavior into application-binary tests merely to make it more end-to-end. For example, pacing and multi-target scheduling belong in `irtt-client` even though the CLI exposes them.
|
||||
|
||||
Concurrency correctness is a production requirement, but deterministic coverage
|
||||
does not by itself justify a test-only synchronization hook. Prefer simplifying
|
||||
the concurrency machinery or observing receipts/status/events through the public
|
||||
control surface before introducing or restoring internal race gates.
|
||||
|
||||
### Every persistent test must earn its place
|
||||
|
||||
Before adding or retaining a test, be able to state the durable behavior, compatibility guarantee, regression, or invariant it protects. Other agents and humans will review tests on that basis. Do not add persistent tests merely because code changed, a branch exists, coverage would increase, or the test was useful during implementation.
|
||||
|
||||
One strong behavioral test through a normal production interface is usually better than several tests of private helpers or reconstructed internals. Be especially skeptical of unit tests that depend on private functions, methods, structs, fields, or implementation sequence: harmless internal refactors should not require test rewrites.
|
||||
|
||||
Avoid tests that primarily:
|
||||
|
||||
- assert private representation or historical implementation details;
|
||||
- mirror implementation steps or exercise branches solely for coverage;
|
||||
- duplicate stronger coverage at another layer;
|
||||
- pin exact human-readable wording unless that wording is itself contractual;
|
||||
- test standard-library/dependency behavior;
|
||||
- use subprocess machinery or test-only observability when the owning crate exposes the behavior directly.
|
||||
|
||||
If a component/integration test through the owning crate's normal API protects the same durable behavior, prefer it unless a focused unit test materially improves precision, diagnosis, or coverage of a hard-to-observe invariant.
|
||||
|
||||
### Unit tests where they earn it
|
||||
|
||||
Focused unit/table-driven tests are appropriate for stable pure or algorithmic behavior, especially protocol codecs/vectors, statistics formulas, hand-rolled data structures, and resource/liveness invariants that cannot be observed reliably from a higher-level production interface.
|
||||
|
||||
### Temporary implementation tests
|
||||
|
||||
Temporary narrow tests are welcome while implementing/debugging a change to prove a hypothesis, reproduce a bug, or exercise a difficult state transition. Remove or consolidate them before finalizing unless they independently protect a durable regression or invariant.
|
||||
|
||||
### Resource/liveness tests and hooks
|
||||
|
||||
Direct low-level tests and test-only counters are justified when the invariant is otherwise not observable, e.g. bounded work per poll, no starvation/eventual progress, avoiding an otherwise O(n) hot-path operation, or exact cancellation/stop/update linearization.
|
||||
|
||||
Assert the invariant rather than the current mechanism used to achieve it. Prefer “bounded work and eventual progress” over a specific private cursor value when another fair implementation would also be valid.
|
||||
|
||||
`#[cfg(test)]` fault injection is acceptable for conditions that cannot be produced reliably through normal interfaces, such as socket-option failures, short sends, false readiness, exact cancellation points, sequence wrap, or race linearization. Do not add test-only production hooks solely for convenient observability.
|
||||
|
||||
### Fake peers and interoperability
|
||||
|
||||
Use production protocol encoders/decoders for compliant fake peers. Do not maintain shadow packet encoders in tests unless intentionally constructing malformed wire data.
|
||||
|
||||
Prefer a real `irtt-server` for normal compliant behavior once available; keep small adversarial peers for behavior a compliant server should not produce.
|
||||
|
||||
Cross-implementation tests are especially valuable because two `irtt-rs` endpoints can otherwise agree on the same mistake.
|
||||
|
||||
When compatibility is in question, prefer black-box interoperability testing
|
||||
against an upstream `irtt` executable where practical. Follow
|
||||
`docs/INTEROP_COMPARISON.md` and treat upstream only as an external executable;
|
||||
do not inspect its source or tests.
|
||||
|
||||
### Synchronization and timing
|
||||
|
||||
Prefer synchronization on actual events/state over sleeps or tiny wall-clock windows.
|
||||
|
||||
- Use channels, condvars, packet gates, or equivalent deterministic signals with bounded failure timeouts.
|
||||
- Sleeps are fine when oversleeping cannot invalidate correctness; do not sleep merely hoping another thread/process finished work.
|
||||
- Avoid wall-clock assertions that include process spawn or unrelated setup time.
|
||||
- Condition-wait loops must be bounded and fail with a useful diagnostic rather than hang.
|
||||
|
||||
When a test fails after a legitimate implementation change, determine whether production regressed or the harness encoded an invalid assumption. Do not change correct production behavior merely to satisfy a brittle test.
|
||||
|
||||
## Workflow and verification
|
||||
|
||||
For non-trivial work:
|
||||
|
||||
1. Inspect relevant code, tests, and local protocol/design references.
|
||||
2. Identify the externally meaningful contract or internal invariant.
|
||||
3. Make a short plan for multi-file or subtle lifecycle/state changes.
|
||||
4. Implement the smallest coherent patch.
|
||||
5. Run focused verification while iterating.
|
||||
6. Review the final diff for accidental scope, brittle tests, and unnecessary test hooks.
|
||||
7. Run broader verification appropriate to the risk.
|
||||
|
||||
Skip planning ceremony for tiny obvious edits. Do not add unrelated tests, abstractions, docs, or refactors merely because they are nearby.
|
||||
|
||||
Use the narrowest useful checks first. For substantive workspace changes, final verification should normally include:
|
||||
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --all-targets --all-features --locked
|
||||
cargo test --workspace --all-targets --all-features --locked
|
||||
cargo test --workspace --doc --all-features --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
```
|
||||
|
||||
Also verify `--no-default-features` when changing feature gates, optional dependencies, runtime boundaries, or code intended to build without Tokio.
|
||||
|
||||
Interop tests require an external `irtt` binary and should only run when the task concerns interoperability or explicitly requests them. Do not run repetition loops, fuzzing, Miri, Loom, mutation testing, or similar expensive checks as routine ceremony.
|
||||
|
||||
Never claim a command passed unless it actually ran successfully.
|
||||
|
||||
## Repository hygiene
|
||||
|
||||
- Do not use destructive Git operations unless explicitly requested.
|
||||
- Do not push, force-push, create/update a PR, or rewrite unrelated history unless explicitly requested.
|
||||
- Keep commits scoped to the requested work and preserve unrelated local changes.
|
||||
|
||||
## Completion
|
||||
|
||||
Report concisely:
|
||||
|
||||
1. what changed;
|
||||
2. verification actually run and its result;
|
||||
3. meaningful tests added/updated/consolidated/removed, with justification for each persistent test added or materially expanded;
|
||||
4. remaining risks, ambiguities, or follow-ups.
|
||||
|
||||
Do not produce a giant changelog of incidental edits.
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
# Changelog
|
||||
|
||||
This project's crates are versioned independently. Each release below is
|
||||
scoped to a single crate and tagged as `<crate>/vX.Y.Z` (see
|
||||
[Releasing](#releasing) below).
|
||||
|
||||
All notable changes to each crate are documented in its own section per
|
||||
release. The format is loosely based on
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## 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
|
||||
|
||||
- New server-direction wire codecs: `decode_open_request`, `encode_open_reply`, `decode_echo_request`, `encode_echo_reply`, and `decode_close_request`, so a server implementation (now `irtt-server`) can encode/decode the same wire format the client side already used, instead of duplicating protocol logic outside this crate.
|
||||
- New `ProtoError` variants `MissingField` and `UnexpectedField` for structural validation of optional fields that must be present/absent together.
|
||||
|
||||
#### Changed
|
||||
|
||||
- `encode_echo_request` (and the request-side echo codec generally) now takes the negotiated params as a separate argument rather than bundling them into `EchoRequest` — a breaking signature change for any direct caller of the proto-level echo encoder.
|
||||
- `ProtoError::NegativePacketLength` was renamed to `ProtoError::PacketLengthUnrepresentable { length: i64 }`, reflecting that the check now rejects any length that doesn't fit the wire representation, not only negative ones — a breaking rename for callers matching on this variant.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- The echo-reply length codec now accepts upstream `irtt`'s longer midpoint-echo compatibility length instead of rejecting it, fixing an interop gap against real upstream servers/clients that send that length.
|
||||
|
||||
## 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
|
||||
|
||||
- Kernel transmit-timestamp correlation is now invalidated when a probe send fails, preventing a later timestamp from being matched to the wrong probe.
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
|
||||
- `ManagedCommandReceipt::blocking_wait()` lets synchronous callers wait for a managed target-set update to be applied or rejected without entering an async runtime.
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Added
|
||||
|
||||
- New optional `tokio` Cargo feature adding `AsyncClient`, a low-level non-blocking/poll-based Tokio adapter alongside the existing runtime-free blocking `Client`.
|
||||
- New managed driver stack built on top of `AsyncClient`: `ManagedClientTask` / `ManagedClientHandle` (a unified Tokio-based managed driver and control surface, supporting dynamic per-target updates to a running session group) and `BlockingManagedClient` (a synchronous owner that runs a dedicated current-thread Tokio runtime internally). This replaces the project's earlier managed-client design outright rather than extending it, including changes to the `ClientError` variants it can surface.
|
||||
- Linux kernel receive-timestamp capture (`SO_TIMESTAMPING`-family ancillary data) feeding one-way-delay (OWD) calculations, and equivalent kernel transmit-timestamp capture on the send side, materially improving OWD accuracy over the previous best-effort user-space send/receive timestamps on Linux. Both require the `ancillary` feature.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- DSCP wire semantics: the negotiated DSCP value was being shifted twice on the wire path (double-application of the ECN/DSCP bit layout), which produced an incorrect traffic-class byte in some configurations; the client-side DSCP handling was reworked to apply the shift exactly once and is covered by expanded DSCP/negotiation tests.
|
||||
- Post-send timestamp capture correctness fix so the locally-recorded send timestamp used for OWD/RTT calculations reflects when the probe actually left the socket rather than a timestamp that could be skewed by scheduling between encode and send.
|
||||
- A batch of managed-session liveness and lifecycle fixes: dynamic target groups no longer spin instead of blocking while idle; empty dynamic groups stay idle correctly; peer-initiated closes are now recorded and surfaced (including counters/provenance) instead of being silently dropped or racing with correlation; authenticated close is honored ahead of reply correlation; terminal outcomes are now published for targets that fail rather than being dropped silently; completed event hubs are sealed and completed-target retention is bounded (preventing unbounded growth in long multi-target runs); missed probe slots are skipped rather than mis-scheduled; dropped-subscription events are now exposed to consumers instead of being swallowed; and a timeout-budget bug in the managed opening phase was corrected.
|
||||
- Linux receive paths (blocking `Client::open`/`recv_once`/`recv_available`, `AsyncClient`'s non-blocking recv/open polling, and the MSG_ERRQUEUE TX-timestamp drain) now retry transparently on `EINTR` instead of surfacing it as a fatal socket error, while preserving each path's existing timeout/deadline contract (no retransmit, no timeout extension under repeated interruption).
|
||||
- `ClientError` failure classification was made exhaustive/more precise, giving callers a fuller and more accurate error taxonomy to match on instead of a catch-all in some paths.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- The `ancillary` (Linux socket ancillary-data) feature was hardened to only perform safe operations, closing a soundness gap in how ancillary control-message buffers were handled.
|
||||
|
||||
## 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
|
||||
|
||||
- `parse_hex_pattern`'s chunking now uses the stable `slice::as_chunks` API instead of `chunks_exact`; no behavior change.
|
||||
|
||||
### 0.5.1
|
||||
|
||||
#### Added
|
||||
|
||||
- New public `address_family_available()` helper (re-exported from `set`), for probing whether an address family can actually bind locally rather than merely constructing a socket for it. Used by the `irtt-rs` server applet to distinguish a genuinely unsupported address family from an ordinary bind failure when falling back from its default dual-family bind.
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Added
|
||||
|
||||
- `irtt-server` is a new first-class, reusable crate this release, built from scratch: a deterministic `ServerCore` handling OPEN/ECHO/CLOSE packet admission, authentication policy, open negotiation, a bounded session table, echo processing with per-session receive state and timestamps, per-session rate limiting, session lifetime with idle expiry, server-initiated close on maximum duration, and client-initiated close.
|
||||
- A reusable Tokio UDP `Server` runs one sequential `ServerCore` per listener with caller-controlled shutdown and once-per-second scheduled idle-session maintenance (in addition to exact logical expiry on authenticated, structurally valid requests). `ServerSet` sits above it as the service-level owner of one or more independent `Server`s: it binds them all-or-none, runs each in its own Tokio task, fans one external shutdown signal out to all of them, joins them, and fails the group if any listener fails or stops early — enabling multi-listener deployments from a single `ServerConfig`.
|
||||
- Each reply now carries the raw traffic class it must be sent with, applied to the listener socket immediately before every send.
|
||||
- Wildcard listeners recover each request's local destination from packet ancillary metadata and send that request's reply from the same address, on Linux, macOS, and FreeBSD. `Server::from_socket`/`Server::bind` are now fallible specifically because of this: a wildcard bind on a platform without that path is refused at construction (`ServerRuntimeError::WildcardSourceSelectionUnsupported`) rather than silently served from a routing-table-chosen source address. Explicit-address listeners are unaffected on all platforms.
|
||||
- Configurable echo-reply payload fill policy (`ServerFill`), controlling what bytes a session's echo replies are padded with.
|
||||
- Two optional negotiation policies, both off by default and settled during open negotiation: a timestamp allowance and a DSCP permission, restricting what a session may ask the server to provide.
|
||||
- Linux kernel receive-timestamp capture for inbound packets, feeding more accurate server-side timing metadata, mirroring the equivalent client-side capability added in `irtt-client`.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- The DSCP/traffic-class byte applied to replies was corrected to match the client-side wire-semantics fix (see `irtt-client`), so server-echoed DSCP marking is now consistent with what clients expect.
|
||||
- Executable-params validation on OPEN requests was tightened so malformed/contradictory negotiated parameters are rejected during open negotiation rather than accepted and mishandled later.
|
||||
- Packet-length policy corrected so oversized/invalid negotiated lengths are rejected consistently with the updated `irtt-proto` length validation.
|
||||
- A receive-drop/backpressure policy fix for malformed or unauthenticated inbound packets, preventing them from disrupting the session table or other in-flight sessions.
|
||||
- A receive-timestamp "wall clock" fix on the kernel RX-capture path, and a 32-bit (i686) `timespec` conversion correctness fix covered by a dedicated regression test.
|
||||
- The server's ancillary receive loop now retries transparently on `EINTR` instead of terminating the listener, matching the equivalent `irtt-client` fix.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- Resource bounds are enforced by design (bounded session table, per-listener `max_sessions`, rate limiting, idle/max-duration expiry) rather than mirroring upstream `irtt`'s effectively unbounded session/per-peer behavior; this is a deliberate divergence, not an oversight, and is documented in `crates/irtt-server/AGENTS.md`.
|
||||
|
||||
## 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
|
||||
|
||||
- Exact median calculation for `send_call`, `timer_error`, and `server_processing` event-duration statistics, which previously always reported `None` for these medians instead of a computed value.
|
||||
|
||||
#### Added
|
||||
|
||||
- `LateReplyMode` (`Measure` / `CountOnly`) lets a consumer choose whether replies that arrive after their probe is considered late are still fully measured into the running statistics or only counted, without affecting the other samples.
|
||||
- `StatsConfig::estimated_retained_bytes(probe_count)` gives callers an API to estimate the memory a stats configuration will retain for a given probe count, ahead of actually running a session (used by `irtt-cli`'s multi-target memory-usage warning, see below).
|
||||
|
||||
## irtt-rs
|
||||
|
||||
### 0.7.1
|
||||
|
||||
#### Fixed
|
||||
|
||||
- `IRTT_SERVER_NO_DSCP` now accepts ordinary boolean environment values such as `1` and `0`; falsey values leave DSCP marking enabled, while truthy values disable it. The `--no-dscp` command-line flag remains a flag without a value.
|
||||
|
||||
### 0.7.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Client target arguments now support per-target HMAC configuration: append `@hmac=KEY` to override the global `--hmac` value for that target, or use `@hmac=` to explicitly disable HMAC for it. The target syntax supports context-aware escaping for literal delimiters.
|
||||
- Continuous client runs can now take complete desired target sets from standard input with `--targets-stdin`. Each non-empty record atomically replaces the current set, `[]` selects an empty set, and EOF requests a graceful stop. The controller retains only the newest unapplied record under transient replacement backpressure, with explicit bounds on record size, desired targets, and live target generations.
|
||||
|
||||
#### Changed
|
||||
|
||||
- A peer-initiated close is now target-local in stdin-controlled continuous runs: the controller and other desired targets keep running, and a later desired set can start that target again.
|
||||
|
||||
### 0.6.2
|
||||
|
||||
#### Added
|
||||
|
||||
- Every `irtt-server` applet flag can now also be set via an `IRTT_SERVER_*` environment variable (`IRTT_SERVER_BIND`, `IRTT_SERVER_HMAC`, `IRTT_SERVER_MAX_SESSIONS`, `IRTT_SERVER_MAX_PACKET_LENGTH`, `IRTT_SERVER_MIN_INTERVAL`, `IRTT_SERVER_BURST`, `IRTT_SERVER_IDLE_TIMEOUT`, `IRTT_SERVER_MAX_DURATION`, `IRTT_SERVER_TIMESTAMP_ALLOWANCE`, `IRTT_SERVER_NO_DSCP`), for deployments that configure by environment (containers, orchestrators) instead of command-line flags. An explicit flag on the command line still overrides the corresponding variable.
|
||||
- A `Dockerfile` and Gitea Actions workflow publishing a minimal, statically linked `irtt-server` container image (cross-compiled for `amd64`/`arm64` via `tonistiigi/xx`, no QEMU) to this project's Gitea and GHCR container registries.
|
||||
|
||||
#### Changed
|
||||
|
||||
- The bundled `ctrlc` dependency now enables its `termination` feature, so the applet also handles `SIGTERM` (not just `SIGINT`) for graceful shutdown.
|
||||
|
||||
### 0.6.1
|
||||
|
||||
#### Added
|
||||
|
||||
- The `irtt-server` applet now has sensible zero-argument defaults: with no `--bind`, it binds the wildcard IRTT port on both address families (`[::]:2112` then `0.0.0.0:2112`) instead of requiring an explicit address, on platforms where wildcard reply-source selection is supported. Any explicit `--bind` fully replaces the default pair rather than adding to it.
|
||||
|
||||
#### Changed
|
||||
|
||||
- If one of the two default listeners fails because its address family has no local support on the host (e.g. IPv6 administratively disabled on an otherwise IPv4-capable host), the server now falls back to serving just the surviving family instead of failing outright. Any other failure reason (port in use, permission denied, no safe wildcard reply-source path) still fails startup as it would for an explicit `--bind`.
|
||||
|
||||
### 0.6.0
|
||||
|
||||
#### Changed
|
||||
|
||||
- The application Cargo package was renamed from `irtt-cli` to `irtt-rs`, moving to `crates/irtt-app`. This is the application distribution package that produces the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries; it is unrelated to the reusable `irtt-client` library crate, which keeps its existing name and version.
|
||||
- The `irtt-cli` executable was hard-renamed to `irtt-client` to avoid reading as an umbrella CLI now that the application also ships a first-class server. There is no `irtt-cli` compatibility binary or alias.
|
||||
- Dedicated binaries now have role-specific entry points (`irtt-client`, `irtt-tui`, `irtt-server`) instead of all funneling through the multicall dispatcher's argv0/subcommand logic. Each dedicated binary always runs its own role regardless of the name it is invoked or copied under, and no longer links code for the other applet roles it doesn't implement. The `irtt-rs` binary remains the feature-selectable multicall dispatcher and is the only binary that inspects argv0 or subcommands.
|
||||
- Future application releases move to the `irtt-rs/vX.Y.Z` tag namespace (previously `irtt-cli/vX.Y.Z`); see [Releasing](#releasing).
|
||||
|
||||
## irtt-cli
|
||||
|
||||
### 0.5.0
|
||||
|
||||
#### Added
|
||||
|
||||
- A new `server` applet (the `irtt-server` binary and `irtt-rs server` subcommand), gated behind the `server` Cargo feature which is now part of `irtt-cli`'s default feature set. It is thin orchestration over the new `irtt-server` crate: one current-thread Tokio runtime, one repeatable `--bind`, and `ServerSet` for startup/shutdown/listener-failure handling — including for a single bind.
|
||||
- Positional `[LABEL=]TARGET` argument syntax for specifying client targets.
|
||||
|
||||
#### Changed
|
||||
|
||||
- Target specification moved from a `--target LABEL=TARGET` flag to plain positional `[LABEL=]TARGET` arguments — a breaking CLI syntax change for any script invoking targets via the old flag.
|
||||
- The `Target` column is now always present in default, CSV, TSV, and JSONL output, rather than only appearing once more than one target was specified. This is a breaking output-format change for scripts that parsed single-target output and assumed no `Target` column.
|
||||
- Cargo feature flags were simplified: the separate `stats`, `full`, and `client-runtime` features were removed, and statistics support is now mandatory whenever the `client` (or `tui`) feature is enabled, rather than optional. Consumers building with `--no-default-features` and selectively re-enabling a `stats` or `client-runtime` feature will need to update their feature selection.
|
||||
- The stats-related memory-usage warning now scales with the number of configured targets (using `irtt-stats`'s new `estimated_retained_bytes`), instead of assuming a single target's worth of retained memory.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Single-target runs are now driven through the same `ManagedClient` path as multi-target runs, instead of a separate single-target code path, fixing behavioral drift between the two (e.g. continuous single-target runs now stop correctly on peer-initiated close and multi-target failures are now reported with a nonzero exit status).
|
||||
- Continuous (unbounded-duration) runs, both single- and multi-target, now stop and report correctly when the peer closes the session, instead of hanging or exiting silently.
|
||||
- The TUI's input-event draining is now bounded per tick, fixing a starvation/liveness issue where a burst of input could stall rendering or probe scheduling.
|
||||
- The TUI's live graph history buffer is now allocated lazily instead of eagerly, avoiding unnecessary upfront allocation for graphs that are never shown.
|
||||
- A managed-session terminal-state reconciliation fix in the CLI's use of the managed client, correcting a case where a target's final/terminal outcome could be reconciled incorrectly.
|
||||
|
||||
#### Compatibility
|
||||
|
||||
- MSRV raised to Rust 1.88 (from 1.85), to track `ratatui`'s minimum supported Rust version; this applies to the whole workspace, including `irtt-cli`.
|
||||
- CI test coverage was expanded to include `musl` and `aarch64` targets, increasing confidence in `irtt-cli` binaries built for those platforms (no code-level behavior change).
|
||||
|
||||
## Releasing
|
||||
|
||||
Starting with this release, crates are versioned and tagged independently
|
||||
rather than in lockstep. Each crate's release is tagged as `<crate>/vX.Y.Z`
|
||||
(e.g. `irtt-proto/v0.5.0`), pointing at the commit its version was released
|
||||
from. `irtt-proto`, `irtt-client`, `irtt-server`, and `irtt-stats` are
|
||||
libraries published to [crates.io](https://crates.io); their tags exist to
|
||||
identify the exact source for a given crates.io release and do not produce a
|
||||
GitHub Release. The application package (`irtt-cli` through 0.5.0, `irtt-rs`
|
||||
from 0.6.0 onward) is the only package with prebuilt binary artifacts: pushing
|
||||
an `irtt-rs/vX.Y.Z` tag triggers [cargo-dist](https://opensource.axo.dev/cargo-dist/)
|
||||
to build and publish a GitHub Release with platform binaries. The historical
|
||||
`irtt-cli/v0.5.0` tag remains as released; it is not rewritten or replaced.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Claude Code
|
||||
|
||||
Read and follow `AGENTS.md` before making changes. When working below a subtree
|
||||
that contains a more specific `AGENTS.md`, read and follow that file as well.
|
||||
Generated
+1972
-72
File diff suppressed because it is too large.
Load diff
+8
-8
@@ -2,32 +2,32 @@
|
||||
members = [
|
||||
"crates/irtt-proto",
|
||||
"crates/irtt-client",
|
||||
"crates/irtt-server",
|
||||
"crates/irtt-stats",
|
||||
"crates/irtt-cli",
|
||||
"crates/irtt-app",
|
||||
"crates/irtt-integration-tests",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
rust-version = "1.75"
|
||||
rust-version = "1.88"
|
||||
repository = "https://github.com/Lochnair/irtt-rs"
|
||||
homepage = "https://github.com/Lochnair/irtt-rs"
|
||||
readme = "README.md"
|
||||
|
||||
[workspace.dependencies]
|
||||
irtt-proto = { version = "0.2.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.2.0", path = "crates/irtt-client" }
|
||||
irtt-stats = { version = "0.2.0", path = "crates/irtt-stats" }
|
||||
irtt-proto = { version = "0.5.0", path = "crates/irtt-proto" }
|
||||
irtt-client = { version = "0.5.1", path = "crates/irtt-client" }
|
||||
irtt-server = { version = "0.5.2", path = "crates/irtt-server" }
|
||||
irtt-stats = { version = "0.5.0", path = "crates/irtt-stats" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
# The profile that 'dist' will build with
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Builds the irtt-server applet only, statically linked against musl, and
|
||||
# ships it in a scratch image with no shell or libc.
|
||||
#
|
||||
# Cross-compiles for TARGETPLATFORM using tonistiigi/xx: the builder stage
|
||||
# always runs natively on BUILDPLATFORM (no QEMU emulation) and xx-cargo
|
||||
# picks the right musl target triple, sysroot, and linker for whatever
|
||||
# platform buildx asked for.
|
||||
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
|
||||
|
||||
FROM --platform=$BUILDPLATFORM rust:1.99-alpine AS builder
|
||||
|
||||
COPY --from=xx / /
|
||||
|
||||
RUN apk add --no-cache clang lld file
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
RUN xx-apk add --no-cache musl-dev
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
|
||||
RUN xx-cargo build \
|
||||
--release \
|
||||
--locked \
|
||||
-p irtt-rs \
|
||||
--bin irtt-server \
|
||||
--no-default-features \
|
||||
--features server \
|
||||
--target-dir /build/target \
|
||||
&& xx-verify /build/target/$(xx-cargo --print-target-triple)/release/irtt-server \
|
||||
&& cp /build/target/$(xx-cargo --print-target-triple)/release/irtt-server /irtt-server
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=builder /irtt-server /irtt-server
|
||||
|
||||
ENTRYPOINT ["/irtt-server"]
|
||||
@@ -1,144 +1,180 @@
|
||||
# irtt-rs
|
||||
|
||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client.
|
||||
`irtt-rs` is a Rust implementation of an IRTT-compatible client and server:
|
||||
finite or continuous UDP latency probing, plus the server side those probes
|
||||
run against.
|
||||
|
||||
It is not the upstream IRTT project. For the original implementation, protocol background, and broader project documentation, see:
|
||||
This is not the upstream IRTT project. For the original implementation,
|
||||
protocol background, and broader documentation, see
|
||||
[heistp/irtt](https://github.com/heistp/irtt). The installed dispatcher is
|
||||
named `irtt-rs` rather than `irtt` to avoid conflicting with or being
|
||||
mistaken for it.
|
||||
|
||||
<https://github.com/heistp/irtt>
|
||||
## Features
|
||||
|
||||
## Status
|
||||
* Finite and continuous probe sessions
|
||||
* Concurrent probing of multiple targets, with staggered or burst pacing
|
||||
* Reusable client library with a structured event stream (blocking, Tokio,
|
||||
or managed)
|
||||
* Human-readable table output, plus CSV, TSV, and JSON Lines for scripts and
|
||||
monitoring systems, with selectable output columns
|
||||
* Optional terminal UI
|
||||
* Reusable Tokio-native server library
|
||||
* UDP server applet with sensible zero-argument defaults and explicit bind
|
||||
and session-policy options
|
||||
* Router-friendly multi-applet binary layout
|
||||
|
||||
This repository currently provides:
|
||||
## Comparison with upstream IRTT
|
||||
|
||||
- `irtt-cli`, a command-line IRTT-compatible client
|
||||
- `irtt-client`, a Rust library for running client sessions and consuming events
|
||||
- finite and continuous probe runs
|
||||
- human, simple, machine-readable, and RTT-only output modes
|
||||
- optional local summary statistics
|
||||
`irtt-rs` is an independent, compatible reimplementation, not a successor or
|
||||
replacement for upstream IRTT — [heistp/irtt](https://github.com/heistp/irtt)
|
||||
remains the original implementation. Interoperability is a project goal; see
|
||||
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) and
|
||||
[`docs/protocol/`](docs/protocol/) for the black-box interoperability
|
||||
methodology and protocol baseline this project verifies against. Choose
|
||||
between them based on the capabilities and deployment model you need.
|
||||
|
||||
Server support is not implemented.
|
||||
The table below was checked against upstream release
|
||||
[v0.9.2](https://github.com/heistp/irtt/releases/tag/v0.9.2) (2026-07-17).
|
||||
This project's own interop testing and protocol baseline are currently
|
||||
pinned to the prior release, v0.9.1; the wire protocol version number is
|
||||
unchanged between the two, but v0.9.2 has not itself been exercised against
|
||||
this project's interop harness.
|
||||
|
||||
## Install
|
||||
| Capability | irtt-rs | upstream IRTT (v0.9.2) |
|
||||
| --- | --- | --- |
|
||||
| Wire protocol | Protocol version 1, verified against upstream v0.9.1 | Protocol version 1 (unchanged in v0.9.2) |
|
||||
| Finite probing | Supported, default 10s | Supported, default 1m |
|
||||
| Continuous/indefinite operation | Supported (`--duration 0`); still computes running statistics (see below) | Supported via streaming mode (`-s`, added in v0.9.2); computes no statistics at all in that mode |
|
||||
| Statistics in continuous mode | Running count/min/max/mean/variance per target; exact medians unavailable; bounded IPDV tracking | None in streaming mode — raw/event output only, by design, to avoid unbounded memory |
|
||||
| Multi-target probing (one invocation) | Supported, with labels and staggered/burst pacing | Not supported — one remote address per invocation |
|
||||
| Terminal UI | `irtt-tui`, a live graph/dashboard applet | Not present |
|
||||
| Output formats | table, csv, tsv, jsonl, with selectable columns | Text summary, JSON (optionally gzip to file), and a fixed-field raw mode; no CSV/TSV and no column selection |
|
||||
| Client/server library API | Separate, independently versioned Rust crates (`irtt-client`, `irtt-server`) | One combined Go package exposing both roles |
|
||||
| Server: multiple listeners | `ServerSet` supervises several listeners in one process | `ListenAndServe` also serves multiple requested addresses |
|
||||
| Server: wildcard reply-source | Default behavior on Linux/macOS/FreeBSD; refused elsewhere rather than served incorrectly | Opt-in via `--set-src-ip`, with a documented per-packet allocation cost |
|
||||
| Server: zero-argument bind | Binds the wildcard address on both families by default, gated by the platform check above | Binds the wildcard address on both families by default; no documented platform gate |
|
||||
| Prebuilt release binaries | Published for six target triples | None published; install via `go install` or manual build |
|
||||
|
||||
Requires Rust 1.75 or newer.
|
||||
Upstream has capabilities this project does not implement: a `report`
|
||||
subcommand for replaying saved JSON results, benchmarking and
|
||||
timer/clock-diagnostic subcommands, client-side payload fill and
|
||||
don't-fragment/wait-strategy/timer-algorithm controls, an operator-configurable
|
||||
server-side fill policy (this project's server always honors a session's
|
||||
negotiated fill request, with a fixed fallback pattern, but has no equivalent
|
||||
to upstream's flags for restricting or overriding it), syslog logging, and
|
||||
bundled systemd/OpenRC service files.
|
||||
|
||||
From a local checkout:
|
||||
This project has capabilities upstream does not implement: multi-target
|
||||
probing with labels, the terminal UI, CSV/TSV output with selectable
|
||||
columns, a quantified and actively-warned memory-retention model for
|
||||
long-running client sessions, and best-effort Linux kernel-timestamp
|
||||
preference for its own send/receive endpoint.
|
||||
|
||||
## Installation
|
||||
|
||||
The build requires Rust 1.88 or newer.
|
||||
|
||||
Install the latest release from crates.io:
|
||||
|
||||
```sh
|
||||
cargo install irtt-rs --locked
|
||||
```
|
||||
|
||||
This installs the `irtt-rs` dispatcher plus dedicated `irtt-client`,
|
||||
`irtt-tui`, and `irtt-server` binaries. Each applet can also be built in
|
||||
isolation with `--no-default-features` plus the applet's own feature, for
|
||||
example a lightweight client-only install:
|
||||
|
||||
```sh
|
||||
cargo install irtt-rs --locked --no-default-features --features client
|
||||
```
|
||||
|
||||
For development, or to install directly from a checkout:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Lochnair/irtt-rs.git
|
||||
cd irtt-rs
|
||||
cargo install --path crates/irtt-cli
|
||||
cargo install --path crates/irtt-app --locked
|
||||
```
|
||||
|
||||
Or run it directly without installing:
|
||||
## Quick start
|
||||
|
||||
Probe a server:
|
||||
|
||||
```sh
|
||||
cargo run -p irtt-cli -- <server>
|
||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 30s --interval 100ms
|
||||
```
|
||||
|
||||
## CLI usage
|
||||
|
||||
Basic test:
|
||||
Probe continuously until interrupted (`Ctrl-C`):
|
||||
|
||||
```sh
|
||||
irtt-cli <server>
|
||||
irtt-client netperf-eu.bufferbloat.net:2112 --duration 0
|
||||
```
|
||||
|
||||
Set duration and interval:
|
||||
Watch the same session live in the terminal UI:
|
||||
|
||||
```sh
|
||||
irtt-cli <server> --duration 30s --interval 100ms
|
||||
irtt-tui netperf-eu.bufferbloat.net:2112
|
||||
```
|
||||
|
||||
Run continuously:
|
||||
Run a server with sensible defaults — no `--bind` needed:
|
||||
|
||||
```sh
|
||||
irtt-cli <server> --duration 0
|
||||
irtt-server
|
||||
```
|
||||
|
||||
Use a specific output mode:
|
||||
## Documentation
|
||||
|
||||
* [`docs/irtt-client.md`](docs/irtt-client.md) — target syntax, output
|
||||
formats and columns, measurement field definitions, finite/continuous
|
||||
memory behavior, kernel timestamps.
|
||||
* [`docs/irtt-tui.md`](docs/irtt-tui.md) — controls, continuous default,
|
||||
retained history.
|
||||
* [`docs/irtt-server.md`](docs/irtt-server.md) — bind defaults and
|
||||
overrides, wildcard listener behavior, session policy, HMAC.
|
||||
* [`docs/irtt-rs.md`](docs/irtt-rs.md) — the multicall dispatcher, applet
|
||||
binary names, feature-gated builds.
|
||||
* [docs.rs](https://docs.rs) for the library crates below.
|
||||
* [`docs/protocol/`](docs/protocol/) and
|
||||
[`docs/INTEROP_COMPARISON.md`](docs/INTEROP_COMPARISON.md) for the wire
|
||||
protocol specification and interoperability validation against upstream
|
||||
`irtt`.
|
||||
|
||||
Every applet also documents its full option list with `--help`.
|
||||
|
||||
## Crates
|
||||
|
||||
| Crate | crates.io | What it is |
|
||||
| --- | --- | --- |
|
||||
| [`irtt-rs`](crates/irtt-app) | [crates.io](https://crates.io/crates/irtt-rs) | Application package: the `irtt-rs`, `irtt-client`, `irtt-tui`, and `irtt-server` binaries |
|
||||
| [`irtt-client`](crates/irtt-client) | [crates.io](https://crates.io/crates/irtt-client) | Reusable client/session library ([docs.rs](https://docs.rs/irtt-client)) |
|
||||
| [`irtt-server`](crates/irtt-server) | [crates.io](https://crates.io/crates/irtt-server) | Reusable Tokio-native server library ([docs.rs](https://docs.rs/irtt-server)) |
|
||||
| [`irtt-stats`](crates/irtt-stats) | [crates.io](https://crates.io/crates/irtt-stats) | Statistics aggregation over `irtt-client` events ([docs.rs](https://docs.rs/irtt-stats)) |
|
||||
| [`irtt-proto`](crates/irtt-proto) | [crates.io](https://crates.io/crates/irtt-proto) | Low-level wire protocol encode/decode ([docs.rs](https://docs.rs/irtt-proto)) |
|
||||
|
||||
Each library crate has its own crates.io README describing what it's for and
|
||||
when to use it instead of `irtt-client`/`irtt-server` directly.
|
||||
|
||||
## Binaries and features
|
||||
|
||||
| Build | Binaries |
|
||||
| --- | --- |
|
||||
| `--no-default-features` | `irtt-rs` |
|
||||
| `--no-default-features --features client` | `irtt-rs`, `irtt-client` |
|
||||
| `--no-default-features --features server` | `irtt-rs`, `irtt-server` |
|
||||
| `--no-default-features --features tui` | `irtt-rs`, `irtt-tui` |
|
||||
| Default features (or `--all-features`) | `irtt-rs`, `irtt-client`, `irtt-tui`, `irtt-server` |
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
irtt-cli <server> --output human
|
||||
irtt-cli <server> --output simple
|
||||
irtt-cli <server> --output machine
|
||||
irtt-cli <server> --output rtt-us
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --locked
|
||||
cargo test --workspace --doc --all-features --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
```
|
||||
|
||||
For available options:
|
||||
|
||||
```sh
|
||||
irtt-cli --help
|
||||
```
|
||||
|
||||
## Machine output
|
||||
|
||||
Use `--output machine` for line-oriented `key=value` output intended for scripts, monitoring, and autorate consumers.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
irtt-cli <server> --duration 0 --interval 250ms --output machine
|
||||
```
|
||||
|
||||
Each line represents one client event and starts with an `event` field, for example:
|
||||
|
||||
```text
|
||||
event=echo_reply seq=4 logical_seq=4 remote=203.0.113.10:2112 raw_rtt_us=12400 effective_rtt_us=12100 adjusted_rtt_us=12100 server_processing_us=300 dscp=0 ecn=0 kernel_rx_ns=none
|
||||
```
|
||||
|
||||
Consumers should match on `event=...` and read the fields they need. Unknown fields should be ignored.
|
||||
|
||||
For consumers that only need RTT values, use:
|
||||
|
||||
```sh
|
||||
irtt-cli <server> --output rtt-us
|
||||
```
|
||||
|
||||
## Library usage
|
||||
|
||||
`irtt-client` can be used directly from Rust code.
|
||||
|
||||
Example `Cargo.toml` dependency from a local checkout:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
irtt-client = { path = "crates/irtt-client" }
|
||||
```
|
||||
|
||||
Minimal managed-client example:
|
||||
|
||||
```rust
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
ClientConfig, ClientEvent, ManagedClient, SubscriberConfig,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = ClientConfig {
|
||||
server_addr: "netperf-eu.bufferbloat.net:2112".to_owned(),
|
||||
duration: Some(Duration::from_secs(10)),
|
||||
interval: Duration::from_secs(1),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
|
||||
let (session, events) =
|
||||
ManagedClient::start_with_subscription(config, SubscriberConfig::default())?;
|
||||
|
||||
while let Ok(event) = events.recv() {
|
||||
match event {
|
||||
ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||
println!("seq={seq} rtt_us={}", rtt.effective.as_micros());
|
||||
}
|
||||
ClientEvent::SessionClosed { .. } => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = session.join()?;
|
||||
println!("session ended: {:?}", outcome.end_reason);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
See [`AGENTS.md`](AGENTS.md) for architecture, testing policy, and the
|
||||
clean-room provenance boundary this project maintains against upstream
|
||||
`irtt`.
|
||||
@@ -0,0 +1,73 @@
|
||||
[package]
|
||||
name = "irtt-rs"
|
||||
version = "0.7.1"
|
||||
description = "IRTT-compatible client, TUI, and server applications"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme = "README.md"
|
||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||
categories = ["command-line-utilities", "network-programming"]
|
||||
|
||||
[lib]
|
||||
name = "irtt_app"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "irtt-rs"
|
||||
path = "src/bin/irtt-rs.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "irtt-client"
|
||||
path = "src/bin/irtt-client.rs"
|
||||
required-features = ["client"]
|
||||
# This applet shares its name with the irtt-client library crate, whose rustdoc
|
||||
# output it would otherwise collide with.
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "irtt-tui"
|
||||
path = "src/bin/irtt-tui.rs"
|
||||
required-features = ["tui"]
|
||||
|
||||
[[bin]]
|
||||
name = "irtt-server"
|
||||
path = "src/bin/irtt-server.rs"
|
||||
required-features = ["server"]
|
||||
# This applet shares its name with the irtt-server library crate, whose rustdoc
|
||||
# output it would otherwise collide with.
|
||||
doc = false
|
||||
|
||||
[features]
|
||||
default = ["client", "server", "tui"]
|
||||
client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"]
|
||||
server = ["dep:irtt-server", "dep:tokio"]
|
||||
tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"]
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true }
|
||||
clap = { version = "4.6.7", features = ["derive", "env"] }
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"], optional = true }
|
||||
futures-core = { version = "0.3.34", default-features = false, optional = true }
|
||||
ctrlc = { version = "3.5.2", features = ["termination"] }
|
||||
irtt-client = { workspace = true, optional = true }
|
||||
irtt-proto = { workspace = true, optional = true }
|
||||
irtt-server = { workspace = true, optional = true }
|
||||
irtt-stats = { workspace = true, optional = true }
|
||||
ratatui = { version = "0.30.2", optional = true }
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "time", "sync", "macros"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
nix = { version = "0.31.3", features = ["term", "poll", "signal", "process"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,33 @@
|
||||
# irtt-rs
|
||||
|
||||
The IRTT-compatible client, TUI, and server applications, packaged as Cargo
|
||||
package `irtt-rs`.
|
||||
|
||||
Installing this crate builds:
|
||||
|
||||
- `irtt-rs` — a multicall dispatcher binary that runs the client, TUI, or
|
||||
server applet by subcommand.
|
||||
- `irtt-client` — the dedicated client binary (feature `client`).
|
||||
- `irtt-tui` — the interactive terminal UI (feature `tui`).
|
||||
- `irtt-server` — the dedicated server binary (feature `server`).
|
||||
|
||||
All three features are enabled by default, so a plain `cargo install irtt-rs`
|
||||
installs everything.
|
||||
|
||||
## This is an application, not a library
|
||||
|
||||
This package's library target is undocumented (`doc = false`) and exists only
|
||||
to share code between its binaries. If you're looking for the client/session
|
||||
or server API to embed in your own Rust program, use
|
||||
[`irtt-client`](https://crates.io/crates/irtt-client) or
|
||||
[`irtt-server`](https://crates.io/crates/irtt-server) instead.
|
||||
|
||||
## Usage
|
||||
|
||||
See the [project repository](https://github.com/Lochnair/irtt-rs) for
|
||||
installation instructions, command-line usage, and configuration.
|
||||
|
||||
## Project
|
||||
|
||||
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||
implementation of an IRTT-compatible protocol stack.
|
||||
@@ -0,0 +1,182 @@
|
||||
use std::{ffi::OsString, path::Path};
|
||||
|
||||
use clap::Command;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequestedApplet {
|
||||
Client,
|
||||
Tui,
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppletDispatch {
|
||||
Run {
|
||||
applet: RequestedApplet,
|
||||
argv: Vec<OsString>,
|
||||
},
|
||||
Help(String),
|
||||
}
|
||||
|
||||
pub fn detect_applet_from_argv0(argv0: &str) -> Option<RequestedApplet> {
|
||||
match applet_basename(argv0) {
|
||||
"irtt-client" => Some(RequestedApplet::Client),
|
||||
"irtt-tui" => Some(RequestedApplet::Tui),
|
||||
"irtt-server" => Some(RequestedApplet::Server),
|
||||
"irtt-rs" => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dispatch_from_argv(argv: Vec<OsString>) -> Result<AppletDispatch, String> {
|
||||
let argv0 = argv
|
||||
.first()
|
||||
.and_then(|arg| arg.to_str())
|
||||
.unwrap_or("irtt-rs");
|
||||
|
||||
if let Some(applet) = detect_applet_from_argv0(argv0) {
|
||||
return Ok(AppletDispatch::Run { applet, argv });
|
||||
}
|
||||
|
||||
let basename = applet_basename(argv0);
|
||||
if basename.starts_with("irtt-") && basename != "irtt-rs" {
|
||||
return Err(format!(
|
||||
"unknown applet name '{basename}'. Known applet names: {}",
|
||||
known_applet_binary_names()
|
||||
));
|
||||
}
|
||||
|
||||
let Some(command) = argv.get(1).and_then(|arg| arg.to_str()) else {
|
||||
return Err(format!(
|
||||
"choose an applet: {}\n\n{}",
|
||||
applet_command_names(),
|
||||
dispatcher_help()
|
||||
));
|
||||
};
|
||||
|
||||
match command {
|
||||
"-h" | "--help" => Ok(AppletDispatch::Help(dispatcher_help())),
|
||||
"client" => Ok(AppletDispatch::Run {
|
||||
applet: RequestedApplet::Client,
|
||||
argv: applet_argv("irtt-client", &argv[2..]),
|
||||
}),
|
||||
"tui" => Ok(AppletDispatch::Run {
|
||||
applet: RequestedApplet::Tui,
|
||||
argv: applet_argv("irtt-tui", &argv[2..]),
|
||||
}),
|
||||
"server" => Ok(AppletDispatch::Run {
|
||||
applet: RequestedApplet::Server,
|
||||
argv: applet_argv("irtt-server", &argv[2..]),
|
||||
}),
|
||||
_ => Err(format!(
|
||||
"unknown applet '{command}'. Choose one of: {}\n\n{}",
|
||||
applet_command_names(),
|
||||
dispatcher_help()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dispatcher_help() -> String {
|
||||
let mut command = Command::new("irtt-rs")
|
||||
.about("IRTT-compatible multi-applet dispatcher")
|
||||
.subcommand(Command::new("client").about(client_applet_about()))
|
||||
.subcommand(Command::new("tui").about(tui_applet_about()))
|
||||
.subcommand(Command::new("server").about(server_applet_about()))
|
||||
.after_help(dispatcher_after_help());
|
||||
command.render_help().to_string()
|
||||
}
|
||||
|
||||
fn client_applet_about() -> &'static str {
|
||||
if cfg!(feature = "client") {
|
||||
"Run the stream client applet"
|
||||
} else {
|
||||
"Stream client applet is not available in this build"
|
||||
}
|
||||
}
|
||||
|
||||
fn tui_applet_about() -> &'static str {
|
||||
if cfg!(feature = "tui") {
|
||||
"Run the terminal UI applet"
|
||||
} else {
|
||||
"Terminal UI applet is not available in this build"
|
||||
}
|
||||
}
|
||||
|
||||
fn server_applet_about() -> &'static str {
|
||||
if cfg!(feature = "server") {
|
||||
"Run the UDP server applet"
|
||||
} else {
|
||||
"UDP server applet is not available in this build"
|
||||
}
|
||||
}
|
||||
|
||||
fn known_applet_binary_names() -> &'static str {
|
||||
"irtt-rs, irtt-client, irtt-tui, irtt-server"
|
||||
}
|
||||
|
||||
fn applet_command_names() -> &'static str {
|
||||
"client, tui, or server"
|
||||
}
|
||||
|
||||
fn dispatcher_after_help() -> String {
|
||||
let mut help = "Recognized applet binary names: irtt-client, irtt-tui, irtt-server".to_owned();
|
||||
|
||||
let mut missing = Vec::new();
|
||||
if !cfg!(feature = "client") {
|
||||
missing.push("irtt-client requires the client feature");
|
||||
}
|
||||
if !cfg!(feature = "server") {
|
||||
missing.push("irtt-server requires the server feature");
|
||||
}
|
||||
if !cfg!(feature = "tui") {
|
||||
missing.push("irtt-tui requires the tui feature");
|
||||
}
|
||||
|
||||
match missing.len() {
|
||||
0 => {}
|
||||
1 => help.push_str(&format!("\nOptional applet: {}", missing[0])),
|
||||
_ => help.push_str(&format!("\nOptional applets: {}", missing.join("; "))),
|
||||
}
|
||||
help
|
||||
}
|
||||
|
||||
fn applet_basename(argv0: &str) -> &str {
|
||||
let name = Path::new(argv0)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(argv0);
|
||||
strip_suffix_ignoring_case(name, std::env::consts::EXE_SUFFIX)
|
||||
}
|
||||
|
||||
/// Removes the platform executable suffix from a file name.
|
||||
///
|
||||
/// The suffix is part of the file name on Windows and empty everywhere else, so
|
||||
/// this is a no-op on Unix. Without it, every applet name — including the
|
||||
/// `irtt-rs` dispatcher itself — reaches argv0 detection as `<name>.exe` and is
|
||||
/// rejected as an unknown applet. The applet names themselves are still matched
|
||||
/// exactly; only this platform artifact is case-insensitive, because it is not
|
||||
/// a name the user chose.
|
||||
///
|
||||
/// The suffix is a parameter so that Windows behavior is assertable from any
|
||||
/// host, not only where `EXE_SUFFIX` happens to be non-empty.
|
||||
fn strip_suffix_ignoring_case<'a>(name: &'a str, suffix: &str) -> &'a str {
|
||||
if suffix.is_empty() || name.len() <= suffix.len() {
|
||||
return name;
|
||||
}
|
||||
let split = name.len() - suffix.len();
|
||||
let (Some(stem), Some(tail)) = (name.get(..split), name.get(split..)) else {
|
||||
return name;
|
||||
};
|
||||
if tail.eq_ignore_ascii_case(suffix) {
|
||||
stem
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fn applet_argv(applet_name: &str, args: &[OsString]) -> Vec<OsString> {
|
||||
let mut argv = Vec::with_capacity(args.len() + 1);
|
||||
argv.push(OsString::from(applet_name));
|
||||
argv.extend(args.iter().cloned());
|
||||
argv
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_app::entry::client_main()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_app::entry::dispatcher_main()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_app::entry::server_main()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
irtt_app::entry::tui_main()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::shared::client::{
|
||||
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
||||
ManagedRunSetup, TargetArg, TargetSelection, TimestampArg,
|
||||
};
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
pub const DEFAULT_CLIENT_DURATION: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-client", about = "Minimal IRTT-compatible stream client")]
|
||||
pub struct ClientArgs {
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
value_name = "TARGET",
|
||||
num_args = 0..,
|
||||
value_parser = parse_target,
|
||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExamples:\n irtt-client host-a:2112 host-b:2112\n irtt-client eu=host-a:2112 us=host-b:2112\n irtt-client ams=host-a:2112@hmac=secret public=host-b:2112@hmac=\n\nA target without @hmac= inherits --hmac. @hmac= explicitly disables it for that target."
|
||||
)]
|
||||
pub targets: Vec<TargetArg>,
|
||||
|
||||
/// Read complete desired target sets from standard input in continuous mode.
|
||||
#[arg(
|
||||
long,
|
||||
long_help = "Read one complete desired target set per stdin line in continuous mode. Commas separate targets and backslash escapes commas within a target. [] selects an empty desired set. Records are limited to 64 KiB. EOF stops the client gracefully and may discard a revision that has not yet been acknowledged as applied."
|
||||
)]
|
||||
pub targets_stdin: bool,
|
||||
|
||||
/// Managed group pacing for multi-target mode.
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
default_value_t = GroupPacingArg::Staggered,
|
||||
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
||||
)]
|
||||
pub pacing: GroupPacingArg,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "10s",
|
||||
value_parser = parse_test_duration,
|
||||
help = "Test duration; use 0 for continuous mode",
|
||||
long_help = "Test duration; use 0 for continuous mode.\n\nFinite runs retain exact statistics for final summaries. Continuous mode uses bounded-memory running statistics and prints a final summary when interrupted; --targets-stdin also prints the final retained summary on EOF."
|
||||
)]
|
||||
pub duration: Duration,
|
||||
|
||||
#[command(flatten)]
|
||||
pub common: CommonClientArgs,
|
||||
|
||||
/// Event row output format.
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
default_value_t = OutputFormat::Table,
|
||||
long_help = "Event row output format.\n\nTable is the default interactive format. CSV, TSV, and JSON Lines default to all columns for structured export."
|
||||
)]
|
||||
pub format: OutputFormat,
|
||||
|
||||
/// Comma-separated event row columns, or default/all.
|
||||
#[arg(
|
||||
short = 'c',
|
||||
long,
|
||||
value_name = "COLUMNS",
|
||||
long_help = "Comma-separated event row columns, or default/all.\n\nThe default table columns are compact and hide echo_sent rows. Custom table columns include all event rows. Run --list-columns to see valid names."
|
||||
)]
|
||||
pub columns: Option<String>,
|
||||
|
||||
/// List available event row columns and aliases, then exit.
|
||||
#[arg(
|
||||
long,
|
||||
long_help = "List available event row columns and aliases, then exit.\n\nA server argument is not required when listing columns."
|
||||
)]
|
||||
pub list_columns: bool,
|
||||
|
||||
/// Header policy for table, CSV, and TSV output.
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
default_value_t = HeaderMode::Auto,
|
||||
long_help = "Header policy for table, CSV, and TSV output.\n\nJSON Lines never prints a header."
|
||||
)]
|
||||
pub header: HeaderMode,
|
||||
|
||||
/// Include extra fields in table output and final summaries.
|
||||
#[arg(long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
impl ClientArgs {
|
||||
/// Validate the selected targets and prepare the managed run.
|
||||
///
|
||||
/// Targets are only required here. `--list-columns` returns before this is
|
||||
/// called, so listing columns still needs no target.
|
||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
if self.targets_stdin && !self.is_continuous() {
|
||||
return Err("--targets-stdin requires --duration 0".to_owned());
|
||||
}
|
||||
prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
stdin_controlled: self.targets_stdin,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
self.duration == Duration::ZERO
|
||||
}
|
||||
|
||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||
self.common.tstamp
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ClientArgs {
|
||||
type Target = CommonClientArgs;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.common
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum OutputFormat {
|
||||
/// Readable terminal table output.
|
||||
Table,
|
||||
/// Comma-separated event rows.
|
||||
Csv,
|
||||
/// Tab-separated event rows.
|
||||
Tsv,
|
||||
/// One JSON object per event row.
|
||||
Jsonl,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
pub fn prints_summary(self) -> bool {
|
||||
matches!(self, Self::Table)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum HeaderMode {
|
||||
/// Print headers for table, CSV, and TSV output.
|
||||
Auto,
|
||||
/// Always print headers where the format supports them.
|
||||
Always,
|
||||
/// Never print headers.
|
||||
Never,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod args;
|
||||
pub mod output;
|
||||
pub mod run;
|
||||
pub mod summary;
|
||||
|
||||
pub use args::{ClientArgs, HeaderMode, OutputFormat};
|
||||
pub use run::run_stream;
|
||||
@@ -0,0 +1,998 @@
|
||||
use std::{
|
||||
fmt::Write as _,
|
||||
net::SocketAddr,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use irtt_client::{
|
||||
ClientEvent, NegotiationResult, OneWayDelaySample, PacketMeta, ReceivedStatsSample, RttSample,
|
||||
ServerTiming, SignedDuration, WarningKind,
|
||||
};
|
||||
|
||||
use irtt_stats::{EventStatsUpdate, IpdvPairUpdate};
|
||||
|
||||
use super::args::{HeaderMode, OutputFormat};
|
||||
use crate::cmd::format::{format_duration, format_signed_duration, ABSENT};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct OutputConfig {
|
||||
format: OutputFormat,
|
||||
columns: Vec<Column>,
|
||||
header: HeaderMode,
|
||||
default_table_rows: bool,
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
impl OutputConfig {
|
||||
pub(super) fn new(
|
||||
format: OutputFormat,
|
||||
columns: Option<&str>,
|
||||
header: HeaderMode,
|
||||
verbose: bool,
|
||||
) -> Result<Self, String> {
|
||||
let uses_default_columns = columns
|
||||
.map(|columns| columns.trim() == "default")
|
||||
.unwrap_or(true);
|
||||
let default_table_rows = format == OutputFormat::Table && uses_default_columns;
|
||||
let columns = match columns {
|
||||
Some(columns) => parse_columns(columns, format, verbose)?,
|
||||
None => default_columns(format, verbose),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
format,
|
||||
columns,
|
||||
header,
|
||||
default_table_rows,
|
||||
verbose,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prints_summary(&self) -> bool {
|
||||
self.format.prints_summary()
|
||||
}
|
||||
|
||||
pub(super) fn summary_verbose(&self) -> bool {
|
||||
self.verbose
|
||||
}
|
||||
|
||||
pub(super) fn should_print_header(&self) -> bool {
|
||||
match self.header {
|
||||
HeaderMode::Always => self.format != OutputFormat::Jsonl,
|
||||
HeaderMode::Never => false,
|
||||
HeaderMode::Auto => matches!(
|
||||
self.format,
|
||||
OutputFormat::Table | OutputFormat::Csv | OutputFormat::Tsv
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_header(&self) -> Option<String> {
|
||||
if !self.should_print_header() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match self.format {
|
||||
OutputFormat::Table => Some(render_table_header(&self.columns)),
|
||||
OutputFormat::Csv => Some(
|
||||
self.columns
|
||||
.iter()
|
||||
.map(|column| escape_csv(column.name()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
),
|
||||
OutputFormat::Tsv => Some(
|
||||
self.columns
|
||||
.iter()
|
||||
.map(|column| escape_tsv(column.name()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\t"),
|
||||
),
|
||||
OutputFormat::Jsonl => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_event(
|
||||
&self,
|
||||
event: &ClientEvent,
|
||||
target: Option<&str>,
|
||||
stats: Option<&EventStatsUpdate>,
|
||||
) -> Option<String> {
|
||||
let row = OutputRow::from_event(event);
|
||||
if self.default_table_rows && row.is_default_table_hidden() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let context = RenderContext {
|
||||
stats,
|
||||
target,
|
||||
verbose: self.verbose,
|
||||
};
|
||||
|
||||
match self.format {
|
||||
OutputFormat::Table => Some(render_table_row(&row, &self.columns, context)),
|
||||
OutputFormat::Csv => Some(render_delimited_row(
|
||||
&row,
|
||||
&self.columns,
|
||||
context,
|
||||
DelimitedFormat::Csv,
|
||||
)),
|
||||
OutputFormat::Tsv => Some(render_delimited_row(
|
||||
&row,
|
||||
&self.columns,
|
||||
context,
|
||||
DelimitedFormat::Tsv,
|
||||
)),
|
||||
OutputFormat::Jsonl => Some(render_jsonl_row(&row, &self.columns, context)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn list_columns() -> String {
|
||||
let mut out = String::new();
|
||||
writeln!(out, "Available event columns:").unwrap();
|
||||
for column in ALL_COLUMNS {
|
||||
writeln!(out, " {:<24} {}", column.name(), column.description()).unwrap();
|
||||
}
|
||||
writeln!(out).unwrap();
|
||||
writeln!(out, "Aliases:").unwrap();
|
||||
for column in ALL_COLUMNS {
|
||||
let aliases = column.aliases();
|
||||
if !aliases.is_empty() {
|
||||
writeln!(out, " {} ({})", aliases.join(", "), column.name()).unwrap();
|
||||
}
|
||||
}
|
||||
writeln!(out).unwrap();
|
||||
writeln!(out, "Special column sets:").unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
" default format default; compact for table, all columns for csv/tsv/jsonl"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(out, " all every column").unwrap();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RenderContext<'a> {
|
||||
stats: Option<&'a EventStatsUpdate>,
|
||||
target: Option<&'a str>,
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum OutputRow {
|
||||
SessionStarted(LifecycleRow),
|
||||
NoTestCompleted(LifecycleRow),
|
||||
SessionClosed {
|
||||
remote: SocketAddr,
|
||||
token: u64,
|
||||
event_wall: SystemTime,
|
||||
},
|
||||
EchoSent {
|
||||
seq: u32,
|
||||
remote: SocketAddr,
|
||||
client_send_wall: SystemTime,
|
||||
bytes: usize,
|
||||
send_call: Duration,
|
||||
timer_error: Option<Duration>,
|
||||
},
|
||||
EchoReply(ReplyRow),
|
||||
Loss {
|
||||
seq: u32,
|
||||
client_send_wall: SystemTime,
|
||||
},
|
||||
Duplicate {
|
||||
seq: u32,
|
||||
remote: SocketAddr,
|
||||
client_receive_wall: SystemTime,
|
||||
bytes: usize,
|
||||
},
|
||||
Late {
|
||||
reply: ReplyRow,
|
||||
highest_seen: u32,
|
||||
},
|
||||
Warning {
|
||||
kind: WarningKind,
|
||||
message: String,
|
||||
event_wall: SystemTime,
|
||||
},
|
||||
}
|
||||
|
||||
impl OutputRow {
|
||||
fn from_event(event: &ClientEvent) -> Self {
|
||||
match event {
|
||||
ClientEvent::SessionStarted(irtt_client::SessionStarted {
|
||||
remote,
|
||||
token,
|
||||
negotiation: negotiated,
|
||||
at,
|
||||
}) => Self::SessionStarted(LifecycleRow::new(
|
||||
*remote,
|
||||
Some(*token),
|
||||
negotiated,
|
||||
at.wall,
|
||||
)),
|
||||
ClientEvent::NoTestCompleted(irtt_client::NoTestCompleted {
|
||||
remote,
|
||||
negotiation: negotiated,
|
||||
at,
|
||||
}) => Self::NoTestCompleted(LifecycleRow::new(*remote, None, negotiated, at.wall)),
|
||||
ClientEvent::SessionClosed { remote, token, at } => Self::SessionClosed {
|
||||
remote: *remote,
|
||||
token: *token,
|
||||
event_wall: at.wall,
|
||||
},
|
||||
ClientEvent::EchoSent {
|
||||
seq,
|
||||
remote,
|
||||
sent_at,
|
||||
bytes,
|
||||
send_call,
|
||||
timer_error,
|
||||
..
|
||||
} => Self::EchoSent {
|
||||
seq: *seq,
|
||||
remote: *remote,
|
||||
client_send_wall: sent_at.wall,
|
||||
bytes: *bytes,
|
||||
send_call: *send_call,
|
||||
timer_error: *timer_error,
|
||||
},
|
||||
ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote,
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt,
|
||||
server_timing,
|
||||
one_way,
|
||||
received_stats,
|
||||
bytes,
|
||||
packet_meta,
|
||||
} => Self::EchoReply(ReplyRow {
|
||||
seq: *seq,
|
||||
remote: *remote,
|
||||
client_send_wall: Some(sent_at.wall),
|
||||
client_receive_wall: received_at.wall,
|
||||
rtt: Some(*rtt),
|
||||
server_timing: *server_timing,
|
||||
one_way: *one_way,
|
||||
received_stats: *received_stats,
|
||||
bytes: *bytes,
|
||||
packet_meta: *packet_meta,
|
||||
}),
|
||||
ClientEvent::EchoLoss { seq, sent_at, .. } => Self::Loss {
|
||||
seq: *seq,
|
||||
client_send_wall: sent_at.wall,
|
||||
},
|
||||
ClientEvent::DuplicateReply {
|
||||
seq,
|
||||
remote,
|
||||
received_at,
|
||||
bytes,
|
||||
} => Self::Duplicate {
|
||||
seq: *seq,
|
||||
remote: *remote,
|
||||
client_receive_wall: received_at.wall,
|
||||
bytes: *bytes,
|
||||
},
|
||||
ClientEvent::LateReply {
|
||||
seq,
|
||||
highest_seen,
|
||||
remote,
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt,
|
||||
server_timing,
|
||||
one_way,
|
||||
received_stats,
|
||||
bytes,
|
||||
packet_meta,
|
||||
} => Self::Late {
|
||||
reply: ReplyRow {
|
||||
seq: *seq,
|
||||
remote: *remote,
|
||||
client_send_wall: sent_at.map(|sent_at| sent_at.wall),
|
||||
client_receive_wall: received_at.wall,
|
||||
rtt: *rtt,
|
||||
server_timing: *server_timing,
|
||||
one_way: *one_way,
|
||||
received_stats: *received_stats,
|
||||
bytes: *bytes,
|
||||
packet_meta: *packet_meta,
|
||||
},
|
||||
highest_seen: *highest_seen,
|
||||
},
|
||||
ClientEvent::Warning { kind, message, at } => Self::Warning {
|
||||
kind: *kind,
|
||||
message: message.clone(),
|
||||
event_wall: at.wall,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SessionStarted(_) => "session_started",
|
||||
Self::NoTestCompleted(_) => "no_test_completed",
|
||||
Self::SessionClosed { .. } => "session_closed",
|
||||
Self::EchoSent { .. } => "echo_sent",
|
||||
Self::EchoReply(_) => "echo_reply",
|
||||
Self::Loss { .. } => "loss",
|
||||
Self::Duplicate { .. } => "duplicate",
|
||||
Self::Late { .. } => "late",
|
||||
Self::Warning { .. } => "warning",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_default_table_hidden(&self) -> bool {
|
||||
matches!(self, Self::EchoSent { .. })
|
||||
}
|
||||
|
||||
fn reply(&self) -> Option<&ReplyRow> {
|
||||
match self {
|
||||
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn seq(&self) -> Option<u32> {
|
||||
match self {
|
||||
Self::EchoSent { seq, .. } | Self::Loss { seq, .. } | Self::Duplicate { seq, .. } => {
|
||||
Some(*seq)
|
||||
}
|
||||
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.seq),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote(&self) -> Option<SocketAddr> {
|
||||
match self {
|
||||
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.remote),
|
||||
Self::SessionClosed { remote, .. }
|
||||
| Self::EchoSent { remote, .. }
|
||||
| Self::Duplicate { remote, .. } => Some(*remote),
|
||||
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.remote),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn token(&self) -> Option<u64> {
|
||||
match self {
|
||||
Self::SessionStarted(row) | Self::NoTestCompleted(row) => row.token,
|
||||
Self::SessionClosed { token, .. } => Some(*token),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::EchoSent { bytes, .. } | Self::Duplicate { bytes, .. } => Some(*bytes),
|
||||
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.bytes),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn message(&self, context: RenderContext<'_>) -> Option<String> {
|
||||
match self {
|
||||
Self::SessionStarted(row) => Some(format!(
|
||||
"token={:#x} duration_ns={} interval_ns={} length={}",
|
||||
row.token?, row.duration_ns, row.interval_ns, row.payload_length
|
||||
)),
|
||||
Self::NoTestCompleted(row) => Some(format!(
|
||||
"duration_ns={} interval_ns={} length={}",
|
||||
row.duration_ns, row.interval_ns, row.payload_length
|
||||
)),
|
||||
Self::SessionClosed { token, .. } => Some(format!("token={token:#x}")),
|
||||
Self::Late { highest_seen, .. } => Some(format!("highest_seen={highest_seen}")),
|
||||
Self::Warning { message, .. } => Some(message.clone()),
|
||||
Self::Loss { .. } => Some("timeout".to_owned()),
|
||||
Self::Duplicate { .. } if context.verbose => Some("duplicate reply".to_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_wall(&self) -> Option<SystemTime> {
|
||||
match self {
|
||||
Self::SessionStarted(row) | Self::NoTestCompleted(row) => Some(row.event_wall),
|
||||
Self::SessionClosed { event_wall, .. } | Self::Warning { event_wall, .. } => {
|
||||
Some(*event_wall)
|
||||
}
|
||||
Self::EchoSent {
|
||||
client_send_wall, ..
|
||||
}
|
||||
| Self::Loss {
|
||||
client_send_wall, ..
|
||||
} => Some(*client_send_wall),
|
||||
Self::Duplicate {
|
||||
client_receive_wall,
|
||||
..
|
||||
} => Some(*client_receive_wall),
|
||||
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_send_wall(&self) -> Option<SystemTime> {
|
||||
match self {
|
||||
Self::EchoSent {
|
||||
client_send_wall, ..
|
||||
}
|
||||
| Self::Loss {
|
||||
client_send_wall, ..
|
||||
} => Some(*client_send_wall),
|
||||
Self::EchoReply(reply) | Self::Late { reply, .. } => reply.client_send_wall,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn client_receive_wall(&self) -> Option<SystemTime> {
|
||||
match self {
|
||||
Self::Duplicate {
|
||||
client_receive_wall,
|
||||
..
|
||||
} => Some(*client_receive_wall),
|
||||
Self::EchoReply(reply) | Self::Late { reply, .. } => Some(reply.client_receive_wall),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LifecycleRow {
|
||||
remote: SocketAddr,
|
||||
token: Option<u64>,
|
||||
event_wall: SystemTime,
|
||||
duration_ns: i128,
|
||||
interval_ns: i128,
|
||||
payload_length: i128,
|
||||
}
|
||||
|
||||
impl LifecycleRow {
|
||||
fn new(
|
||||
remote: SocketAddr,
|
||||
token: Option<u64>,
|
||||
negotiated: &NegotiationResult,
|
||||
event_wall: SystemTime,
|
||||
) -> Self {
|
||||
Self {
|
||||
remote,
|
||||
token,
|
||||
event_wall,
|
||||
duration_ns: negotiated
|
||||
.accepted
|
||||
.duration
|
||||
.map_or(0, |duration| duration.as_nanos() as i128),
|
||||
interval_ns: negotiated.accepted.interval.as_nanos() as i128,
|
||||
payload_length: i128::from(negotiated.accepted.length),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ReplyRow {
|
||||
seq: u32,
|
||||
remote: SocketAddr,
|
||||
client_send_wall: Option<SystemTime>,
|
||||
client_receive_wall: SystemTime,
|
||||
rtt: Option<RttSample>,
|
||||
server_timing: Option<ServerTiming>,
|
||||
one_way: Option<OneWayDelaySample>,
|
||||
received_stats: Option<ReceivedStatsSample>,
|
||||
bytes: usize,
|
||||
packet_meta: PacketMeta,
|
||||
}
|
||||
|
||||
// Only schema metadata is generated here; cell_for keeps rendering behavior explicit.
|
||||
macro_rules! columns {
|
||||
($($variant:ident($name:literal, [$($alias:literal),*], $width:expr, $align:ident, $description:literal);)*) => {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Column { $($variant),* }
|
||||
|
||||
const ALL_COLUMNS: &[Column] = &[$(Column::$variant),*];
|
||||
|
||||
impl Column {
|
||||
fn name(self) -> &'static str {
|
||||
match self { $(Self::$variant => $name),* }
|
||||
}
|
||||
|
||||
fn parse(input: &str) -> Option<Self> {
|
||||
match input {
|
||||
$($name $(| $alias)* => Some(Self::$variant),)*
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn aliases(self) -> &'static [&'static str] {
|
||||
match self { $(Self::$variant => &[$($alias),*]),* }
|
||||
}
|
||||
|
||||
fn description(self) -> &'static str {
|
||||
match self { $(Self::$variant => $description),* }
|
||||
}
|
||||
|
||||
fn table_width(self) -> usize {
|
||||
match self { $(Self::$variant => $width),* }
|
||||
}
|
||||
|
||||
fn align_right(self) -> bool {
|
||||
match self { $(Self::$variant => columns!(@align $align)),* }
|
||||
}
|
||||
}
|
||||
};
|
||||
(@align left) => { false };
|
||||
(@align right) => { true };
|
||||
}
|
||||
|
||||
// Variant(canonical name, aliases, table width, alignment, description).
|
||||
columns! {
|
||||
Target("target", [], 18, left, "logical CLI target label");
|
||||
Event("event", [], 17, left, "event kind");
|
||||
Seq("seq", [], 6, right, "probe sequence number");
|
||||
Remote("remote", [], 21, left, "remote socket address");
|
||||
Token("token", [], 18, left, "session token as hexadecimal");
|
||||
Rtt("rtt", [], 9, right, "human-readable effective RTT");
|
||||
RttUs("rtt_us", [], 10, right, "effective RTT in signed microseconds");
|
||||
RawRttUs("raw_rtt_us", [], 10, right, "raw client send-to-receive RTT in microseconds");
|
||||
EffectiveRttUs("effective_rtt_us", [], 16, right, "effective RTT in signed microseconds");
|
||||
AdjustedRttUs("adjusted_rtt_us", [], 15, right, "adjusted RTT in signed microseconds");
|
||||
ReceiveDelay("rd", ["receive_delay"], 9, right, "human-readable server-to-client delay");
|
||||
ReceiveDelayUs("rd_us", ["receive_delay_us"], 10, right, "server-to-client delay in signed microseconds");
|
||||
SendDelay("sd", ["send_delay"], 9, right, "human-readable client-to-server delay");
|
||||
SendDelayUs("sd_us", ["send_delay_us"], 10, right, "client-to-server delay in signed microseconds");
|
||||
Ipdv("ipdv", [], 9, right, "human-readable round-trip IPDV for adjacent samples");
|
||||
IpdvUs("ipdv_us", [], 10, right, "round-trip IPDV in microseconds");
|
||||
ServerProcessing("proc", ["server_processing"], 9, right, "human-readable server processing time");
|
||||
ServerProcessingUs("server_processing_us", [], 20, right, "server processing time in microseconds");
|
||||
Bytes("bytes", [], 10, right, "packet bytes for packet events");
|
||||
SendCallUs("send_call_us", [], 12, right, "send system call duration in microseconds");
|
||||
TimerErrorUs("timer_error_us", [], 14, right, "scheduled-vs-actual send timer error in microseconds");
|
||||
HighestSeen("highest_seen", [], 15, right, "highest sequence seen when a late reply arrived");
|
||||
ServerReceivedCount("server_received", ["server_received_count"], 15, right, "server-reported received packet count");
|
||||
ServerReceivedWindow("server_window", ["server_received_window"], 13, left, "server-reported received window as hexadecimal");
|
||||
Dscp("dscp", [], 4, right, "received packet DSCP codepoint");
|
||||
Ecn("ecn", [], 4, right, "received packet ECN bits");
|
||||
TrafficClass("traffic_class", [], 13, right, "received packet traffic class byte");
|
||||
KernelRxNs("kernel_rx_ns", [], 12, right, "kernel receive timestamp as Unix nanoseconds");
|
||||
WarningKind("warning_kind", [], 28, left, "warning classifier");
|
||||
Message("message", [], 24, left, "warning or lifecycle message");
|
||||
EventWallNs("event_wall_ns", [], 13, right, "event wall timestamp as Unix nanoseconds");
|
||||
ClientSendWallNs("client_send_wall_ns", [], 19, right, "client send wall timestamp as Unix nanoseconds");
|
||||
ClientReceiveWallNs("client_receive_wall_ns", [], 22, right, "client receive wall timestamp as Unix nanoseconds");
|
||||
DurationNs("duration_ns", [], 11, right, "negotiated test duration in nanoseconds");
|
||||
IntervalNs("interval_ns", [], 11, right, "negotiated probe interval in nanoseconds");
|
||||
PayloadLength("payload_length", [], 14, right, "negotiated payload length");
|
||||
ServerReceiveWallNs("server_receive_wall_ns", [], 22, right, "server receive wall timestamp in nanoseconds");
|
||||
ServerReceiveMonoNs("server_receive_mono_ns", [], 22, right, "server receive monotonic timestamp in nanoseconds");
|
||||
ServerSendWallNs("server_send_wall_ns", [], 19, right, "server send wall timestamp in nanoseconds");
|
||||
ServerSendMonoNs("server_send_mono_ns", [], 19, right, "server send monotonic timestamp in nanoseconds");
|
||||
ServerMidpointWallNs("server_midpoint_wall_ns", [], 23, right, "server midpoint wall timestamp in nanoseconds");
|
||||
ServerMidpointMonoNs("server_midpoint_mono_ns", [], 23, right, "server midpoint monotonic timestamp in nanoseconds");
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum CellValue {
|
||||
Text(String),
|
||||
Integer(i128),
|
||||
Unsigned(u128),
|
||||
Hex(u128),
|
||||
}
|
||||
|
||||
impl CellValue {
|
||||
fn text(value: impl Into<String>) -> Self {
|
||||
Self::Text(value.into())
|
||||
}
|
||||
|
||||
fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Text(value) => value.clone(),
|
||||
Self::Integer(value) => value.to_string(),
|
||||
Self::Unsigned(value) => value.to_string(),
|
||||
Self::Hex(value) => format!("{value:#x}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_json(&self, out: &mut String) {
|
||||
match self {
|
||||
Self::Text(value) => {
|
||||
out.push('"');
|
||||
write_json_string_content(out, value);
|
||||
out.push('"');
|
||||
}
|
||||
Self::Integer(value) => write!(out, "{value}").unwrap(),
|
||||
Self::Unsigned(value) => write!(out, "{value}").unwrap(),
|
||||
Self::Hex(value) => {
|
||||
out.push('"');
|
||||
write!(out, "{value:#x}").unwrap();
|
||||
out.push('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_columns(input: &str, format: OutputFormat, verbose: bool) -> Result<Vec<Column>, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("--columns requires at least one column".to_owned());
|
||||
}
|
||||
if trimmed == "all" {
|
||||
return Ok(ALL_COLUMNS.to_vec());
|
||||
}
|
||||
if trimmed == "default" {
|
||||
return Ok(default_columns(format, verbose));
|
||||
}
|
||||
|
||||
let mut columns = Vec::new();
|
||||
for raw in trimmed.split(',') {
|
||||
let name = raw.trim();
|
||||
if name.is_empty() {
|
||||
return Err("empty column in --columns list".to_owned());
|
||||
}
|
||||
let column = Column::parse(name).ok_or_else(|| {
|
||||
format!("unknown output column {name:?}; run --list-columns to see valid names")
|
||||
})?;
|
||||
columns.push(column);
|
||||
}
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
fn default_columns(format: OutputFormat, verbose: bool) -> Vec<Column> {
|
||||
match format {
|
||||
OutputFormat::Table => {
|
||||
let mut columns = vec![
|
||||
Column::Target,
|
||||
Column::Event,
|
||||
Column::Seq,
|
||||
Column::Rtt,
|
||||
Column::ReceiveDelay,
|
||||
Column::SendDelay,
|
||||
Column::Ipdv,
|
||||
Column::ServerProcessing,
|
||||
Column::Message,
|
||||
];
|
||||
if verbose {
|
||||
columns.extend([
|
||||
Column::Remote,
|
||||
Column::ServerReceivedCount,
|
||||
Column::ServerReceivedWindow,
|
||||
Column::RawRttUs,
|
||||
Column::AdjustedRttUs,
|
||||
Column::Bytes,
|
||||
Column::Dscp,
|
||||
Column::Ecn,
|
||||
]);
|
||||
}
|
||||
columns
|
||||
}
|
||||
OutputFormat::Csv | OutputFormat::Tsv | OutputFormat::Jsonl => ALL_COLUMNS.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_table_header(columns: &[Column]) -> String {
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| format_table_cell(column, Some(column.name().to_owned())))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext<'_>) -> String {
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let value = cell_for(row, *column, context).map(|value| value.display());
|
||||
format_table_cell(column, value)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn format_table_cell(column: &Column, value: Option<String>) -> String {
|
||||
let value = value.unwrap_or_else(|| ABSENT.to_owned());
|
||||
let width = column.table_width();
|
||||
let value = if matches!(column, Column::Target) {
|
||||
truncate_for_table(&value, width)
|
||||
} else {
|
||||
value
|
||||
};
|
||||
if column.align_right() {
|
||||
format!("{value:>width$}")
|
||||
} else {
|
||||
format!("{value:<width$}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortens `value` to fit within `max_width` display characters for TABLE
|
||||
/// presentation only. Values within the limit are returned unchanged. Longer
|
||||
/// values keep a prefix and end with `"..."` so the result is exactly
|
||||
/// `max_width` characters. Operates on `char`s (never raw byte indices) so a
|
||||
/// multibyte UTF-8 value cannot be split mid-code-point.
|
||||
///
|
||||
/// This is a presentation-only concern: callers must keep the untruncated
|
||||
/// value for CSV/TSV/JSONL and any non-display use.
|
||||
fn truncate_for_table(value: &str, max_width: usize) -> String {
|
||||
if value.chars().count() <= max_width {
|
||||
return value.to_owned();
|
||||
}
|
||||
let suffix = "...";
|
||||
let prefix_len = max_width.saturating_sub(suffix.len());
|
||||
let mut truncated: String = value.chars().take(prefix_len).collect();
|
||||
truncated.push_str(suffix);
|
||||
truncated
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DelimitedFormat {
|
||||
Csv,
|
||||
Tsv,
|
||||
}
|
||||
|
||||
fn render_delimited_row(
|
||||
row: &OutputRow,
|
||||
columns: &[Column],
|
||||
context: RenderContext<'_>,
|
||||
format: DelimitedFormat,
|
||||
) -> String {
|
||||
let separator = match format {
|
||||
DelimitedFormat::Csv => ",",
|
||||
DelimitedFormat::Tsv => "\t",
|
||||
};
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let value = cell_for(row, *column, context)
|
||||
.map(|value| value.display())
|
||||
.unwrap_or_default();
|
||||
match format {
|
||||
DelimitedFormat::Csv => escape_csv(&value),
|
||||
DelimitedFormat::Tsv => escape_tsv(&value),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(separator)
|
||||
}
|
||||
|
||||
fn render_jsonl_row(row: &OutputRow, columns: &[Column], context: RenderContext<'_>) -> String {
|
||||
let mut out = String::from("{");
|
||||
for (index, column) in columns.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push('"');
|
||||
write_json_string_content(&mut out, column.name());
|
||||
out.push_str("\":");
|
||||
if let Some(value) = cell_for(row, *column, context) {
|
||||
value.write_json(&mut out);
|
||||
} else {
|
||||
out.push_str("null");
|
||||
}
|
||||
}
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
fn cell_for(row: &OutputRow, column: Column, context: RenderContext<'_>) -> Option<CellValue> {
|
||||
match column {
|
||||
Column::Target => context.target.map(CellValue::text),
|
||||
Column::Event => Some(CellValue::text(row.event_name())),
|
||||
Column::Seq => row.seq().map(|seq| CellValue::Unsigned(u128::from(seq))),
|
||||
Column::Remote => row
|
||||
.remote()
|
||||
.map(|remote| CellValue::text(remote.to_string())),
|
||||
Column::Token => row.token().map(|token| CellValue::Hex(u128::from(token))),
|
||||
Column::Rtt => row.reply().and_then(|reply| {
|
||||
reply
|
||||
.rtt
|
||||
.map(|rtt| CellValue::text(format_signed_duration(rtt.effective)))
|
||||
}),
|
||||
Column::RttUs | Column::EffectiveRttUs => row.reply().and_then(|reply| {
|
||||
reply
|
||||
.rtt
|
||||
.map(|rtt| CellValue::Integer(signed_duration_us(rtt.effective)))
|
||||
}),
|
||||
Column::RawRttUs => row.reply().and_then(|reply| {
|
||||
reply
|
||||
.rtt
|
||||
.map(|rtt| CellValue::Unsigned(duration_us(rtt.raw)))
|
||||
}),
|
||||
Column::AdjustedRttUs => row.reply().and_then(|reply| {
|
||||
reply
|
||||
.rtt
|
||||
.and_then(|rtt| rtt.adjusted)
|
||||
.map(|adjusted| CellValue::Integer(signed_duration_us(adjusted)))
|
||||
}),
|
||||
Column::ReceiveDelay => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.server_to_client))
|
||||
.map(|value| CellValue::text(format_signed_duration(value))),
|
||||
Column::ReceiveDelayUs => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.server_to_client))
|
||||
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
||||
Column::SendDelay => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
||||
.map(|value| CellValue::text(format_signed_duration(value))),
|
||||
Column::SendDelayUs => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.one_way.and_then(|one_way| one_way.client_to_server))
|
||||
.map(|value| CellValue::Integer(signed_duration_us(value))),
|
||||
Column::Ipdv => row
|
||||
.seq()
|
||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
||||
.map(|pair| CellValue::text(format_duration(pair.rtt_ipdv))),
|
||||
Column::IpdvUs => row
|
||||
.seq()
|
||||
.and_then(|seq| ipdv_pair(context.stats, seq))
|
||||
.map(|pair| CellValue::Unsigned(duration_us(pair.rtt_ipdv))),
|
||||
Column::ServerProcessing => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
||||
.map(|value| CellValue::text(format_duration(value))),
|
||||
Column::ServerProcessingUs => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.server_timing.and_then(|timing| timing.processing))
|
||||
.map(|value| CellValue::Unsigned(duration_us(value))),
|
||||
Column::Bytes => row.bytes().map(|bytes| CellValue::Unsigned(bytes as u128)),
|
||||
Column::SendCallUs => match row {
|
||||
OutputRow::EchoSent { send_call, .. } => {
|
||||
Some(CellValue::Unsigned(duration_us(*send_call)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Column::TimerErrorUs => match row {
|
||||
OutputRow::EchoSent { timer_error, .. } => {
|
||||
timer_error.map(|error| CellValue::Unsigned(duration_us(error)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Column::HighestSeen => match row {
|
||||
OutputRow::Late { highest_seen, .. } => {
|
||||
Some(CellValue::Unsigned(u128::from(*highest_seen)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Column::ServerReceivedCount => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.received_stats.and_then(|stats| stats.count))
|
||||
.map(|value| CellValue::Unsigned(u128::from(value))),
|
||||
Column::ServerReceivedWindow => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.received_stats.and_then(|stats| stats.window))
|
||||
.map(|value| CellValue::Hex(u128::from(value))),
|
||||
Column::Dscp => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.packet_meta.dscp)
|
||||
.map(|value| CellValue::Unsigned(u128::from(value))),
|
||||
Column::Ecn => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.packet_meta.ecn)
|
||||
.map(|value| CellValue::Unsigned(u128::from(value))),
|
||||
Column::TrafficClass => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.packet_meta.traffic_class)
|
||||
.map(|value| CellValue::Unsigned(u128::from(value))),
|
||||
Column::KernelRxNs => row
|
||||
.reply()
|
||||
.and_then(|reply| reply.packet_meta.kernel_rx_timestamp)
|
||||
.and_then(wall_time_ns)
|
||||
.map(CellValue::Unsigned),
|
||||
Column::WarningKind => match row {
|
||||
OutputRow::Warning { kind, .. } => Some(CellValue::text(warning_kind(*kind))),
|
||||
_ => None,
|
||||
},
|
||||
Column::Message => row.message(context).map(CellValue::text),
|
||||
Column::EventWallNs => row
|
||||
.event_wall()
|
||||
.and_then(wall_time_ns)
|
||||
.map(CellValue::Unsigned),
|
||||
Column::ClientSendWallNs => row
|
||||
.client_send_wall()
|
||||
.and_then(wall_time_ns)
|
||||
.map(CellValue::Unsigned),
|
||||
Column::ClientReceiveWallNs => row
|
||||
.client_receive_wall()
|
||||
.and_then(wall_time_ns)
|
||||
.map(CellValue::Unsigned),
|
||||
Column::DurationNs => match row {
|
||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
|
||||
Some(CellValue::Integer(row.duration_ns))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Column::IntervalNs => match row {
|
||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
|
||||
Some(CellValue::Integer(row.interval_ns))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Column::PayloadLength => match row {
|
||||
OutputRow::SessionStarted(row) | OutputRow::NoTestCompleted(row) => {
|
||||
Some(CellValue::Integer(row.payload_length))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Column::ServerReceiveWallNs => server_timing_i64(row, |timing| timing.receive_wall_ns),
|
||||
Column::ServerReceiveMonoNs => server_timing_i64(row, |timing| timing.receive_mono_ns),
|
||||
Column::ServerSendWallNs => server_timing_i64(row, |timing| timing.send_wall_ns),
|
||||
Column::ServerSendMonoNs => server_timing_i64(row, |timing| timing.send_mono_ns),
|
||||
Column::ServerMidpointWallNs => server_timing_i64(row, |timing| timing.midpoint_wall_ns),
|
||||
Column::ServerMidpointMonoNs => server_timing_i64(row, |timing| timing.midpoint_mono_ns),
|
||||
}
|
||||
}
|
||||
|
||||
fn server_timing_i64(
|
||||
row: &OutputRow,
|
||||
select: impl FnOnce(ServerTiming) -> Option<i64>,
|
||||
) -> Option<CellValue> {
|
||||
row.reply()
|
||||
.and_then(|reply| reply.server_timing)
|
||||
.and_then(select)
|
||||
.map(|value| CellValue::Integer(i128::from(value)))
|
||||
}
|
||||
|
||||
fn ipdv_pair(stats: Option<&EventStatsUpdate>, seq: u32) -> Option<&IpdvPairUpdate> {
|
||||
let stats = stats?;
|
||||
stats
|
||||
.ipdv_pairs
|
||||
.iter()
|
||||
.find(|pair| pair.current_seq == seq)
|
||||
.or_else(|| {
|
||||
stats
|
||||
.ipdv_pairs
|
||||
.iter()
|
||||
.find(|pair| pair.previous_seq == seq)
|
||||
})
|
||||
}
|
||||
|
||||
fn duration_us(duration: Duration) -> u128 {
|
||||
duration.as_micros()
|
||||
}
|
||||
|
||||
fn signed_duration_us(duration: SignedDuration) -> i128 {
|
||||
duration.as_micros()
|
||||
}
|
||||
|
||||
fn wall_time_ns(wall: SystemTime) -> Option<u128> {
|
||||
wall.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_nanos())
|
||||
}
|
||||
|
||||
fn warning_kind(kind: WarningKind) -> &'static str {
|
||||
match kind {
|
||||
WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet",
|
||||
WarningKind::WrongToken => "wrong_token",
|
||||
WarningKind::UntrackedReply => "untracked_reply",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_csv(value: &str) -> String {
|
||||
if value.contains([',', '"', '\n', '\r']) {
|
||||
let escaped = value.replace('"', "\"\"");
|
||||
format!("\"{escaped}\"")
|
||||
} else {
|
||||
value.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_tsv(value: &str) -> String {
|
||||
value.replace(['\t', '\n', '\r'], " ")
|
||||
}
|
||||
|
||||
fn write_json_string_content(out: &mut String, value: &str) {
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0c}' => out.push_str("\\f"),
|
||||
c if c < '\u{20}' => write!(out, "\\u{:04x}", c as u32).unwrap(),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashSet, VecDeque},
|
||||
io::{self, BufRead, Write},
|
||||
sync::Arc,
|
||||
thread,
|
||||
};
|
||||
|
||||
use irtt_client::{
|
||||
managed::{
|
||||
ManagedClient, ManagedCommandApplyError, ManagedEndReason, ManagedEvent, ManagedStatus,
|
||||
ManagedTargetConfig, ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance,
|
||||
},
|
||||
ClientEvent,
|
||||
};
|
||||
|
||||
use super::{args::ClientArgs, output::OutputConfig};
|
||||
|
||||
use crate::shared::client::{
|
||||
expected_probe_count, parse_stdin_target_set,
|
||||
session::{
|
||||
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||
request_managed_stop_once, should_print_final_summary,
|
||||
},
|
||||
worker::ManagedWorker,
|
||||
ManagedRunSetup, STDIN_MAX_DESIRED_TARGETS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
|
||||
use irtt_stats::{EventStatsUpdate, StatsCollector, StatsConfig};
|
||||
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
|
||||
const GIB: u64 = 1024 * MIB;
|
||||
|
||||
const FINITE_STATS_MEMORY_WARNING_BYTES: u64 = 128 * MIB;
|
||||
|
||||
const FINITE_STATS_MEMORY_STRONG_WARNING_BYTES: u64 = 512 * MIB;
|
||||
|
||||
const FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES: u64 = GIB;
|
||||
const MAX_STDIN_RECORD_BYTES: usize = 64 * 1024;
|
||||
|
||||
pub async fn run_stream(
|
||||
args: ClientArgs,
|
||||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if args.list_columns {
|
||||
print!("{}", OutputConfig::list_columns());
|
||||
return Ok(());
|
||||
}
|
||||
let setup = args
|
||||
.prepare()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let multi_target = setup.is_multi_target();
|
||||
let output_config = OutputConfig::new(
|
||||
args.format,
|
||||
args.columns.as_deref(),
|
||||
args.header,
|
||||
args.verbose,
|
||||
)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
if setup.stdin_controlled {
|
||||
return run_stdin_stream(setup, output_config, shutdown).await;
|
||||
}
|
||||
let continuous = args.is_continuous();
|
||||
let target_count = setup.target_count();
|
||||
|
||||
if let Some(warning) = finite_stats_memory_warning(&args, target_count) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
|
||||
let mut events = handle.subscribe()?;
|
||||
let mut status = handle.subscribe_status();
|
||||
let owner = ManagedWorker::start(task, handle.clone())?;
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
let mut stream_output = StreamOutput {
|
||||
config: output_config,
|
||||
header_printed: false,
|
||||
print_final_summary: false,
|
||||
show_running_only_summary_note: false,
|
||||
out: &mut stdout,
|
||||
};
|
||||
|
||||
let mut stats = setup
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
(
|
||||
target.label.clone(),
|
||||
StatsCollector::new(stats_config(continuous)),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut terminal_targets = HashSet::new();
|
||||
let mut dropped_events = 0_u64;
|
||||
let mut interrupted = false;
|
||||
let mut stop_requested = false;
|
||||
loop {
|
||||
let snapshot = status.borrow_and_update().clone();
|
||||
if *shutdown.borrow() {
|
||||
interrupted = true;
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
}
|
||||
if request_managed_stop_for_peer_close(
|
||||
continuous,
|
||||
interrupted,
|
||||
snapshot.peer_closed_target_outcomes,
|
||||
&mut stop_requested,
|
||||
) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
if snapshot.final_outcome.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::select! {
|
||||
_ = shutdown.changed(), if !interrupted => {}
|
||||
changed = status.changed() => { if changed.is_err() { break; } }
|
||||
event = events.recv() => match event {
|
||||
Ok(event) => process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
dropped_events = dropped_events.saturating_add(count);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
if interrupted {
|
||||
eprintln!("interrupted, closing managed run...");
|
||||
}
|
||||
let outcome = owner.join().await?;
|
||||
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||
process_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
|
||||
})?;
|
||||
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
if outcome.discarded_target_outcomes != 0 {
|
||||
eprintln!(
|
||||
"irtt-rs: warning: {} final target outcomes were discarded",
|
||||
outcome.discarded_target_outcomes
|
||||
);
|
||||
}
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
if terminal_targets.insert(target.target.clone()) {
|
||||
report_target_failure(target);
|
||||
}
|
||||
}
|
||||
interrupted |= *shutdown.borrow();
|
||||
let terminal_error = match &outcome.end_reason {
|
||||
ManagedEndReason::DriverFailed(failure) => {
|
||||
Some(format!("managed driver failed: {failure}"))
|
||||
}
|
||||
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
||||
.or_else(|| {
|
||||
(!interrupted
|
||||
&& outcome.successful_target_outcomes == 0
|
||||
&& outcome.failed_target_outcomes > 0)
|
||||
.then(|| {
|
||||
format!(
|
||||
"no managed target completed successfully ({} failed)",
|
||||
outcome.failed_target_outcomes
|
||||
)
|
||||
})
|
||||
}),
|
||||
};
|
||||
stream_output.print_final_summary = should_print_final_summary(continuous, interrupted);
|
||||
stream_output.show_running_only_summary_note =
|
||||
continuous && interrupted && stream_output.print_final_summary;
|
||||
for target in &setup.targets {
|
||||
let target_stats = stats.get(&target.label).expect(
|
||||
"every prepared target must have a stats collector inserted before the run begins",
|
||||
);
|
||||
if multi_target
|
||||
&& stream_output.print_final_summary
|
||||
&& stream_output.config.prints_summary()
|
||||
{
|
||||
writeln!(stream_output.out)?;
|
||||
writeln!(stream_output.out, "target: {}", target.label)?;
|
||||
}
|
||||
stream_output.print_summary(target_stats)?;
|
||||
}
|
||||
stream_output.out.flush()?;
|
||||
if let Some(error) = terminal_error {
|
||||
return Err(error.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum StdinUpdate {
|
||||
Targets(Vec<ManagedTargetConfig>),
|
||||
Stop(StdinStop),
|
||||
}
|
||||
|
||||
fn read_stdin_record<R: BufRead>(
|
||||
reader: &mut R,
|
||||
buffer: &mut Vec<u8>,
|
||||
) -> io::Result<Option<String>> {
|
||||
buffer.clear();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
if buffer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if buffer.len() > MAX_STDIN_RECORD_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
return std::str::from_utf8(buffer)
|
||||
.map(str::to_owned)
|
||||
.map(Some)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||
}
|
||||
|
||||
if buffer.len() == MAX_STDIN_RECORD_BYTES + 1 {
|
||||
if available.first() == Some(&b'\n') && buffer.last() == Some(&b'\r') {
|
||||
reader.consume(1);
|
||||
buffer.pop();
|
||||
return std::str::from_utf8(buffer)
|
||||
.map(str::to_owned)
|
||||
.map(Some)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||
}
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(newline) = available.iter().position(|byte| *byte == b'\n') {
|
||||
let before_newline = &available[..newline];
|
||||
if buffer.len().saturating_add(before_newline.len()) > MAX_STDIN_RECORD_BYTES + 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
buffer.extend_from_slice(before_newline);
|
||||
reader.consume(newline + 1);
|
||||
if buffer.last() == Some(&b'\r') {
|
||||
buffer.pop();
|
||||
}
|
||||
if buffer.len() > MAX_STDIN_RECORD_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
return std::str::from_utf8(buffer)
|
||||
.map(str::to_owned)
|
||||
.map(Some)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "stdin is not UTF-8"));
|
||||
}
|
||||
|
||||
let remaining = MAX_STDIN_RECORD_BYTES + 1 - buffer.len();
|
||||
let take = available.len().min(remaining);
|
||||
buffer.extend_from_slice(&available[..take]);
|
||||
reader.consume(take);
|
||||
if buffer.len() > MAX_STDIN_RECORD_BYTES && buffer.last() != Some(&b'\r') {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"stdin target record exceeds the maximum size",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_stdin_target_sets<R: BufRead>(
|
||||
reader: &mut R,
|
||||
updates: &tokio::sync::watch::Sender<StdinUpdate>,
|
||||
) {
|
||||
let mut line = 0_u64;
|
||||
let mut record = Vec::with_capacity(MAX_STDIN_RECORD_BYTES + 1);
|
||||
loop {
|
||||
match read_stdin_record(reader, &mut record) {
|
||||
Ok(Some(record)) => {
|
||||
line = line.saturating_add(1);
|
||||
if record.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match parse_stdin_target_set(&record, STDIN_MAX_DESIRED_TARGETS) {
|
||||
Ok(targets) => {
|
||||
updates.send_replace(StdinUpdate::Targets(
|
||||
targets.into_iter().map(|target| target.managed).collect(),
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
|
||||
"invalid --targets-stdin line {line}: {error}"
|
||||
))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
updates.send_replace(StdinUpdate::Stop(StdinStop::Eof));
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
updates.send_replace(StdinUpdate::Stop(StdinStop::Fatal(format!(
|
||||
"failed to read --targets-stdin line {}",
|
||||
line + 1
|
||||
))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stdin_target_reader(updates: tokio::sync::watch::Sender<StdinUpdate>) -> io::Result<()> {
|
||||
thread::Builder::new()
|
||||
.name("irtt-targets-stdin".to_owned())
|
||||
.spawn(move || {
|
||||
let stdin = io::stdin();
|
||||
read_stdin_target_sets(&mut stdin.lock(), &updates);
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
struct BoundedTargetSet {
|
||||
limit: usize,
|
||||
order: VecDeque<TargetInstance>,
|
||||
members: HashSet<TargetInstance>,
|
||||
}
|
||||
|
||||
impl BoundedTargetSet {
|
||||
fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
limit,
|
||||
order: VecDeque::with_capacity(limit),
|
||||
members: HashSet::with_capacity(limit),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, target: TargetInstance) -> bool {
|
||||
if !self.members.insert(target.clone()) {
|
||||
return false;
|
||||
}
|
||||
if self.order.len() == self.limit {
|
||||
let evicted = self
|
||||
.order
|
||||
.pop_front()
|
||||
.expect("bounded target order is nonempty");
|
||||
self.members.remove(&evicted);
|
||||
}
|
||||
self.order.push_back(target);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn process_stdin_event<W: Write>(
|
||||
event: ManagedEvent,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
terminal_targets: &mut BoundedTargetSet,
|
||||
) -> io::Result<()> {
|
||||
match event {
|
||||
ManagedEvent::TargetStateChanged { target, .. } => {
|
||||
stats
|
||||
.entry(target)
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
}
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let collector = stats
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
let update = collector.process(&event);
|
||||
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome }
|
||||
if terminal_targets.insert(outcome.target.clone()) =>
|
||||
{
|
||||
report_target_failure(&outcome);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reconcile_stdin_stats(
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
status: &ManagedStatus,
|
||||
final_summary_targets: Option<&BTreeSet<TargetInstance>>,
|
||||
) {
|
||||
let live = status
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.target.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
stats.retain(|target, _| {
|
||||
live.contains(target)
|
||||
|| final_summary_targets.is_some_and(|targets| targets.contains(target))
|
||||
});
|
||||
}
|
||||
|
||||
fn snapshot_stdin_summary_targets(
|
||||
stats: &mut BTreeMap<TargetInstance, StatsCollector>,
|
||||
status: &ManagedStatus,
|
||||
) -> BTreeSet<TargetInstance> {
|
||||
let targets = status
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| target.target.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for target in &targets {
|
||||
stats
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(true)));
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum StdinStop {
|
||||
Interrupted,
|
||||
Eof,
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
async fn run_stdin_stream(
|
||||
setup: ManagedRunSetup,
|
||||
output_config: OutputConfig,
|
||||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
let (task, handle) = ManagedClient::task(setup.managed_config(), setup.managed_targets())?;
|
||||
let mut events = handle.subscribe()?;
|
||||
let mut status = handle.subscribe_status();
|
||||
let owner = ManagedWorker::start(task, handle.clone())?;
|
||||
let (updates, mut stdin) = tokio::sync::watch::channel(StdinUpdate::Targets(Vec::new()));
|
||||
spawn_stdin_target_reader(updates)?;
|
||||
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
let mut stream_output = StreamOutput {
|
||||
config: output_config,
|
||||
header_printed: false,
|
||||
print_final_summary: false,
|
||||
show_running_only_summary_note: false,
|
||||
out: &mut stdout,
|
||||
};
|
||||
let mut stats = BTreeMap::new();
|
||||
let mut terminal_targets = BoundedTargetSet::new(STDIN_OUTCOME_HISTORY_LIMIT);
|
||||
let mut stdin_changed = false;
|
||||
let mut desired = None;
|
||||
let mut pending = None;
|
||||
let mut submitted = Vec::new();
|
||||
let mut retry_after = None;
|
||||
let mut dropped_events = 0_u64;
|
||||
let mut stop_requested = false;
|
||||
let mut stop = None;
|
||||
let mut summary_targets = None;
|
||||
loop {
|
||||
let snapshot = status.borrow_and_update().clone();
|
||||
if stop.is_none() {
|
||||
if *shutdown.borrow() {
|
||||
stop = Some(StdinStop::Interrupted);
|
||||
} else if stdin_changed || stdin.has_changed().unwrap_or(true) {
|
||||
stdin_changed = false;
|
||||
match stdin.borrow_and_update().clone() {
|
||||
StdinUpdate::Targets(targets) => {
|
||||
desired = Some(targets);
|
||||
retry_after = None;
|
||||
}
|
||||
StdinUpdate::Stop(reason) => stop = Some(reason),
|
||||
}
|
||||
}
|
||||
if stop.is_none()
|
||||
&& pending.is_none()
|
||||
&& !retry_after
|
||||
.as_ref()
|
||||
.is_some_and(|previous| Arc::ptr_eq(previous, &snapshot))
|
||||
{
|
||||
if let Some(targets) = desired.take() {
|
||||
submitted = targets;
|
||||
match handle.update_targets(submitted.clone()) {
|
||||
Ok(receipt) => {
|
||||
pending = Some(receipt);
|
||||
retry_after = Some(snapshot.clone());
|
||||
}
|
||||
Err(_) => {
|
||||
stop = Some(StdinStop::Fatal(
|
||||
"failed to submit --targets-stdin update".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if stop.is_some() {
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &snapshot));
|
||||
if request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
}
|
||||
}
|
||||
reconcile_stdin_stats(&mut stats, &snapshot, summary_targets.as_ref());
|
||||
if snapshot.final_outcome.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::select! {
|
||||
_ = shutdown.changed(), if stop.is_none() => {}
|
||||
_ = stdin.changed(), if stop.is_none() => { stdin_changed = true; }
|
||||
changed = status.changed() => { if changed.is_err() { break; } }
|
||||
result = async { pending.as_mut().expect("guarded pending receipt").await }, if pending.is_some() && stop.is_none() => {
|
||||
pending = None;
|
||||
match result {
|
||||
Ok(_) => retry_after = None,
|
||||
Err(ManagedCommandApplyError::LiveGenerationLimitExceeded { .. }) => {
|
||||
// A newer desired set supersedes this rejected set. Otherwise,
|
||||
// retry only after the driver's status advances from submission.
|
||||
if desired.is_none() {
|
||||
desired = Some(std::mem::take(&mut submitted));
|
||||
} else {
|
||||
retry_after = None;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
stop = Some(StdinStop::Fatal("--targets-stdin update was rejected".to_owned()));
|
||||
summary_targets = Some(snapshot_stdin_summary_targets(&mut stats, &status.borrow()));
|
||||
if request_managed_stop_once(&mut stop_requested) { drop(handle.stop()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
event = events.recv() => match event {
|
||||
Ok(event) => {
|
||||
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)?;
|
||||
reconcile_stdin_stats(&mut stats, &status.borrow(), summary_targets.as_ref());
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
dropped_events = dropped_events.saturating_add(count);
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = owner.join().await?;
|
||||
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||
process_stdin_event(event, &mut stream_output, &mut stats, &mut terminal_targets)
|
||||
})?;
|
||||
if let Some(warning) = dropped_event_warning(dropped_events) {
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
if terminal_targets.insert(target.target.clone()) {
|
||||
report_target_failure(target);
|
||||
}
|
||||
}
|
||||
if outcome.discarded_target_outcomes != 0 {
|
||||
eprintln!(
|
||||
"irtt-rs: warning: {} final target outcomes were discarded",
|
||||
outcome.discarded_target_outcomes
|
||||
);
|
||||
}
|
||||
|
||||
let print_summary = matches!(stop, Some(StdinStop::Interrupted | StdinStop::Eof));
|
||||
stream_output.print_final_summary = print_summary;
|
||||
stream_output.show_running_only_summary_note = print_summary;
|
||||
if let Some(summary_targets) = summary_targets {
|
||||
let multi_target = summary_targets.len() > 1;
|
||||
for target in summary_targets {
|
||||
let stats = stats
|
||||
.get(&target)
|
||||
.expect("summary snapshot retains every selected target collector");
|
||||
if multi_target && stream_output.config.prints_summary() {
|
||||
writeln!(stream_output.out)?;
|
||||
writeln!(
|
||||
stream_output.out,
|
||||
"target: {} (generation {})",
|
||||
target.id, target.generation
|
||||
)?;
|
||||
}
|
||||
stream_output.print_summary(stats)?;
|
||||
}
|
||||
}
|
||||
stream_output.out.flush()?;
|
||||
|
||||
match stop {
|
||||
Some(StdinStop::Fatal(error)) => Err(error.into()),
|
||||
_ => match outcome.end_reason {
|
||||
ManagedEndReason::DriverFailed(failure) => {
|
||||
Err(format!("managed driver failed: {failure}").into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn process_event<W: Write>(
|
||||
event: ManagedEvent,
|
||||
stream_output: &mut StreamOutput<'_, W>,
|
||||
stats: &mut BTreeMap<String, StatsCollector>,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
) -> io::Result<()> {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let collector = stats
|
||||
.entry(target.id.as_str().to_owned())
|
||||
.or_insert_with(|| StatsCollector::new(stats_config(false)));
|
||||
let update = collector.process(&event);
|
||||
stream_output.print_event(&event, Some(target.id.as_str()), &update)?;
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome } => {
|
||||
terminal_targets.insert(outcome.target.clone());
|
||||
report_target_failure(&outcome);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report_target_failure(target: &ManagedTargetOutcome) {
|
||||
for message in target_failure_messages(target) {
|
||||
eprintln!("{message}");
|
||||
}
|
||||
}
|
||||
|
||||
fn target_failure_messages(target: &ManagedTargetOutcome) -> Vec<String> {
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let ManagedTargetEndReason::Failed(failure) = &target.end_reason {
|
||||
messages.push(format!(
|
||||
"irtt-rs: target {} failed ({} {}): {}",
|
||||
target.target.id, failure.phase, failure.kind, failure.message
|
||||
));
|
||||
}
|
||||
if let Some(failure) = &target.cleanup_failure {
|
||||
messages.push(format!(
|
||||
"irtt-rs: target {} cleanup failed ({} {}): {}",
|
||||
target.target.id, failure.phase, failure.kind, failure.message
|
||||
));
|
||||
}
|
||||
messages
|
||||
}
|
||||
fn dropped_event_warning(dropped_events: u64) -> Option<String> {
|
||||
(dropped_events > 0).then(|| format!("irtt-rs: warning: dropped {dropped_events} managed run event{}; output and statistics may be incomplete", if dropped_events == 1 { "" } else { "s" }))
|
||||
}
|
||||
|
||||
struct StreamOutput<'a, W: Write> {
|
||||
config: OutputConfig,
|
||||
header_printed: bool,
|
||||
print_final_summary: bool,
|
||||
show_running_only_summary_note: bool,
|
||||
out: &'a mut W,
|
||||
}
|
||||
impl<W: Write> StreamOutput<'_, W> {
|
||||
fn print_event(
|
||||
&mut self,
|
||||
event: &ClientEvent,
|
||||
target: Option<&str>,
|
||||
update: &EventStatsUpdate,
|
||||
) -> io::Result<()> {
|
||||
self.print_header()?;
|
||||
if let Some(line) = self.config.render_event(event, target, Some(update)) {
|
||||
writeln!(self.out, "{line}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn print_header(&mut self) -> io::Result<()> {
|
||||
if self.header_printed {
|
||||
return Ok(());
|
||||
}
|
||||
self.header_printed = true;
|
||||
if let Some(header) = self.config.render_header() {
|
||||
writeln!(self.out, "{header}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_summary(&mut self, stats: &StatsCollector) -> io::Result<()> {
|
||||
if self.print_final_summary && self.config.prints_summary() {
|
||||
write!(
|
||||
self.out,
|
||||
"{}",
|
||||
crate::cmd::client::summary::format_summary_with_options(
|
||||
&stats.snapshot(),
|
||||
crate::cmd::client::summary::SummaryFormatOptions {
|
||||
verbose: self.config.summary_verbose(),
|
||||
show_running_only_note: self.show_running_only_summary_note
|
||||
}
|
||||
)
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn stats_config(continuous: bool) -> StatsConfig {
|
||||
if continuous {
|
||||
StatsConfig::continuous()
|
||||
} else {
|
||||
StatsConfig::finite()
|
||||
}
|
||||
}
|
||||
fn finite_stats_memory_warning(args: &ClientArgs, target_count: usize) -> Option<String> {
|
||||
if args.is_continuous() || args.duration.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let target_count = u64::try_from(target_count).unwrap_or(u64::MAX);
|
||||
let total_probe_count =
|
||||
expected_probe_count(args.duration, args.interval).saturating_mul(target_count);
|
||||
// Ask the stats crate what the configuration this run will actually use
|
||||
// retains; the CLI owns the probe count and the thresholds, not the
|
||||
// retention model.
|
||||
let estimated_bytes = stats_config(false).estimated_retained_bytes(total_probe_count);
|
||||
if estimated_bytes < FINITE_STATS_MEMORY_WARNING_BYTES {
|
||||
return None;
|
||||
}
|
||||
let formatted = if estimated_bytes >= GIB {
|
||||
format!("{} GiB", estimated_bytes.saturating_add(GIB / 2) / GIB)
|
||||
} else {
|
||||
format!("{} MiB", estimated_bytes.saturating_add(MIB / 2) / MIB)
|
||||
};
|
||||
let guidance = if estimated_bytes >= FINITE_STATS_MEMORY_VERY_STRONG_WARNING_BYTES {
|
||||
"this may be unsuitable on memory-constrained systems"
|
||||
} else if estimated_bytes >= FINITE_STATS_MEMORY_STRONG_WARNING_BYTES {
|
||||
"consider shortening the run, increasing the interval, or using continuous mode"
|
||||
} else {
|
||||
"use continuous mode for bounded-memory long-running tests"
|
||||
};
|
||||
Some(format!("irtt-rs: warning: finite exact statistics may retain about {formatted} for this run; {guidance}"))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use irtt_stats::{Snapshot, TimeStats};
|
||||
|
||||
use crate::cmd::format::{
|
||||
format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt,
|
||||
format_optional_ns_i128 as format_ns_i128, format_percent,
|
||||
};
|
||||
|
||||
pub fn format_summary(summary: &Snapshot) -> String {
|
||||
format_summary_with_options(summary, SummaryFormatOptions::default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct SummaryFormatOptions {
|
||||
pub verbose: bool,
|
||||
pub show_running_only_note: bool,
|
||||
}
|
||||
|
||||
pub fn format_summary_with_options(summary: &Snapshot, options: SummaryFormatOptions) -> String {
|
||||
let mut out = String::new();
|
||||
let packets = summary.packets;
|
||||
let loss = summary.loss;
|
||||
|
||||
writeln!(out).unwrap();
|
||||
writeln!(out, "irtt-rs summary").unwrap();
|
||||
if options.show_running_only_note {
|
||||
writeln!(
|
||||
out,
|
||||
"note: medians unavailable in continuous mode; running statistics are bounded-memory"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(out).unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
" {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||
"Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(out, " {}", "-".repeat(82)).unwrap();
|
||||
|
||||
write_time_row(&mut out, "RTT", &summary.rtt.primary);
|
||||
if options.verbose {
|
||||
write_time_row(&mut out, "raw RTT", &summary.rtt.raw);
|
||||
write_time_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
|
||||
}
|
||||
write_time_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
|
||||
write_time_row(&mut out, "send IPDV", &summary.ipdv.send);
|
||||
write_time_row(&mut out, "receive IPDV", &summary.ipdv.receive);
|
||||
write_time_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
|
||||
write_time_row(
|
||||
&mut out,
|
||||
"receive delay",
|
||||
&summary.one_way_delay.receive_delay,
|
||||
);
|
||||
write_time_row(
|
||||
&mut out,
|
||||
"server processing",
|
||||
&summary.server_processing.processing,
|
||||
);
|
||||
write_time_row(&mut out, "send call", &summary.send_call);
|
||||
write_time_row(&mut out, "timer error", &summary.timer_error);
|
||||
|
||||
writeln!(out).unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
"packets: sent={} received={} unique={} lost={} loss={}",
|
||||
packets.packets_sent,
|
||||
packets.packets_received,
|
||||
packets.unique_replies,
|
||||
loss.lost_packets,
|
||||
format_percent(loss.packet_loss_percent)
|
||||
)
|
||||
.unwrap();
|
||||
if packets.duplicates != 0 || packets.late_packets != 0 {
|
||||
writeln!(
|
||||
out,
|
||||
"replies: duplicates={} ({}) late={} ({})",
|
||||
packets.duplicates,
|
||||
format_percent(loss.duplicate_percent),
|
||||
packets.late_packets,
|
||||
format_percent(loss.late_packets_percent)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"bytes: sent={} received={}",
|
||||
packets.bytes_sent, packets.bytes_received
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
|
||||
write!(out, "server:").unwrap();
|
||||
if let Some(count) = packets.server_packets_received {
|
||||
write!(out, " received={count}").unwrap();
|
||||
}
|
||||
if let Some(window) = packets.server_received_window {
|
||||
write!(out, " window={window:#x}").unwrap();
|
||||
}
|
||||
writeln!(out).unwrap();
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn write_time_row(out: &mut String, label: &str, value: &TimeStats) {
|
||||
if value.count == 0 {
|
||||
return;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||
value.count,
|
||||
format_ns_i128(value.min_ns),
|
||||
format_ns_f64(value.mean_ns),
|
||||
format_ns_f64_opt(value.median_ns),
|
||||
format_ns_i128(value.max_ns),
|
||||
format_ns_f64(value.stddev_ns())
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Scalar presentation vocabulary shared by the CLI frontends.
|
||||
//!
|
||||
//! Stream output, the run summary, and the TUI display different metrics in
|
||||
//! different layouts, but they render the same *scalars*: nanosecond-scale
|
||||
//! durations, percentages, and counts. This module owns those spellings so the
|
||||
//! frontends cannot drift apart on units, rounding, or how an absent value is
|
||||
//! rendered.
|
||||
//!
|
||||
//! It is presentation only. Nothing here belongs in `irtt-stats`, and it is
|
||||
//! deliberately not a general formatting framework: it holds exactly the
|
||||
//! vocabulary more than one frontend needs.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
use irtt_client::SignedDuration;
|
||||
|
||||
/// Rendering used wherever a scalar is unavailable.
|
||||
pub(crate) const ABSENT: &str = "-";
|
||||
|
||||
/// Format a nanosecond scalar.
|
||||
///
|
||||
/// Unit selection and rounding are one policy for every frontend:
|
||||
///
|
||||
/// | magnitude | unit | precision |
|
||||
/// |-----------|------|-----------|
|
||||
/// | `< 1µs` | `ns` | whole |
|
||||
/// | `< 1ms` | `µs` | 1 decimal |
|
||||
/// | `< 1s` | `ms` | 1 decimal |
|
||||
/// | `>= 1s` | `s` | 3 decimals |
|
||||
///
|
||||
/// Negative values are meaningful (clock offsets, one-way deltas, IPDV), so
|
||||
/// the unit is selected from the magnitude and the sign is preserved. Values
|
||||
/// that are not finite render as [`ABSENT`].
|
||||
pub(crate) fn format_ns_f64(value: f64) -> String {
|
||||
if !value.is_finite() {
|
||||
return ABSENT.to_owned();
|
||||
}
|
||||
let sign = if value.is_sign_negative() { "-" } else { "" };
|
||||
let magnitude = value.abs();
|
||||
if magnitude < 1_000.0 {
|
||||
format!("{sign}{magnitude:.0}ns")
|
||||
} else if magnitude < 1_000_000.0 {
|
||||
format!("{sign}{:.1}µs", magnitude / 1_000.0)
|
||||
} else if magnitude < 1_000_000_000.0 {
|
||||
format!("{sign}{:.1}ms", magnitude / 1_000_000.0)
|
||||
} else {
|
||||
format!("{sign}{:.3}s", magnitude / 1_000_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an exact nanosecond scalar with [`format_ns_f64`]'s policy.
|
||||
pub(crate) fn format_ns_i128(value: i128) -> String {
|
||||
let sign = if value < 0 { "-" } else { "" };
|
||||
let magnitude = value.saturating_abs() as f64;
|
||||
format!("{sign}{}", format_ns_f64(magnitude))
|
||||
}
|
||||
|
||||
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||
pub(crate) fn format_optional_ns_i128(value: Option<i128>) -> String {
|
||||
value.map(format_ns_i128).unwrap_or_else(absent)
|
||||
}
|
||||
|
||||
/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`].
|
||||
#[cfg(feature = "client")]
|
||||
pub(crate) fn format_optional_ns_f64(value: Option<f64>) -> String {
|
||||
value.map(format_ns_f64).unwrap_or_else(absent)
|
||||
}
|
||||
|
||||
/// Format a duration as a nanosecond scalar.
|
||||
pub(crate) fn format_duration(value: Duration) -> String {
|
||||
format_ns_i128(i128::try_from(value.as_nanos()).unwrap_or(i128::MAX))
|
||||
}
|
||||
|
||||
/// Format a signed duration as a nanosecond scalar, preserving its sign.
|
||||
#[cfg(feature = "client")]
|
||||
pub(crate) fn format_signed_duration(value: SignedDuration) -> String {
|
||||
format_ns_i128(value.as_nanos())
|
||||
}
|
||||
|
||||
/// Format a percentage with two decimals; non-finite values render as
|
||||
/// [`ABSENT`].
|
||||
pub(crate) fn format_percent(value: f64) -> String {
|
||||
if value.is_finite() {
|
||||
format!("{value:.2}%")
|
||||
} else {
|
||||
ABSENT.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format `value` as a percentage of `total`, capped at 100%.
|
||||
///
|
||||
/// A zero total has no percentage to report and renders as [`ABSENT`].
|
||||
#[cfg(feature = "tui")]
|
||||
pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String {
|
||||
if total == 0 {
|
||||
ABSENT.to_owned()
|
||||
} else {
|
||||
format_percent((value as f64 / total as f64 * 100.0).min(100.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a scalar count.
|
||||
#[cfg(feature = "tui")]
|
||||
pub(crate) fn format_count(value: u64) -> String {
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
/// Format an optional scalar count, rendering `None` as [`ABSENT`].
|
||||
#[cfg(feature = "tui")]
|
||||
pub(crate) fn format_optional_count(value: Option<u64>) -> String {
|
||||
value.map(format_count).unwrap_or_else(absent)
|
||||
}
|
||||
|
||||
fn absent() -> String {
|
||||
ABSENT.to_owned()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#[cfg(feature = "client")]
|
||||
pub mod client;
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
mod format;
|
||||
#[cfg(feature = "server")]
|
||||
pub mod server;
|
||||
#[cfg(feature = "tui")]
|
||||
pub mod tui;
|
||||
|
||||
// Additional command applets belong here behind their own feature gate.
|
||||
@@ -0,0 +1,259 @@
|
||||
use std::{
|
||||
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use irtt_server::{ServerConfig, TimestampAllowance};
|
||||
|
||||
/// The ordinary IRTT port, used to build the zero-argument default binds.
|
||||
const DEFAULT_PORT: u16 = 2112;
|
||||
|
||||
// Every policy option is optional. An omitted option leaves the corresponding
|
||||
// `ServerConfig` default in place rather than restating it here, so library and
|
||||
// CLI defaults cannot drift apart.
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(
|
||||
name = "irtt-server",
|
||||
about = "Minimal IRTT-compatible UDP server",
|
||||
after_help = "With no --bind, the server listens on the wildcard IRTT port for both address families ([::]:2112 and 0.0.0.0:2112) where the platform supports wildcard reply-source selection.\nRepeat --bind to serve multiple addresses in one process; any explicit --bind replaces the default pair entirely. All listeners use the same policy but keep independent session namespaces."
|
||||
)]
|
||||
pub struct ServerArgs {
|
||||
/// Local UDP address to bind, as ADDR:PORT. Repeatable.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "ADDR",
|
||||
env = "IRTT_SERVER_BIND",
|
||||
long_help = "Local UDP address to bind, as ADDR:PORT, for example 127.0.0.1:2112 or [::1]:2112. Host names are not resolved.\n\nRepeat the option to serve several addresses from one process, in the order given. Every listener applies the same policy options, but each keeps its own sessions and tokens, so a session belongs to the address it was opened on. Binding is all or nothing: if any address cannot be bound, none are served.\n\nWith no --bind at all, the server binds the wildcard IRTT port on both address families, [::]:2112 then 0.0.0.0:2112, on platforms with wildcard reply-source support; elsewhere it fails and asks for an explicit address instead of guessing one. Any explicit --bind replaces that default pair rather than adding to it.\n\nA wildcard bind such as 0.0.0.0:2112 answers each request from the address it was sent to, using per-packet destination metadata. That is supported on Linux, macOS and FreeBSD; on other systems a wildcard bind is refused and an explicit interface address is required.\n\nA port of 0 selects an unused port per listener, so two such binds get two different ports.\n\nIRTT_SERVER_BIND sets the same thing for deployments that configure by environment (systemd, Docker, ...), and takes exactly one address, the same as one --bind. Leave it unset on ordinary hosts: the wildcard default above is already what you want there. A --bind on the command line overrides it entirely."
|
||||
)]
|
||||
pub bind: Vec<SocketAddr>,
|
||||
|
||||
/// HMAC key; every request must then carry a valid MAC.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "KEY",
|
||||
value_parser = parse_hmac_key,
|
||||
env = "IRTT_SERVER_HMAC",
|
||||
long_help = "HMAC key, taken as the UTF-8 bytes of this argument.\n\nAuthentication is global: with a key configured, every request must carry a valid MAC and every reply is authenticated. Without one, authenticated requests are dropped. The key is visible in the process arguments, or in the environment as IRTT_SERVER_HMAC."
|
||||
)]
|
||||
pub hmac: Option<String>,
|
||||
|
||||
/// Maximum simultaneously live sessions per listener.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "COUNT",
|
||||
env = "IRTT_SERVER_MAX_SESSIONS",
|
||||
long_help = "Maximum number of simultaneously live sessions per listener. Once a listener's table is full, a session-creating open to it is dropped silently; nothing is evicted. Zero refuses every session-creating open.\n\nThis is a per-listener bound, not a process-wide one: with two --bind options, each listener admits up to this many sessions."
|
||||
)]
|
||||
pub max_sessions: Option<usize>,
|
||||
|
||||
/// Maximum echo datagram size a session may negotiate, in bytes.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "BYTES",
|
||||
env = "IRTT_SERVER_MAX_PACKET_LENGTH",
|
||||
long_help = "Maximum echo datagram size a session may negotiate, in bytes. A longer requested length is reduced during negotiation, an open whose mandatory field block would not fit is refused, and inbound echo requests are admitted against the same limit. This is a resource bound, not an MTU."
|
||||
)]
|
||||
pub max_packet_length: Option<usize>,
|
||||
|
||||
/// Floor on the negotiated send interval.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_duration_allow_zero,
|
||||
env = "IRTT_SERVER_MIN_INTERVAL",
|
||||
long_help = "Floor on the send interval a session may negotiate. The negotiated interval is still capped at a quarter of the idle timeout afterwards, so a value above that cap is not what actually gets negotiated.\n\nA session's reply allowance refills at the shorter of this floor and the interval it actually negotiated. Ordinarily that is this floor itself; it is the shorter, negotiated value only where the idle-timeout cap above pulled the negotiated interval below it. Use 0 for no time-based throttling."
|
||||
)]
|
||||
pub min_interval: Option<Duration>,
|
||||
|
||||
/// Echo replies a session may burst before its allowance refills.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "COUNT",
|
||||
env = "IRTT_SERVER_BURST",
|
||||
long_help = "Echo replies one session may have answered before its allowance has to refill. Use 0 for no allowance at all, which rate-limits every echo request whatever the interval is."
|
||||
)]
|
||||
pub burst: Option<u32>,
|
||||
|
||||
/// Release a session after this long without an echo request.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_duration_allow_zero,
|
||||
env = "IRTT_SERVER_IDLE_TIMEOUT",
|
||||
long_help = "Release a session after this long without a served or rate-limited echo request. The deadline runs from the open, and release is silent. Use 0 to expire a session at the next evaluation; it is not a way to disable expiry."
|
||||
)]
|
||||
pub idle_timeout: Option<Duration>,
|
||||
|
||||
/// Maximum test duration a session may negotiate.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "DURATION",
|
||||
value_parser = parse_positive_duration,
|
||||
env = "IRTT_SERVER_MAX_DURATION",
|
||||
long_help = "Maximum test duration a session may negotiate. A longer request is reduced to it, and a continuous request is answered with it. Omit this flag for no maximum; a maximum of zero cannot be expressed, because a negotiated duration of zero means continuous."
|
||||
)]
|
||||
pub max_duration: Option<Duration>,
|
||||
|
||||
/// How many timestamps this server will provide.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "MODE",
|
||||
value_enum,
|
||||
env = "IRTT_SERVER_TIMESTAMP_ALLOWANCE",
|
||||
long_help = "How many timestamps this server will provide.\n\ndual: honor a request for send, receive, both or midpoint timestamps.\nsingle: provide at most one timestamp instant; a request for both is negotiated to midpoint.\nnone: provide no timestamps at all.\n\nThe requested clock source is never changed, only which instants are reported, so a single instant is still reported once per requested clock. Omit this flag to honor every requested placement."
|
||||
)]
|
||||
pub timestamp_allowance: Option<TimestampAllowanceArg>,
|
||||
|
||||
/// Refuse to provide requested DSCP marking.
|
||||
//
|
||||
// `FalseyValueParser` rather than clap's default `bool` parser for a flag,
|
||||
// which is what makes IRTT_SERVER_NO_DSCP usable from the environment at
|
||||
// all. A flag's default parser accepts only the literals `true` and
|
||||
// `false`, so the ordinary container/orchestrator spelling
|
||||
// `IRTT_SERVER_NO_DSCP=1` did not enable the flag — it failed argument
|
||||
// parsing and the server refused to start. Falsey semantics are clap's own
|
||||
// documented recipe for a boolean environment flag: `0`, `false`, `no`,
|
||||
// `n`, `off` and empty are off, anything else is on, and an absent
|
||||
// variable is off as before. The command-line flag is unaffected: it still
|
||||
// takes no value and still means "on".
|
||||
#[arg(
|
||||
long,
|
||||
env = "IRTT_SERVER_NO_DSCP",
|
||||
value_parser = clap::builder::FalseyValueParser::new(),
|
||||
long_help = "Refuse to provide requested traffic-class marking. Any requested DSCP is negotiated to zero, so the client is told its echo replies will be unmarked, and they are sent unmarked. The session is not refused.\n\nIRTT_SERVER_NO_DSCP sets the same thing from the environment: 0, false, no, n, off and an empty value leave marking enabled, and any other value disables it."
|
||||
)]
|
||||
pub no_dscp: bool,
|
||||
}
|
||||
|
||||
/// The command-line spelling of [`TimestampAllowance`].
|
||||
///
|
||||
/// A CLI-local enum rather than a `clap` derive on the library type: `irtt-server`
|
||||
/// is a reusable crate and has no business depending on an argument parser.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum TimestampAllowanceArg {
|
||||
/// Honor every requested timestamp placement.
|
||||
Dual,
|
||||
/// Provide at most one timestamp instant; both becomes midpoint.
|
||||
Single,
|
||||
/// Provide no timestamps.
|
||||
None,
|
||||
}
|
||||
|
||||
impl From<TimestampAllowanceArg> for TimestampAllowance {
|
||||
fn from(value: TimestampAllowanceArg) -> Self {
|
||||
match value {
|
||||
TimestampAllowanceArg::Dual => Self::Dual,
|
||||
TimestampAllowanceArg::Single => Self::Single,
|
||||
TimestampAllowanceArg::None => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerArgs {
|
||||
/// The addresses this invocation binds: the requested `--bind` list, or
|
||||
/// the wildcard IRTT port on both address families if none was given.
|
||||
#[must_use]
|
||||
pub fn resolve_binds(&self) -> Vec<SocketAddr> {
|
||||
if self.bind.is_empty() {
|
||||
vec![
|
||||
SocketAddr::from((Ipv6Addr::UNSPECIFIED, DEFAULT_PORT)),
|
||||
SocketAddr::from((Ipv4Addr::UNSPECIFIED, DEFAULT_PORT)),
|
||||
]
|
||||
} else {
|
||||
self.bind.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the server configuration this invocation asks for.
|
||||
#[must_use]
|
||||
pub fn server_config(&self) -> ServerConfig {
|
||||
let mut config = ServerConfig::default();
|
||||
if let Some(key) = self.hmac.as_ref() {
|
||||
config = config.with_hmac_key(key.as_bytes());
|
||||
}
|
||||
if let Some(value) = self.max_sessions {
|
||||
config = config.with_max_sessions(value);
|
||||
}
|
||||
if let Some(value) = self.max_packet_length {
|
||||
config = config.with_max_packet_length(value);
|
||||
}
|
||||
if let Some(value) = self.min_interval {
|
||||
config = config.with_min_send_interval(value);
|
||||
}
|
||||
if let Some(value) = self.burst {
|
||||
config = config.with_burst_allowance(value);
|
||||
}
|
||||
if let Some(value) = self.idle_timeout {
|
||||
config = config.with_idle_timeout(value);
|
||||
}
|
||||
if let Some(value) = self.max_duration {
|
||||
config = config.with_max_test_duration(value);
|
||||
}
|
||||
if let Some(value) = self.timestamp_allowance {
|
||||
config = config.with_timestamp_allowance(value.into());
|
||||
}
|
||||
// A negative capability flag, so absence is the ordinary enabled state
|
||||
// and the library default is what an unset flag leaves in place.
|
||||
if self.no_dscp {
|
||||
config = config.with_dscp_allowed(false);
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects an empty HMAC key so a blank shell expansion cannot silently
|
||||
/// enable authentication with predictable, empty key material.
|
||||
fn parse_hmac_key(input: &str) -> Result<String, String> {
|
||||
if input.is_empty() {
|
||||
return Err("HMAC key must not be empty".to_owned());
|
||||
}
|
||||
Ok(input.to_owned())
|
||||
}
|
||||
|
||||
/// Parses a duration whose zero is meaningful server policy.
|
||||
fn parse_duration_allow_zero(input: &str) -> Result<Duration, String> {
|
||||
if input == "0" {
|
||||
return Ok(Duration::ZERO);
|
||||
}
|
||||
parse_duration_units(input)
|
||||
}
|
||||
|
||||
/// Parses a duration that must be positive, where absence means "no limit".
|
||||
fn parse_positive_duration(input: &str) -> Result<Duration, String> {
|
||||
let value = if input == "0" {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
parse_duration_units(input)?
|
||||
};
|
||||
if value.is_zero() {
|
||||
return Err(
|
||||
"duration must be greater than zero; omit the option for no maximum".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_duration_units(input: &str) -> Result<Duration, String> {
|
||||
let split = input
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
|
||||
let (number, unit) = input.split_at(split);
|
||||
if number.is_empty() {
|
||||
return Err("duration must include a value and a unit: ms, s, or m".to_owned());
|
||||
}
|
||||
let value: u64 = number
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
||||
match unit {
|
||||
"ms" => Ok(Duration::from_millis(value)),
|
||||
"s" => Ok(Duration::from_secs(value)),
|
||||
"m" => value
|
||||
.checked_mul(60)
|
||||
.map(Duration::from_secs)
|
||||
.ok_or_else(|| "duration is too large".to_owned()),
|
||||
_ => Err(format!(
|
||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod args;
|
||||
pub mod run;
|
||||
|
||||
pub use args::ServerArgs;
|
||||
pub use run::run_server;
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use irtt_server::{address_family_available, ServerSet, ServerSetError};
|
||||
|
||||
use super::args::ServerArgs;
|
||||
|
||||
/// How often the shutdown future samples the process-wide signal flag.
|
||||
///
|
||||
/// The flag a signal handler sets has no async wake mechanism, so the future
|
||||
/// polls it on a timer rather than spinning. `ServerSet::run` owns everything
|
||||
/// after that future becomes ready, including fanning it out to the listeners.
|
||||
const SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Runs the configured listeners until the process is asked to shut down.
|
||||
///
|
||||
/// The applet owns exactly one current-thread Tokio runtime and one
|
||||
/// [`ServerSet`], whatever number of addresses was requested. A single `--bind`
|
||||
/// is an ordinary set of one and takes the same path as a set of several: one
|
||||
/// construction, one shutdown future, one supervision rule. Listener
|
||||
/// concurrency is async I/O concurrency and needs no worker threads.
|
||||
pub fn run_server(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(serve(args, shutdown_requested))
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
args: ServerArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = args.server_config();
|
||||
let binds = args.resolve_binds();
|
||||
let is_implicit_default = args.bind.is_empty();
|
||||
let set = match ServerSet::bind(binds.clone(), config.clone()).await {
|
||||
Ok(set) => set,
|
||||
Err(ServerSetError::ListenerSetup { addr, source }) => {
|
||||
let family_available = address_family_available(addr);
|
||||
match implicit_default_fallback(is_implicit_default, family_available, &binds, addr) {
|
||||
Some(remaining) => {
|
||||
eprintln!(
|
||||
"irtt-server: default listener {addr} unavailable ({source}); \
|
||||
continuing with the remaining default listener"
|
||||
);
|
||||
// `remaining`'s lone survivor is bound exactly as it
|
||||
// would be alone: the address family that just failed
|
||||
// has no local support at all, so nothing on that family
|
||||
// could be competing for the surviving listener's port.
|
||||
ServerSet::bind(remaining, config).await?
|
||||
}
|
||||
None => return Err(ServerSetError::ListenerSetup { addr, source }.into()),
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
// Only once every listener is up. Announcing one address and then failing
|
||||
// to bind the next would report a service that never started.
|
||||
for addr in set.local_addrs() {
|
||||
eprintln!("irtt-server: listening on {addr}");
|
||||
}
|
||||
set.run(shutdown_signal(shutdown_requested)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a listener-setup failure for `failed_addr` should be retried with
|
||||
/// a reduced bind set instead of failing the whole invocation, and if so,
|
||||
/// what to retry with.
|
||||
///
|
||||
/// Only the implicit zero-argument default gets this treatment, and only
|
||||
/// when `failed_addr`'s own address family has no local support on this host
|
||||
/// at all (`family_available` is `false`) — not merely because its specific
|
||||
/// port was unavailable for some other reason: already in use, permission
|
||||
/// denied, no safe wildcard reply-source path, or anything else
|
||||
/// `ServerSet::bind` can fail with. Those are real configuration or
|
||||
/// environment problems this invocation should fail on, not silently work
|
||||
/// around by dropping a listener the operator asked for. An explicit
|
||||
/// `--bind` list keeps the library's ordinary all-or-nothing behavior
|
||||
/// regardless.
|
||||
fn implicit_default_fallback(
|
||||
is_implicit_default: bool,
|
||||
family_available: bool,
|
||||
original_binds: &[SocketAddr],
|
||||
failed_addr: SocketAddr,
|
||||
) -> Option<Vec<SocketAddr>> {
|
||||
if !is_implicit_default || family_available || !original_binds.contains(&failed_addr) {
|
||||
return None;
|
||||
}
|
||||
let remaining: Vec<SocketAddr> = original_binds
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&addr| addr != failed_addr)
|
||||
.collect();
|
||||
(!remaining.is_empty()).then_some(remaining)
|
||||
}
|
||||
|
||||
async fn shutdown_signal(shutdown_requested: &AtomicBool) {
|
||||
while !shutdown_requested.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(SHUTDOWN_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::shared::client::{
|
||||
parse_target, parse_test_duration, prepare_managed_run, CommonClientArgs, GroupPacingArg,
|
||||
ManagedRunSetup, TargetArg, TargetSelection,
|
||||
};
|
||||
|
||||
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
||||
pub struct TuiArgs {
|
||||
/// Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.
|
||||
#[arg(
|
||||
value_name = "TARGET",
|
||||
num_args = 1..,
|
||||
required = true,
|
||||
value_parser = parse_target,
|
||||
long_help = "Server address/host, optionally prefixed with LABEL= and suffixed with @hmac=KEY. Repeat for multi-target mode.\n\nExplicit labels are used in the legend and status table. A target without @hmac= inherits --hmac; @hmac= disables it for that target.\n\nExamples:\n irtt-tui host.example\n irtt-tui eu=host.example\n irtt-tui eu=host-a.example@hmac=secret us=host-b.example"
|
||||
)]
|
||||
pub targets: Vec<TargetArg>,
|
||||
|
||||
/// Managed group pacing for multi-target mode.
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
default_value_t = GroupPacingArg::Staggered,
|
||||
long_help = "Managed group pacing for multi-target mode.\n\nstaggered spaces active targets across the probe interval. burst sends one probe to every active target back-to-back once per interval."
|
||||
)]
|
||||
pub pacing: GroupPacingArg,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "0",
|
||||
value_parser = parse_test_duration,
|
||||
help = "Test duration; use 0 for continuous mode",
|
||||
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
||||
)]
|
||||
pub duration: Duration,
|
||||
|
||||
#[command(flatten)]
|
||||
pub common: CommonClientArgs,
|
||||
}
|
||||
|
||||
impl TuiArgs {
|
||||
/// Validate the selected targets and prepare the managed run.
|
||||
///
|
||||
/// The TUI always requires a target: the parser rejects an empty target
|
||||
/// list, and this rejects a target set that cannot be labelled uniquely.
|
||||
pub fn prepare(&self) -> Result<ManagedRunSetup, String> {
|
||||
prepare_managed_run(
|
||||
&self.common,
|
||||
self.duration,
|
||||
TargetSelection {
|
||||
targets: &self.targets,
|
||||
pacing: self.pacing,
|
||||
stdin_controlled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
self.duration == Duration::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TuiArgs {
|
||||
type Target = CommonClientArgs;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.common
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod args;
|
||||
pub mod run;
|
||||
mod ui;
|
||||
|
||||
pub use args::TuiArgs;
|
||||
pub use run::run_tui;
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::{collections::HashSet, future::poll_fn, io, time::Duration};
|
||||
|
||||
use super::ui::{TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||
use crate::{
|
||||
cmd::tui::args::TuiArgs,
|
||||
shared::client::{
|
||||
session::{
|
||||
drain_final_events, peer_close_run_error, request_managed_stop_for_peer_close,
|
||||
request_managed_stop_once,
|
||||
},
|
||||
worker::ManagedWorker,
|
||||
},
|
||||
};
|
||||
use crossterm::{
|
||||
event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers},
|
||||
terminal,
|
||||
};
|
||||
use futures_core::Stream;
|
||||
use irtt_client::{
|
||||
managed::{ManagedClient, ManagedEndReason, ManagedEvent, TargetInstance},
|
||||
ClientEvent,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
use tokio::{
|
||||
sync::{broadcast::error::RecvError, watch},
|
||||
time::{sleep, Instant},
|
||||
};
|
||||
|
||||
const RENDER_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
pub async fn run_tui(
|
||||
args: TuiArgs,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let setup = args
|
||||
.prepare()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||
let continuous = args.is_continuous();
|
||||
if *shutdown.borrow() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut terminal = TuiTerminal::enter()?;
|
||||
let mut state = TuiState::with_target_labels(
|
||||
TuiConfig::from_args(&args, &setup),
|
||||
setup.targets.iter().map(|target| target.label.clone()),
|
||||
);
|
||||
state.set_status(TuiStatus::Opening);
|
||||
terminal.draw(&state)?;
|
||||
let (task, handle) = match ManagedClient::task(setup.managed_config(), setup.managed_targets())
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
state.set_error(error.to_string());
|
||||
terminal.draw(&state)?;
|
||||
return Err(Box::new(error));
|
||||
}
|
||||
};
|
||||
// Both subscriptions exist before the measurement task can be polled.
|
||||
let mut events = handle.subscribe()?;
|
||||
let mut status = handle.subscribe_status();
|
||||
let worker = match ManagedWorker::start(task, handle.clone()) {
|
||||
Ok(worker) => worker,
|
||||
Err(error) => {
|
||||
state.set_run_error(error.to_string());
|
||||
terminal.draw(&state)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
let completion = worker.join();
|
||||
tokio::pin!(completion);
|
||||
// Input and rendering stay on the frontend executor. EventStream's drop
|
||||
// wakes its reader; no terminal task can outlive the terminal guard.
|
||||
let mut input = EventStream::new();
|
||||
let render = sleep(RENDER_INTERVAL);
|
||||
tokio::pin!(render);
|
||||
let mut interrupted = false;
|
||||
let mut stop_requested = false;
|
||||
let mut terminal_targets = HashSet::new();
|
||||
let mut dropped_events = 0_u64;
|
||||
let mut events_closed = false;
|
||||
let mut status_closed = false;
|
||||
let outcome = loop {
|
||||
let mut force_render = false;
|
||||
// Default select fairness lets input, status and rendering progress
|
||||
// even when measurement events are continuously ready.
|
||||
tokio::select! {
|
||||
outcome = &mut completion => break outcome,
|
||||
_ = shutdown.changed(), if !interrupted => {
|
||||
interrupted = true;
|
||||
state.set_status(TuiStatus::Interrupted);
|
||||
force_render = true;
|
||||
}
|
||||
changed = status.changed(), if !status_closed => {
|
||||
status_closed = changed.is_err();
|
||||
let snapshot = status.borrow_and_update().clone();
|
||||
if request_managed_stop_for_peer_close(
|
||||
continuous, interrupted, snapshot.peer_closed_target_outcomes,
|
||||
&mut stop_requested,
|
||||
) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
force_render = true;
|
||||
}
|
||||
event = events.recv(), if !events_closed => match event {
|
||||
Ok(event) => force_render = process_tui_event(
|
||||
event, &mut state, &mut terminal_targets,
|
||||
),
|
||||
Err(RecvError::Lagged(count)) => {
|
||||
dropped_events = dropped_events.saturating_add(count);
|
||||
state.mark_dropped_managed_events(dropped_events);
|
||||
force_render = true;
|
||||
}
|
||||
Err(RecvError::Closed) => events_closed = true,
|
||||
},
|
||||
event = poll_fn(|cx| std::pin::Pin::new(&mut input).poll_next(cx)) => {
|
||||
let event = event.ok_or_else(|| io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof, "terminal input closed",
|
||||
))??;
|
||||
match event {
|
||||
Event::Resize(_, _) => force_render = true,
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Char('c')
|
||||
if key.code == KeyCode::Char('q')
|
||||
|| key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
interrupted = true;
|
||||
state.set_status(TuiStatus::Interrupted);
|
||||
force_render = true;
|
||||
}
|
||||
_ => {
|
||||
let (width, height) = terminal::size()?;
|
||||
force_render = state.handle_key(
|
||||
key.code, Rect::new(0, 0, width, height),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = &mut render, if !state.paused => force_render = true,
|
||||
}
|
||||
if interrupted && request_managed_stop_once(&mut stop_requested) {
|
||||
drop(handle.stop());
|
||||
}
|
||||
if force_render {
|
||||
terminal.draw(&state)?;
|
||||
// Schedule from the completed draw, never catch up missed frames.
|
||||
render.as_mut().reset(Instant::now() + RENDER_INTERVAL);
|
||||
}
|
||||
};
|
||||
state.set_status(TuiStatus::Closing);
|
||||
terminal.draw(&state)?;
|
||||
let outcome = match outcome {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
state.set_run_error(error.to_string());
|
||||
terminal.draw(&state)?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
drain_final_events(&mut events, &mut dropped_events, |event| {
|
||||
process_tui_event(event, &mut state, &mut terminal_targets);
|
||||
Ok::<(), std::convert::Infallible>(())
|
||||
})?;
|
||||
state.mark_dropped_managed_events(dropped_events);
|
||||
if outcome.discarded_target_outcomes != 0 {
|
||||
state.set_run_error(format!(
|
||||
"{} final target outcomes were discarded",
|
||||
outcome.discarded_target_outcomes
|
||||
));
|
||||
}
|
||||
for target in outcome.recent_target_outcomes.iter() {
|
||||
if terminal_targets.insert(target.target.clone()) {
|
||||
state.process_target_outcome(target);
|
||||
}
|
||||
}
|
||||
interrupted |= *shutdown.borrow();
|
||||
let error = match &outcome.end_reason {
|
||||
ManagedEndReason::DriverFailed(failure) => {
|
||||
Some(format!("managed driver failed: {failure}"))
|
||||
}
|
||||
_ => peer_close_run_error(continuous, interrupted, outcome.peer_closed_target_outcomes)
|
||||
.or_else(|| {
|
||||
(!interrupted
|
||||
&& outcome.successful_target_outcomes == 0
|
||||
&& outcome.failed_target_outcomes > 0)
|
||||
.then(|| {
|
||||
format!(
|
||||
"no managed target completed successfully ({} failed)",
|
||||
outcome.failed_target_outcomes
|
||||
)
|
||||
})
|
||||
}),
|
||||
};
|
||||
if let Some(error) = error {
|
||||
state.set_run_error(error.clone());
|
||||
terminal.draw(&state)?;
|
||||
return Err(error.into());
|
||||
}
|
||||
state.set_status(TuiStatus::Complete);
|
||||
terminal.draw(&state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_tui_event(
|
||||
event: ManagedEvent,
|
||||
state: &mut TuiState,
|
||||
terminal_targets: &mut HashSet<TargetInstance>,
|
||||
) -> bool {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => {
|
||||
let force_render = matches!(
|
||||
event,
|
||||
ClientEvent::SessionStarted(_)
|
||||
| ClientEvent::NoTestCompleted(_)
|
||||
| ClientEvent::SessionClosed { .. }
|
||||
| ClientEvent::Warning { .. }
|
||||
);
|
||||
state.process_target_event(&target, &event);
|
||||
force_render
|
||||
}
|
||||
ManagedEvent::TargetFinished { outcome } => {
|
||||
terminal_targets.insert(outcome.target.clone());
|
||||
state.process_target_outcome(&outcome);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,139 @@
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
use std::{env, ffi::OsString, process::ExitCode};
|
||||
|
||||
use crate::applet::{dispatch_from_argv, AppletDispatch, RequestedApplet};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::signal::install_signal_handler;
|
||||
|
||||
/// Entry point for the `irtt-rs` multicall dispatcher binary.
|
||||
///
|
||||
/// Chooses an applet by subcommand or by recognized argv0 and may reference
|
||||
/// every applet enabled by the active feature set.
|
||||
pub fn dispatcher_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-rs", run_dispatcher_from_env)
|
||||
}
|
||||
|
||||
/// Entry point for the dedicated `irtt-client` binary.
|
||||
///
|
||||
/// Runs the stream client directly; it never consults argv0 or the applet
|
||||
/// dispatcher, so a renamed or copied executable still runs the client.
|
||||
#[cfg(feature = "client")]
|
||||
pub fn client_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-client", || {
|
||||
run_client_applet(env::args_os().collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Entry point for the dedicated `irtt-tui` binary.
|
||||
///
|
||||
/// Runs the terminal UI directly; it never consults argv0 or the applet
|
||||
/// dispatcher, so a renamed or copied executable still runs the TUI.
|
||||
#[cfg(feature = "tui")]
|
||||
pub fn tui_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-tui", || run_tui_applet(env::args_os().collect()))
|
||||
}
|
||||
|
||||
/// Entry point for the dedicated `irtt-server` binary.
|
||||
///
|
||||
/// Runs the server directly; it never consults argv0 or the applet
|
||||
/// dispatcher, so a renamed or copied executable still runs the server.
|
||||
#[cfg(feature = "server")]
|
||||
pub fn server_main() -> ExitCode {
|
||||
run_with_exit_code("irtt-server", || {
|
||||
run_server_applet(env::args_os().collect())
|
||||
})
|
||||
}
|
||||
|
||||
fn run_with_exit_code(
|
||||
program: &str,
|
||||
f: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
|
||||
) -> ExitCode {
|
||||
match f() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("{program}: {err}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_dispatcher_from_env() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let argv: Vec<OsString> = env::args_os().collect();
|
||||
let (requested, argv) = match dispatch_from_argv(argv)? {
|
||||
AppletDispatch::Run { applet, argv } => (applet, argv),
|
||||
AppletDispatch::Help(help) => {
|
||||
print!("{help}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match requested {
|
||||
RequestedApplet::Client => run_client_applet(argv),
|
||||
RequestedApplet::Tui => run_tui_applet(argv),
|
||||
RequestedApplet::Server => run_server_applet(argv),
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the shutdown signal handler for synchronous applets and hands the
|
||||
/// resulting flag to `f`.
|
||||
#[cfg(feature = "server")]
|
||||
fn with_shutdown_flag(
|
||||
f: impl FnOnce(&AtomicBool) -> Result<(), Box<dyn std::error::Error>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
||||
install_signal_handler(Arc::clone(&shutdown_requested))
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
f(shutdown_requested.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
fn run_client_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let shutdown = crate::signal::install_async_signal_handler()
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
let args = crate::cmd::client::ClientArgs::parse_from(argv);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(crate::cmd::client::run_stream(args, shutdown))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "client"))]
|
||||
fn run_client_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("client applet is not available; rebuild with the client feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
fn run_server_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
with_shutdown_flag(|shutdown_requested| {
|
||||
let args = crate::cmd::server::ServerArgs::parse_from(argv);
|
||||
crate::cmd::server::run_server(args, shutdown_requested)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
fn run_server_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("server applet is not available; rebuild with the server feature".into())
|
||||
}
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
fn run_tui_applet(argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use clap::Parser;
|
||||
|
||||
let shutdown = crate::signal::install_async_signal_handler()
|
||||
.map_err(|err| format!("failed to install signal handler: {err}"))?;
|
||||
let args = crate::cmd::tui::TuiArgs::parse_from(argv);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(crate::cmd::tui::run_tui(args, shutdown))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tui"))]
|
||||
fn run_tui_applet(_argv: Vec<OsString>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Err("TUI applet is not available; rebuild with the tui feature".into())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod applet;
|
||||
pub mod cmd;
|
||||
#[doc(hidden)]
|
||||
pub mod entry;
|
||||
pub mod shared;
|
||||
pub mod signal;
|
||||
@@ -0,0 +1,247 @@
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH};
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
#[derive(Clone, clap::Args)]
|
||||
pub struct CommonClientArgs {
|
||||
/// Probe interval.
|
||||
#[arg(long, default_value = "1s", value_parser = parse_duration)]
|
||||
pub interval: Duration,
|
||||
|
||||
/// UDP payload length.
|
||||
#[arg(long, default_value_t = 0, value_parser = parse_length)]
|
||||
pub length: u32,
|
||||
|
||||
/// HMAC key.
|
||||
#[arg(long, value_parser = parse_hmac_key)]
|
||||
pub hmac: Option<String>,
|
||||
|
||||
/// Clock mode to request.
|
||||
#[arg(long, value_enum, default_value_t = ClockArg::Both)]
|
||||
pub clock: ClockArg,
|
||||
|
||||
/// Timestamp mode to request.
|
||||
#[arg(
|
||||
long = "tstamp",
|
||||
visible_alias = "timestamps",
|
||||
value_enum,
|
||||
value_name = "MODE",
|
||||
default_value_t = TimestampArg::Both
|
||||
)]
|
||||
pub tstamp: TimestampArg,
|
||||
|
||||
/// Received-stats mode to request.
|
||||
#[arg(
|
||||
long = "stats",
|
||||
value_enum,
|
||||
default_value_t = ReceivedStatsArg::Both,
|
||||
help = "Server received-stats negotiation mode"
|
||||
)]
|
||||
pub stats: ReceivedStatsArg,
|
||||
|
||||
/// Server payload fill string to request, up to 32 bytes.
|
||||
#[arg(
|
||||
long = "sfill",
|
||||
visible_alias = "server-fill",
|
||||
value_name = "STRING",
|
||||
value_parser = parse_server_fill
|
||||
)]
|
||||
pub server_fill: Option<String>,
|
||||
|
||||
/// DSCP codepoint to request; this is not a raw TOS or Traffic Class byte.
|
||||
#[arg(long, default_value_t = 0, value_name = "0..=63", value_parser = parse_dscp)]
|
||||
pub dscp: u8,
|
||||
|
||||
/// Local outgoing IPv4 TTL or IPv6 unicast hop limit; not negotiated.
|
||||
#[arg(long, value_name = "1..=255", value_parser = parse_ttl)]
|
||||
pub ttl: Option<u32>,
|
||||
|
||||
/// Accept safe server restrictions during negotiation.
|
||||
#[arg(long)]
|
||||
pub loose: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CommonClientArgs {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CommonClientArgs")
|
||||
.field("interval", &self.interval)
|
||||
.field("length", &self.length)
|
||||
.field("hmac", &self.hmac.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("clock", &self.clock)
|
||||
.field("tstamp", &self.tstamp)
|
||||
.field("stats", &self.stats)
|
||||
.field("server_fill", &self.server_fill)
|
||||
.field("dscp", &self.dscp)
|
||||
.field("ttl", &self.ttl)
|
||||
.field("loose", &self.loose)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum ClockArg {
|
||||
Wall,
|
||||
Monotonic,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl From<ClockArg> for Clock {
|
||||
fn from(value: ClockArg) -> Self {
|
||||
match value {
|
||||
ClockArg::Wall => Self::Wall,
|
||||
ClockArg::Monotonic => Self::Monotonic,
|
||||
ClockArg::Both => Self::Both,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum TimestampArg {
|
||||
None,
|
||||
Send,
|
||||
Receive,
|
||||
Both,
|
||||
Midpoint,
|
||||
}
|
||||
|
||||
impl From<TimestampArg> for StampAt {
|
||||
fn from(value: TimestampArg) -> Self {
|
||||
match value {
|
||||
TimestampArg::None => Self::None,
|
||||
TimestampArg::Send => Self::Send,
|
||||
TimestampArg::Receive => Self::Receive,
|
||||
TimestampArg::Both => Self::Both,
|
||||
TimestampArg::Midpoint => Self::Midpoint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum ReceivedStatsArg {
|
||||
None,
|
||||
Count,
|
||||
Window,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl From<ReceivedStatsArg> for ReceivedStats {
|
||||
fn from(value: ReceivedStatsArg) -> Self {
|
||||
match value {
|
||||
ReceivedStatsArg::None => Self::None,
|
||||
ReceivedStatsArg::Count => Self::Count,
|
||||
ReceivedStatsArg::Window => Self::Window,
|
||||
ReceivedStatsArg::Both => Self::Both,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_duration(input: &str) -> Result<Duration, String> {
|
||||
let (number, unit) = split_duration(input)?;
|
||||
let value: u64 = number
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
||||
if value == 0 {
|
||||
return Err("duration must be greater than zero".to_owned());
|
||||
}
|
||||
match unit {
|
||||
"ms" => Ok(Duration::from_millis(value)),
|
||||
"s" => Ok(Duration::from_secs(value)),
|
||||
"m" => value
|
||||
.checked_mul(60)
|
||||
.map(Duration::from_secs)
|
||||
.ok_or_else(|| "duration is too large".to_owned()),
|
||||
_ => Err(format!(
|
||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_test_duration(input: &str) -> Result<Duration, String> {
|
||||
if input == "0" {
|
||||
return Ok(Duration::ZERO);
|
||||
}
|
||||
let (number, unit) = split_duration(input)?;
|
||||
let value: u64 = number
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid duration value {input:?}"))?;
|
||||
if value == 0 {
|
||||
return Ok(Duration::ZERO);
|
||||
}
|
||||
match unit {
|
||||
"ms" => Ok(Duration::from_millis(value)),
|
||||
"s" => Ok(Duration::from_secs(value)),
|
||||
"m" => value
|
||||
.checked_mul(60)
|
||||
.map(Duration::from_secs)
|
||||
.ok_or_else(|| "duration is too large".to_owned()),
|
||||
_ => Err(format!(
|
||||
"unsupported duration unit {unit:?}; use ms, s, or m"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_duration(input: &str) -> Result<(&str, &str), String> {
|
||||
let split = input
|
||||
.find(|ch: char| !ch.is_ascii_digit())
|
||||
.ok_or_else(|| "duration must include a unit: ms, s, or m".to_owned())?;
|
||||
let (number, unit) = input.split_at(split);
|
||||
if number.is_empty() || unit.is_empty() {
|
||||
return Err("duration must include a positive value and unit".to_owned());
|
||||
}
|
||||
if number.starts_with('-') {
|
||||
return Err("duration must be greater than zero".to_owned());
|
||||
}
|
||||
Ok((number, unit))
|
||||
}
|
||||
|
||||
pub fn parse_length(input: &str) -> Result<u32, String> {
|
||||
let length: u32 = input
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid packet length {input:?}"))?;
|
||||
if length > MAX_UDP_PAYLOAD_LENGTH {
|
||||
return Err(format!("packet length must be <= {MAX_UDP_PAYLOAD_LENGTH}"));
|
||||
}
|
||||
Ok(length)
|
||||
}
|
||||
|
||||
pub fn parse_hmac_key(input: &str) -> Result<String, String> {
|
||||
if input.is_empty() {
|
||||
return Err("HMAC key must not be empty".to_owned());
|
||||
}
|
||||
Ok(input.to_owned())
|
||||
}
|
||||
|
||||
pub fn parse_server_fill(input: &str) -> Result<String, String> {
|
||||
if input.is_empty() {
|
||||
return Err("server fill must not be empty".to_owned());
|
||||
}
|
||||
let len = input.len();
|
||||
if len > MAX_SERVER_FILL_BYTES {
|
||||
return Err(format!(
|
||||
"server fill must be <= {MAX_SERVER_FILL_BYTES} bytes, got {len}"
|
||||
));
|
||||
}
|
||||
Ok(input.to_owned())
|
||||
}
|
||||
|
||||
pub fn parse_dscp(input: &str) -> Result<u8, String> {
|
||||
let value: u8 = input
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid DSCP codepoint {input:?}"))?;
|
||||
if value > MAX_DSCP_CODEPOINT {
|
||||
return Err(format!("DSCP codepoint must be <= {MAX_DSCP_CODEPOINT}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn parse_ttl(input: &str) -> Result<u32, String> {
|
||||
let value: u32 = input
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid TTL/hop limit {input:?}"))?;
|
||||
if value == 0 || value > MAX_TTL {
|
||||
return Err(format!("TTL/hop limit must be in range 1..={MAX_TTL}"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
Authentication, ClientConfig, HmacKey, NegotiationPolicy, OpenPolicy, SessionRequest,
|
||||
SocketConfig,
|
||||
};
|
||||
|
||||
use super::args::CommonClientArgs;
|
||||
|
||||
impl CommonClientArgs {
|
||||
/// Build the shared client configuration template for a managed run.
|
||||
///
|
||||
/// The config is endpoint-independent; managed targets own their addresses.
|
||||
pub fn to_client_config(&self, duration: Duration) -> ClientConfig {
|
||||
ClientConfig {
|
||||
open: OpenPolicy {
|
||||
negotiation: if self.loose {
|
||||
NegotiationPolicy::Loose
|
||||
} else {
|
||||
NegotiationPolicy::Strict
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
request: SessionRequest {
|
||||
duration: (!duration.is_zero()).then_some(duration),
|
||||
interval: self.interval,
|
||||
length: self.length,
|
||||
received_stats: self.stats.into(),
|
||||
stamp_at: self.tstamp.into(),
|
||||
clock: self.clock.into(),
|
||||
dscp: self.dscp,
|
||||
server_fill: self.server_fill.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
auth: self
|
||||
.hmac
|
||||
.as_ref()
|
||||
.map_or(Authentication::Unauthenticated, |key| {
|
||||
Authentication::Hmac(HmacKey::new(key.as_bytes()))
|
||||
}),
|
||||
socket: SocketConfig {
|
||||
ttl: self.ttl,
|
||||
..SocketConfig::default()
|
||||
},
|
||||
..ClientConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
|
||||
let interval_nanos = interval.as_nanos();
|
||||
if interval_nanos == 0 {
|
||||
return u64::MAX;
|
||||
}
|
||||
|
||||
let expected = duration
|
||||
.as_nanos()
|
||||
.saturating_add(interval_nanos.saturating_sub(1))
|
||||
/ interval_nanos;
|
||||
expected.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub mod args;
|
||||
pub mod config;
|
||||
pub mod prepare;
|
||||
pub mod session;
|
||||
pub mod targets;
|
||||
pub(crate) mod worker;
|
||||
|
||||
pub use args::{
|
||||
parse_dscp, parse_duration, parse_length, parse_server_fill, parse_test_duration, parse_ttl,
|
||||
ClockArg, CommonClientArgs, ReceivedStatsArg, TimestampArg,
|
||||
};
|
||||
pub use config::expected_probe_count;
|
||||
pub use prepare::{
|
||||
prepare_managed_run, ManagedRunSetup, TargetSelection, MANAGED_EVENT_CAPACITY,
|
||||
STDIN_MAX_DESIRED_TARGETS, STDIN_MAX_LIVE_TARGET_GENERATIONS, STDIN_OUTCOME_HISTORY_LIMIT,
|
||||
};
|
||||
pub use targets::{
|
||||
parse_stdin_target_set, parse_target, prepare_managed_targets, target_specs, GroupPacingArg,
|
||||
PreparedTarget, TargetArg, TargetSpec,
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
//! One preparation step from parsed arguments to a runnable managed setup.
|
||||
//!
|
||||
//! Both client applets accept the same target and pacing arguments and then
|
||||
//! have to turn them into the same three things: a validated target set, one
|
||||
//! shared [`ClientConfig`] template, and a [`ManagedPacing`]. Doing that
|
||||
//! separately in each applet is how the CLI ended up deriving its client
|
||||
//! configuration from an arbitrary "primary" target whose address the managed
|
||||
//! driver immediately replaced per target.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
managed::{ManagedClientConfig, ManagedCompletionPolicy, ManagedPacing},
|
||||
ClientConfig,
|
||||
};
|
||||
|
||||
use super::{
|
||||
args::CommonClientArgs,
|
||||
targets::{prepare_managed_targets, target_specs, GroupPacingArg, TargetArg},
|
||||
PreparedTarget,
|
||||
};
|
||||
|
||||
/// Capacity of the lossy managed presentation event channel.
|
||||
pub const MANAGED_EVENT_CAPACITY: usize = 16_384;
|
||||
pub const STDIN_MAX_DESIRED_TARGETS: usize = 128;
|
||||
pub const STDIN_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
|
||||
pub const STDIN_OUTCOME_HISTORY_LIMIT: usize = 256;
|
||||
|
||||
/// Target and pacing arguments common to the client applets.
|
||||
///
|
||||
/// Duration is deliberately absent: the applets disagree about what a duration
|
||||
/// means and what its default is, so each keeps its own.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TargetSelection<'a> {
|
||||
pub targets: &'a [TargetArg],
|
||||
pub pacing: GroupPacingArg,
|
||||
pub stdin_controlled: bool,
|
||||
}
|
||||
|
||||
/// Everything a managed run needs, prepared and validated together.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ManagedRunSetup {
|
||||
/// Validated targets, in the order the user gave them.
|
||||
pub targets: Vec<PreparedTarget>,
|
||||
/// Shared client configuration template.
|
||||
///
|
||||
/// The managed driver supplies each target's address and authentication
|
||||
/// from its [`ManagedTargetConfig`](irtt_client::managed::ManagedTargetConfig),
|
||||
/// so this template carries no target of its own.
|
||||
pub client: ClientConfig,
|
||||
/// Send coordination across active targets.
|
||||
pub pacing: ManagedPacing,
|
||||
/// Whether this stream may replace its desired set from stdin.
|
||||
pub stdin_controlled: bool,
|
||||
}
|
||||
|
||||
impl ManagedRunSetup {
|
||||
/// Number of validated targets.
|
||||
pub fn target_count(&self) -> usize {
|
||||
self.targets.len()
|
||||
}
|
||||
|
||||
/// Whether this run drives more than one target.
|
||||
pub fn is_multi_target(&self) -> bool {
|
||||
self.targets.len() > 1
|
||||
}
|
||||
|
||||
/// Managed target configurations, in argument order.
|
||||
pub fn managed_targets(&self) -> Vec<irtt_client::managed::ManagedTargetConfig> {
|
||||
self.targets
|
||||
.iter()
|
||||
.map(|target| target.managed.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Managed driver configuration for this run.
|
||||
pub fn managed_config(&self) -> ManagedClientConfig {
|
||||
let target_count = self.target_count();
|
||||
let mut config = ManagedClientConfig {
|
||||
client: self.client.clone(),
|
||||
pacing: self.pacing,
|
||||
completion: ManagedCompletionPolicy::FinishWhenQuiescent,
|
||||
event_capacity: MANAGED_EVENT_CAPACITY,
|
||||
outcome_history_limit: target_count,
|
||||
max_live_target_generations: target_count,
|
||||
..ManagedClientConfig::default()
|
||||
};
|
||||
if self.stdin_controlled {
|
||||
config.completion = ManagedCompletionPolicy::ExplicitStop;
|
||||
config.outcome_history_limit = STDIN_OUTCOME_HISTORY_LIMIT;
|
||||
config.max_live_target_generations = STDIN_MAX_LIVE_TARGET_GENERATIONS;
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the selected targets and build the shared run setup.
|
||||
///
|
||||
/// Targets are validated before any configuration is built, so a bad target set
|
||||
/// fails before the caller can act on a half-prepared run.
|
||||
pub fn prepare_managed_run(
|
||||
common: &CommonClientArgs,
|
||||
duration: Duration,
|
||||
selection: TargetSelection<'_>,
|
||||
) -> Result<ManagedRunSetup, String> {
|
||||
let specs = if selection.stdin_controlled {
|
||||
if selection.targets.len() > STDIN_MAX_DESIRED_TARGETS {
|
||||
return Err(format!(
|
||||
"initial target set exceeds the {STDIN_MAX_DESIRED_TARGETS}-target limit"
|
||||
));
|
||||
}
|
||||
super::targets::target_specs_with_empty(selection.targets, true)?
|
||||
} else {
|
||||
target_specs(selection.targets)?
|
||||
};
|
||||
let targets = prepare_managed_targets(specs)?;
|
||||
Ok(ManagedRunSetup {
|
||||
targets,
|
||||
client: common.to_client_config(duration),
|
||||
pacing: selection.pacing.into(),
|
||||
stdin_controlled: selection.stdin_controlled,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use irtt_client::managed::{ManagedEvent, ManagedEventSubscription, ManagedEventTryRecvError};
|
||||
|
||||
pub(crate) fn drain_final_events<E>(
|
||||
events: &mut ManagedEventSubscription,
|
||||
dropped_events: &mut u64,
|
||||
mut process: impl FnMut(ManagedEvent) -> Result<(), E>,
|
||||
) -> Result<(), E> {
|
||||
loop {
|
||||
match events.try_recv() {
|
||||
Ok(event) => process(event)?,
|
||||
Err(ManagedEventTryRecvError::Empty | ManagedEventTryRecvError::Closed) => break,
|
||||
Err(ManagedEventTryRecvError::Lagged(count)) => {
|
||||
*dropped_events = dropped_events.saturating_add(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_print_final_summary(continuous: bool, interrupted: bool) -> bool {
|
||||
!continuous || interrupted
|
||||
}
|
||||
|
||||
pub fn peer_close_run_error(
|
||||
continuous: bool,
|
||||
interrupted: bool,
|
||||
peer_closed_target_outcomes: u64,
|
||||
) -> Option<String> {
|
||||
if !continuous || interrupted || peer_closed_target_outcomes == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sessions = if peer_closed_target_outcomes == 1 {
|
||||
"target session"
|
||||
} else {
|
||||
"target sessions"
|
||||
};
|
||||
Some(format!(
|
||||
"continuous run ended because of peer closure ({peer_closed_target_outcomes} {sessions})"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn request_managed_stop_for_peer_close(
|
||||
continuous: bool,
|
||||
interrupted: bool,
|
||||
peer_closed_target_outcomes: u64,
|
||||
stop_requested: &mut bool,
|
||||
) -> bool {
|
||||
if !continuous || interrupted || peer_closed_target_outcomes == 0 {
|
||||
return false;
|
||||
}
|
||||
request_managed_stop_once(stop_requested)
|
||||
}
|
||||
|
||||
pub fn request_managed_stop_once(stop_requested: &mut bool) -> bool {
|
||||
if *stop_requested {
|
||||
return false;
|
||||
}
|
||||
*stop_requested = true;
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use std::{collections::HashSet, fmt};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use irtt_client::{
|
||||
managed::{ManagedPacing, ManagedTargetConfig, TargetAuth, TargetId},
|
||||
Authentication, HmacKey,
|
||||
};
|
||||
|
||||
/// One raw positional target captured by Clap.
|
||||
///
|
||||
/// Parsing happens during run preparation so invalid target syntax never makes
|
||||
/// Clap include the original argument (which may carry an HMAC key) in an
|
||||
/// error diagnostic.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TargetArg {
|
||||
input: String,
|
||||
}
|
||||
|
||||
impl TargetArg {
|
||||
pub fn new(input: impl Into<String>) -> Self {
|
||||
Self {
|
||||
input: input.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetArg {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("TargetArg(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture one positional target without exposing it through Clap diagnostics.
|
||||
pub fn parse_target(input: &str) -> Result<TargetArg, String> {
|
||||
Ok(TargetArg::new(input))
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct TargetSpec {
|
||||
pub label: String,
|
||||
pub addr: String,
|
||||
pub auth: TargetAuth,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("TargetSpec")
|
||||
.field("label", &self.label)
|
||||
.field("addr", &self.addr)
|
||||
.field("auth", &self.auth)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PreparedTarget {
|
||||
pub label: String,
|
||||
pub managed: ManagedTargetConfig,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedTarget {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PreparedTarget")
|
||||
.field("label", &self.label)
|
||||
.field("server_addr", &self.managed.server_addr)
|
||||
.field("auth", &self.managed.auth)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum GroupPacingArg {
|
||||
Staggered,
|
||||
Burst,
|
||||
}
|
||||
|
||||
impl From<GroupPacingArg> for ManagedPacing {
|
||||
fn from(value: GroupPacingArg) -> Self {
|
||||
match value {
|
||||
GroupPacingArg::Staggered => Self::Staggered,
|
||||
GroupPacingArg::Burst => Self::Burst,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TargetParseError {
|
||||
EmptyLabel,
|
||||
EmptyAddress,
|
||||
InvalidEscape,
|
||||
EmptyTarget,
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let message = match self {
|
||||
Self::EmptyLabel => "target label must not be empty",
|
||||
Self::EmptyAddress => "target address must not be empty",
|
||||
Self::InvalidEscape => "invalid target escape sequence",
|
||||
Self::EmptyTarget => "target must not be empty",
|
||||
};
|
||||
f.write_str(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn split_unescaped(input: &str, delimiter: char) -> Result<Vec<&str>, TargetParseError> {
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == delimiter {
|
||||
parts.push(&input[start..index]);
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
parts.push(&input[start..]);
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
fn first_unescaped(input: &str, delimiter: char) -> Result<Option<usize>, TargetParseError> {
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if ch == delimiter {
|
||||
return Ok(Some(index));
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn unescape(input: &str) -> Result<String, TargetParseError> {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut escaped = false;
|
||||
for ch in input.chars() {
|
||||
if escaped {
|
||||
if !matches!(ch, '\\' | '=' | ';' | ',' | '@') {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
output.push(ch);
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else {
|
||||
output.push(ch);
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn hmac_modifier(input: &str) -> Result<Option<usize>, TargetParseError> {
|
||||
let mut escaped = false;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if ch == '\\' {
|
||||
escaped = true;
|
||||
} else if input[index..].starts_with("@hmac=") {
|
||||
return Ok(Some(index));
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
return Err(TargetParseError::InvalidEscape);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_target_syntax(
|
||||
input: &str,
|
||||
) -> Result<(Option<String>, String, TargetAuth), TargetParseError> {
|
||||
if input.is_empty() {
|
||||
return Err(TargetParseError::EmptyTarget);
|
||||
}
|
||||
let (main, auth) = match hmac_modifier(input)? {
|
||||
Some(index) => {
|
||||
let value = unescape(&input[index + "@hmac=".len()..])?;
|
||||
let auth = if value.is_empty() {
|
||||
TargetAuth::Override(Authentication::Unauthenticated)
|
||||
} else {
|
||||
TargetAuth::Override(Authentication::Hmac(HmacKey::new(value.into_bytes())))
|
||||
};
|
||||
(&input[..index], auth)
|
||||
}
|
||||
None => (input, TargetAuth::Inherit),
|
||||
};
|
||||
let (label, address) = match first_unescaped(main, '=')? {
|
||||
None => (None, unescape(main)?),
|
||||
Some(index) => {
|
||||
let label = unescape(&main[..index])?;
|
||||
let address = unescape(&main[index + '='.len_utf8()..])?;
|
||||
(Some(label), address)
|
||||
}
|
||||
};
|
||||
if label.as_deref().is_some_and(str::is_empty) {
|
||||
return Err(TargetParseError::EmptyLabel);
|
||||
}
|
||||
if address.is_empty() {
|
||||
return Err(TargetParseError::EmptyAddress);
|
||||
}
|
||||
Ok((label, address, auth))
|
||||
}
|
||||
|
||||
pub(crate) fn target_specs_with_empty(
|
||||
targets: &[TargetArg],
|
||||
allow_empty: bool,
|
||||
) -> Result<Vec<TargetSpec>, String> {
|
||||
let mut specs = Vec::with_capacity(targets.len());
|
||||
let mut unlabeled_counts = std::collections::HashMap::<String, usize>::new();
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
let (explicit_label, addr, auth) = parse_target_syntax(&target.input)
|
||||
.map_err(|error| format!("invalid target {}: {error}", index + 1))?;
|
||||
let label = match explicit_label {
|
||||
Some(label) => label,
|
||||
None => {
|
||||
let count = unlabeled_counts.entry(addr.clone()).or_default();
|
||||
*count += 1;
|
||||
if *count == 1 {
|
||||
addr.clone()
|
||||
} else {
|
||||
format!("{}#{}", addr, *count)
|
||||
}
|
||||
}
|
||||
};
|
||||
specs.push(TargetSpec { label, addr, auth });
|
||||
}
|
||||
|
||||
if specs.is_empty() && !allow_empty {
|
||||
return Err("at least one target is required unless --list-columns is set".to_owned());
|
||||
}
|
||||
|
||||
let mut labels = HashSet::new();
|
||||
for spec in &specs {
|
||||
if !labels.insert(spec.label.clone()) {
|
||||
return Err("duplicate target label".to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
pub fn target_specs(targets: &[TargetArg]) -> Result<Vec<TargetSpec>, String> {
|
||||
target_specs_with_empty(targets, false)
|
||||
}
|
||||
|
||||
pub fn prepare_managed_targets(specs: Vec<TargetSpec>) -> Result<Vec<PreparedTarget>, String> {
|
||||
let mut targets = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let mut managed = ManagedTargetConfig::new(TargetId::from(spec.label.clone()), spec.addr);
|
||||
managed.auth = spec.auth;
|
||||
targets.push(PreparedTarget {
|
||||
label: spec.label,
|
||||
managed,
|
||||
});
|
||||
}
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Parse one complete stdin target set after its line terminator was removed.
|
||||
///
|
||||
/// Commas frame stdin elements only; positional target arguments do not use
|
||||
/// this framing and may therefore contain raw commas.
|
||||
pub fn parse_stdin_target_set(
|
||||
record: &str,
|
||||
maximum_targets: usize,
|
||||
) -> Result<Vec<PreparedTarget>, String> {
|
||||
if record == "[]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let elements = split_unescaped(record, ',').map_err(|error| error.to_string())?;
|
||||
if elements.len() > maximum_targets {
|
||||
return Err(format!(
|
||||
"target set exceeds the {maximum_targets}-target limit"
|
||||
));
|
||||
}
|
||||
let args = elements.into_iter().map(TargetArg::new).collect::<Vec<_>>();
|
||||
prepare_managed_targets(target_specs_with_empty(&args, true)?)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::{io, thread, time::Duration};
|
||||
|
||||
use irtt_client::managed::{ManagedClientHandle, ManagedClientTask, ManagedOutcome};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// Keeps measurement scheduling separate from frontend output and statistics.
|
||||
/// Drop also stops and joins on frontend errors or cancellation.
|
||||
pub(crate) struct ManagedWorker {
|
||||
handle: ManagedClientHandle,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
completion: oneshot::Receiver<io::Result<ManagedOutcome>>,
|
||||
}
|
||||
|
||||
impl ManagedWorker {
|
||||
pub(crate) fn start(task: ManagedClientTask, handle: ManagedClientHandle) -> io::Result<Self> {
|
||||
let (completed, completion) = oneshot::channel();
|
||||
let thread = thread::Builder::new()
|
||||
.name("irtt-managed".to_owned())
|
||||
.spawn(move || {
|
||||
// Construct and destroy the runtime on its owning thread, including
|
||||
// startup failures; never drop a runtime inside the frontend executor.
|
||||
let outcome = (|| {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
runtime.block_on(task)
|
||||
}));
|
||||
runtime.shutdown_timeout(Duration::from_millis(100));
|
||||
match outcome {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(panic) => std::panic::resume_unwind(panic),
|
||||
}
|
||||
})();
|
||||
let _ = completed.send(outcome);
|
||||
})?;
|
||||
Ok(Self {
|
||||
handle,
|
||||
thread: Some(thread),
|
||||
completion,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn join(mut self) -> Result<ManagedOutcome, Box<dyn std::error::Error>> {
|
||||
let outcome = (&mut self.completion).await;
|
||||
self.join_thread()?;
|
||||
Ok(outcome
|
||||
.map_err(|_| io::Error::other("managed worker completed without an outcome"))??)
|
||||
}
|
||||
|
||||
fn join_thread(&mut self) -> io::Result<()> {
|
||||
self.thread
|
||||
.take()
|
||||
.expect("managed worker is joined once")
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("managed worker thread panicked"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedWorker {
|
||||
fn drop(&mut self) {
|
||||
if self.thread.is_some() {
|
||||
drop(self.handle.stop());
|
||||
let _ = self.join_thread();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
pub mod client;
|
||||
@@ -0,0 +1,22 @@
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
||||
ctrlc::set_handler(move || {
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
/// Preserve ctrlc's SIGINT/SIGTERM policy while waking the async frontends.
|
||||
#[cfg(any(feature = "client", feature = "tui"))]
|
||||
pub fn install_async_signal_handler() -> Result<tokio::sync::watch::Receiver<bool>, ctrlc::Error> {
|
||||
let (shutdown, receiver) = tokio::sync::watch::channel(false);
|
||||
ctrlc::set_handler(move || {
|
||||
shutdown.send_replace(true);
|
||||
})?;
|
||||
Ok(receiver)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "irtt-cli"
|
||||
version = "0.2.0"
|
||||
description = "Command-line IRTT-compatible client"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||
categories = ["command-line-utilities", "network-programming"]
|
||||
|
||||
[features]
|
||||
default = ["stats"]
|
||||
stats = ["dep:irtt-stats"]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
ctrlc = "3.5.2"
|
||||
irtt-client.workspace = true
|
||||
irtt-proto.workspace = true
|
||||
irtt-stats = { workspace = true, optional = true }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,479 +0,0 @@
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
process::ExitCode,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
use irtt_cli::{
|
||||
format_event, format_human_event_with_options, CliArgs, HumanOutputOptions, OutputMode,
|
||||
};
|
||||
use irtt_client::{Client, ClientEvent, OpenOutcome, RecvBudget};
|
||||
|
||||
#[cfg(test)]
|
||||
use irtt_client::ClientTimestamp;
|
||||
|
||||
#[cfg(feature = "stats")]
|
||||
use irtt_stats::{StatsCollector, StatsConfig};
|
||||
|
||||
const RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 16 };
|
||||
const MAX_FINAL_DRAIN: Duration = Duration::from_secs(30);
|
||||
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args = CliArgs::parse();
|
||||
let shutdown_requested = Arc::new(AtomicBool::new(false));
|
||||
if let Err(err) = install_signal_handler(Arc::clone(&shutdown_requested)) {
|
||||
eprintln!("irtt-rs: failed to install signal handler: {err}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
match run(args, shutdown_requested.as_ref()) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
eprintln!("irtt-rs: {err}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_signal_handler(shutdown_requested: Arc<AtomicBool>) -> Result<(), ctrlc::Error> {
|
||||
ctrlc::set_handler(move || {
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
})
|
||||
}
|
||||
|
||||
fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mode = args.output;
|
||||
let continuous = args.is_continuous();
|
||||
let mut stdout = io::LineWriter::new(io::stdout().lock());
|
||||
#[cfg(feature = "stats")]
|
||||
let mut stats = StatsCollector::new(stats_config(continuous));
|
||||
let mut output = EventOutput {
|
||||
mode,
|
||||
human_options: HumanOutputOptions {
|
||||
verbose: args.verbose,
|
||||
},
|
||||
print_finite_summary: !continuous,
|
||||
out: &mut stdout,
|
||||
#[cfg(feature = "stats")]
|
||||
stats: &mut stats,
|
||||
};
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut client = Client::connect(args.to_client_config())?;
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let open = client.open()?;
|
||||
output.print_event(open_event(&open))?;
|
||||
|
||||
let mut interrupted = false;
|
||||
while should_continue_run(continuous, client.next_send_deadline(), shutdown_requested) {
|
||||
if should_send_probe(client.next_send_deadline(), shutdown_requested) {
|
||||
let events = client.send_probe()?;
|
||||
output.print_events(&events)?;
|
||||
}
|
||||
|
||||
let events = client.recv_available(RECV_BUDGET)?;
|
||||
output.print_events(&events)?;
|
||||
|
||||
let events = client.poll_timeouts()?;
|
||||
output.print_events(&events)?;
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
sleep_until_next_send(client.next_send_deadline());
|
||||
}
|
||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||
|
||||
if interrupted {
|
||||
eprintln!("interrupted, closing session...");
|
||||
}
|
||||
|
||||
if should_drain_final(continuous, interrupted) {
|
||||
drain_final_replies(&mut client, &mut output)?;
|
||||
}
|
||||
|
||||
let events = client.poll_timeouts()?;
|
||||
output.print_events(&events)?;
|
||||
|
||||
let events = client.close()?;
|
||||
output.print_events(&events)?;
|
||||
output.print_summary()?;
|
||||
output.out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_shutdown_requested(shutdown_requested: &AtomicBool) -> bool {
|
||||
shutdown_requested.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn should_continue_run(
|
||||
continuous: bool,
|
||||
next_send_deadline: Option<Instant>,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> bool {
|
||||
!is_shutdown_requested(shutdown_requested) && (continuous || next_send_deadline.is_some())
|
||||
}
|
||||
|
||||
fn should_send_probe(next_send_deadline: Option<Instant>, shutdown_requested: &AtomicBool) -> bool {
|
||||
!is_shutdown_requested(shutdown_requested)
|
||||
&& next_send_deadline.is_some_and(|deadline| deadline <= Instant::now())
|
||||
}
|
||||
|
||||
fn should_drain_final(continuous: bool, interrupted: bool) -> bool {
|
||||
!continuous || interrupted
|
||||
}
|
||||
|
||||
fn open_event(outcome: &OpenOutcome) -> &ClientEvent {
|
||||
match outcome {
|
||||
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => event,
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_final_replies<W: Write>(
|
||||
client: &mut Client,
|
||||
output: &mut EventOutput<'_, W>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
|
||||
loop {
|
||||
let mut printed = false;
|
||||
|
||||
let events = client.recv_available(RECV_BUDGET)?;
|
||||
printed |= !events.is_empty();
|
||||
output.print_events(&events)?;
|
||||
|
||||
let events = client.poll_timeouts()?;
|
||||
printed |= !events.is_empty();
|
||||
output.print_events(&events)?;
|
||||
|
||||
if client.is_run_complete() || Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
if !printed {
|
||||
thread::sleep(IDLE_SLEEP);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sleep_until_next_send(deadline: Option<Instant>) {
|
||||
let sleep_for = match deadline {
|
||||
Some(deadline) => deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.min(MAX_SLEEP),
|
||||
None => Duration::from_millis(1),
|
||||
};
|
||||
if !sleep_for.is_zero() {
|
||||
thread::sleep(sleep_for);
|
||||
}
|
||||
}
|
||||
|
||||
struct EventOutput<'a, W: Write> {
|
||||
mode: irtt_cli::OutputMode,
|
||||
human_options: HumanOutputOptions,
|
||||
print_finite_summary: bool,
|
||||
out: &'a mut W,
|
||||
#[cfg(feature = "stats")]
|
||||
stats: &'a mut StatsCollector,
|
||||
}
|
||||
|
||||
impl<W: Write> EventOutput<'_, W> {
|
||||
fn print_events(&mut self, events: &[ClientEvent]) -> io::Result<()> {
|
||||
for event in events {
|
||||
self.print_event(event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_event(&mut self, event: &ClientEvent) -> io::Result<()> {
|
||||
#[cfg(feature = "stats")]
|
||||
let stats_update = self.stats.process(event);
|
||||
|
||||
#[cfg(not(feature = "stats"))]
|
||||
let stats_update = irtt_cli::HumanEventStats::default();
|
||||
|
||||
let line =
|
||||
if self.mode == OutputMode::Human && !matches!(event, ClientEvent::EchoSent { .. }) {
|
||||
Some(format_human_event_with_options(
|
||||
event,
|
||||
Some(stats_update.into()),
|
||||
self.human_options,
|
||||
))
|
||||
} else {
|
||||
format_event(event, self.mode)
|
||||
};
|
||||
|
||||
if let Some(line) = line {
|
||||
writeln!(self.out, "{line}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_summary(&mut self) -> io::Result<()> {
|
||||
if !self.print_finite_summary || !self.mode.prints_summary() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(feature = "stats")]
|
||||
{
|
||||
write!(
|
||||
self.out,
|
||||
"{}",
|
||||
irtt_cli::summary::format_summary_with_options(
|
||||
&self.stats.summary(),
|
||||
irtt_cli::summary::SummaryFormatOptions {
|
||||
verbose: self.human_options.verbose,
|
||||
},
|
||||
)
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "stats")]
|
||||
fn stats_config(continuous: bool) -> StatsConfig {
|
||||
if continuous {
|
||||
StatsConfig::continuous()
|
||||
} else {
|
||||
StatsConfig::finite()
|
||||
}
|
||||
}
|
||||
|
||||
fn final_drain_duration(probe_timeout: Duration) -> Duration {
|
||||
probe_timeout.min(MAX_FINAL_DRAIN)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shutdown_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shutdown_flag_stops_run_loop() {
|
||||
let shutdown = AtomicBool::new(false);
|
||||
assert!(should_continue_run(true, Some(Instant::now()), &shutdown));
|
||||
|
||||
shutdown.store(true, Ordering::Relaxed);
|
||||
assert!(!should_continue_run(true, Some(Instant::now()), &shutdown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_flag_suppresses_due_probe_send() {
|
||||
let shutdown = AtomicBool::new(false);
|
||||
let due = Instant::now() - Duration::from_millis(1);
|
||||
assert!(should_send_probe(Some(due), &shutdown));
|
||||
|
||||
shutdown.store(true, Ordering::Relaxed);
|
||||
assert!(!should_send_probe(Some(due), &shutdown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_continuous_run_uses_final_drain_before_close() {
|
||||
assert!(should_drain_final(true, true));
|
||||
assert!(!should_drain_final(true, false));
|
||||
assert!(should_drain_final(false, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "stats"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::{PacketMeta, RttSample, SignedDuration};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
time::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)
|
||||
}
|
||||
|
||||
fn reply_event(seq: u32, rtt_us: u64) -> ClientEvent {
|
||||
let sent_at = test_timestamp(Duration::from_millis(seq as u64));
|
||||
let received_at = ClientTimestamp {
|
||||
wall: sent_at.wall + Duration::from_micros(rtt_us),
|
||||
mono: sent_at.mono + Duration::from_micros(rtt_us),
|
||||
};
|
||||
ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(rtt_us),
|
||||
adjusted: None,
|
||||
effective: Duration::from_micros(rtt_us),
|
||||
adjusted_signed: None,
|
||||
effective_signed: SignedDuration {
|
||||
ns: i128::from(rtt_us) * 1_000,
|
||||
},
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_helper_streams_and_collects_events() {
|
||||
let sent_at = test_timestamp(Duration::from_secs(1));
|
||||
let received_at = test_timestamp(Duration::from_secs(1) + Duration::from_micros(1200));
|
||||
let events = [
|
||||
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::ZERO,
|
||||
},
|
||||
ClientEvent::EchoReply {
|
||||
seq: 1,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1200),
|
||||
adjusted: None,
|
||||
effective: Duration::from_micros(1200),
|
||||
adjusted_signed: None,
|
||||
effective_signed: SignedDuration { ns: 1_200_000 },
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
},
|
||||
];
|
||||
|
||||
let mut stats = StatsCollector::new(StatsConfig::finite());
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut output = EventOutput {
|
||||
mode: irtt_cli::OutputMode::RttUs,
|
||||
human_options: HumanOutputOptions::default(),
|
||||
print_finite_summary: true,
|
||||
out: &mut out,
|
||||
stats: &mut stats,
|
||||
};
|
||||
output.print_events(&events).unwrap();
|
||||
output.print_summary().unwrap();
|
||||
}
|
||||
|
||||
let rendered = String::from_utf8(out).unwrap();
|
||||
let summary = stats.summary();
|
||||
assert_eq!(rendered, "1200\n");
|
||||
assert_eq!(summary.packets.packets_sent, 1);
|
||||
assert_eq!(summary.packets.unique_replies, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_output_does_not_print_finite_summary() {
|
||||
let mut stats = StatsCollector::new(StatsConfig::continuous());
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut output = EventOutput {
|
||||
mode: irtt_cli::OutputMode::Human,
|
||||
human_options: HumanOutputOptions::default(),
|
||||
print_finite_summary: false,
|
||||
out: &mut out,
|
||||
stats: &mut stats,
|
||||
};
|
||||
output.print_summary().unwrap();
|
||||
}
|
||||
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_output_prints_ipdv_from_stats_update() {
|
||||
let events = [reply_event(0, 1200), reply_event(1, 1250)];
|
||||
let mut stats = StatsCollector::new(StatsConfig::finite());
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut output = EventOutput {
|
||||
mode: irtt_cli::OutputMode::Human,
|
||||
human_options: HumanOutputOptions::default(),
|
||||
print_finite_summary: false,
|
||||
out: &mut out,
|
||||
stats: &mut stats,
|
||||
};
|
||||
output.print_events(&events).unwrap();
|
||||
}
|
||||
|
||||
let rendered = String::from_utf8(out).unwrap();
|
||||
let mut lines = rendered.lines();
|
||||
assert!(lines.next().unwrap().contains("ipdv=n/a"));
|
||||
assert!(lines.next().unwrap().contains("ipdv=50.0µs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_mode_uses_continuous_stats_config() {
|
||||
let mut collector = StatsCollector::new(stats_config(true));
|
||||
for seq in 0..5000_u32 {
|
||||
let sent_at = test_timestamp(Duration::from_micros(seq as u64));
|
||||
let received_at = test_timestamp(Duration::from_micros(seq as u64 + 1));
|
||||
collector.process(&ClientEvent::EchoReply {
|
||||
seq,
|
||||
remote: test_remote(),
|
||||
sent_at,
|
||||
received_at,
|
||||
rtt: RttSample {
|
||||
raw: Duration::from_micros(1),
|
||||
adjusted: None,
|
||||
effective: Duration::from_micros(1),
|
||||
adjusted_signed: None,
|
||||
effective_signed: SignedDuration { ns: 1_000 },
|
||||
},
|
||||
server_timing: None,
|
||||
one_way: None,
|
||||
received_stats: None,
|
||||
bytes: 64,
|
||||
packet_meta: PacketMeta::default(),
|
||||
});
|
||||
}
|
||||
|
||||
assert_eq!(collector.summary().rtt.primary.median_ns, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_drain_uses_capped_probe_timeout() {
|
||||
assert_eq!(
|
||||
final_drain_duration(Duration::from_secs(4)),
|
||||
Duration::from_secs(4)
|
||||
);
|
||||
assert_eq!(
|
||||
final_drain_duration(Duration::from_secs(60)),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use irtt_stats::{
|
||||
DurationStats, DurationStatsWithMedian, FiniteSummary, SignedDurationStatsWithMedian,
|
||||
};
|
||||
|
||||
pub fn format_summary(summary: &FiniteSummary) -> String {
|
||||
format_summary_with_options(summary, SummaryFormatOptions::default())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct SummaryFormatOptions {
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
pub fn format_summary_with_options(
|
||||
summary: &FiniteSummary,
|
||||
options: SummaryFormatOptions,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let packets = summary.packets;
|
||||
let loss = summary.loss;
|
||||
|
||||
writeln!(out).unwrap();
|
||||
writeln!(out, "irtt-rs summary").unwrap();
|
||||
writeln!(out).unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
" {:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||
"Metric", "Count", "Min", "Mean", "Median", "Max", "Stddev"
|
||||
)
|
||||
.unwrap();
|
||||
writeln!(out, " {}", "-".repeat(82)).unwrap();
|
||||
|
||||
write_signed_duration_row(&mut out, "RTT", &summary.rtt.primary);
|
||||
if options.verbose {
|
||||
write_duration_row(&mut out, "raw RTT", &summary.rtt.raw);
|
||||
write_signed_duration_row(&mut out, "adjusted RTT", &summary.rtt.adjusted);
|
||||
}
|
||||
write_duration_row(&mut out, "IPDV/jitter", &summary.ipdv.round_trip);
|
||||
write_duration_row(&mut out, "send IPDV", &summary.ipdv.send);
|
||||
write_duration_row(&mut out, "receive IPDV", &summary.ipdv.receive);
|
||||
write_duration_row(&mut out, "send delay", &summary.one_way_delay.send_delay);
|
||||
write_duration_row(
|
||||
&mut out,
|
||||
"receive delay",
|
||||
&summary.one_way_delay.receive_delay,
|
||||
);
|
||||
write_duration_row_no_median(
|
||||
&mut out,
|
||||
"server processing",
|
||||
&summary.server_processing.processing,
|
||||
);
|
||||
write_duration_row_no_median(&mut out, "send call", &summary.send_call);
|
||||
write_duration_row_no_median(&mut out, "timer error", &summary.timer_error);
|
||||
|
||||
writeln!(out).unwrap();
|
||||
writeln!(
|
||||
out,
|
||||
"packets: sent={} received={} unique={} lost={} loss={}",
|
||||
packets.packets_sent,
|
||||
packets.packets_received,
|
||||
packets.unique_replies,
|
||||
loss.lost_packets,
|
||||
format_percent(loss.packet_loss_percent)
|
||||
)
|
||||
.unwrap();
|
||||
if packets.duplicates != 0 || packets.late_packets != 0 {
|
||||
writeln!(
|
||||
out,
|
||||
"replies: duplicates={} ({}) late={} ({})",
|
||||
packets.duplicates,
|
||||
format_percent(loss.duplicate_percent),
|
||||
packets.late_packets,
|
||||
format_percent(loss.late_packets_percent)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"bytes: sent={} received={}",
|
||||
packets.bytes_sent, packets.bytes_received
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if packets.server_packets_received.is_some() || packets.server_received_window.is_some() {
|
||||
write!(out, "server:").unwrap();
|
||||
if let Some(count) = packets.server_packets_received {
|
||||
write!(out, " received={count}").unwrap();
|
||||
}
|
||||
if let Some(window) = packets.server_received_window {
|
||||
write!(out, " window={window:#x}").unwrap();
|
||||
}
|
||||
writeln!(out).unwrap();
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn write_duration_row(out: &mut String, label: &str, value: &DurationStatsWithMedian) {
|
||||
if value.stats.count == 0 {
|
||||
return;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||
value.stats.count,
|
||||
format_ns_u64(value.stats.min_ns),
|
||||
format_ns_f64(value.stats.mean_ns),
|
||||
format_ns_f64_opt(value.median_ns),
|
||||
format_ns_u64(value.stats.max_ns),
|
||||
format_ns_f64(value.stddev_ns())
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_duration_row_no_median(out: &mut String, label: &str, value: &DurationStats) {
|
||||
if value.count == 0 {
|
||||
return;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||
value.count,
|
||||
format_ns_u64(value.min_ns),
|
||||
format_ns_f64(value.mean_ns),
|
||||
"-",
|
||||
format_ns_u64(value.max_ns),
|
||||
format_ns_f64(value.stddev_ns())
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_signed_duration_row(out: &mut String, label: &str, value: &SignedDurationStatsWithMedian) {
|
||||
if value.stats.count == 0 {
|
||||
return;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {label:<18} {:>7} {:>10} {:>10} {:>10} {:>10} {:>10}",
|
||||
value.stats.count,
|
||||
format_ns_i128(value.stats.min_ns),
|
||||
format_ns_f64(value.stats.mean_ns),
|
||||
format_ns_f64_opt(value.median_ns),
|
||||
format_ns_i128(value.stats.max_ns),
|
||||
format_ns_f64(value.stddev_ns())
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn format_percent(value: f64) -> String {
|
||||
format!("{value:.2}%")
|
||||
}
|
||||
|
||||
fn format_ns_u64(value: Option<u64>) -> String {
|
||||
value
|
||||
.map(|value| format_ns_f64(value as f64))
|
||||
.unwrap_or_else(|| "-".to_owned())
|
||||
}
|
||||
|
||||
fn format_ns_i128(value: Option<i128>) -> String {
|
||||
value
|
||||
.map(|value| format_ns_f64(value as f64))
|
||||
.unwrap_or_else(|| "-".to_owned())
|
||||
}
|
||||
|
||||
fn format_ns_f64_opt(value: Option<f64>) -> String {
|
||||
value.map(format_ns_f64).unwrap_or_else(|| "-".to_owned())
|
||||
}
|
||||
|
||||
fn format_ns_f64(value: f64) -> String {
|
||||
let sign = if value.is_sign_negative() { "-" } else { "" };
|
||||
let value = value.abs();
|
||||
if value < 1_000.0 {
|
||||
format!("{sign}{value:.0}ns")
|
||||
} else if value < 1_000_000.0 {
|
||||
format!("{sign}{:.1}µs", value / 1_000.0)
|
||||
} else if value < 1_000_000_000.0 {
|
||||
format!("{sign}{:.1}ms", value / 1_000_000.0)
|
||||
} else {
|
||||
format!("{sign}{:.3}s", value / 1_000_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use irtt_client::{
|
||||
ClientEvent, ClientTimestamp, PacketMeta, RttSample, ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_stats::{SignedDurationStats, 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()).summary();
|
||||
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(Duration::from_micros(1200)),
|
||||
effective: Duration::from_micros(1200),
|
||||
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
|
||||
effective_signed: SignedDuration { ns: 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.summary());
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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(Duration::from_micros(1200)),
|
||||
effective: Duration::from_micros(1200),
|
||||
adjusted_signed: Some(SignedDuration { ns: 1_200_000 }),
|
||||
effective_signed: SignedDuration { ns: 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.summary(),
|
||||
SummaryFormatOptions { verbose: true },
|
||||
);
|
||||
|
||||
assert!(output.contains("raw RTT"));
|
||||
assert!(output.contains("adjusted RTT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_stats_can_format_negative_values() {
|
||||
let stats = SignedDurationStatsWithMedian {
|
||||
stats: SignedDurationStats {
|
||||
count: 1,
|
||||
total_ns: -500,
|
||||
min_ns: Some(-500),
|
||||
max_ns: Some(-500),
|
||||
mean_ns: -500.0,
|
||||
variance_ns2: 0.0,
|
||||
},
|
||||
median_ns: Some(-500.0),
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
write_signed_duration_row(&mut out, "RTT", &stats);
|
||||
assert!(out.contains("-500ns"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
# `irtt-client` guidance
|
||||
|
||||
Repository-wide guidance in the root `AGENTS.md` also applies here.
|
||||
|
||||
## Probe-send commitment API
|
||||
|
||||
`Client::send_probe` and `AsyncClient::send_probe` return
|
||||
`Result<SendReceipt, SendProbeError>`; the Tokio poll driver returns the same
|
||||
result inside `Poll`. A receipt represents an Echo datagram accepted by the
|
||||
socket and already committed in session state: sequence and sent count advanced,
|
||||
and the probe entered pending tracking. It contains `seq`, `remote`, the public
|
||||
post-send `sent_at`, accepted `bytes`, and `send_call`, without schedule metadata.
|
||||
`ClientEvent::from(receipt)` constructs `EchoSent` with no schedule annotations.
|
||||
|
||||
Classify every failure explicitly: `NotCommitted(ClientError)` means no datagram
|
||||
was accepted and no probe committed; `AfterCommit { receipt, source }` preserves
|
||||
the authoritative accepted send when length validation or TX error-queue
|
||||
processing fails. There is no blanket `From<ClientError>` conversion. Do not
|
||||
roll back a committed probe or classify a post-send error with an indiscriminate
|
||||
`?`.
|
||||
|
||||
All fallible state/capacity/counter/allocation and timeout preparation precedes
|
||||
submission. Immediately after `send`/`try_send` returns `Ok(bytes)`, capture the
|
||||
public post-send timestamp, commit infallibly, and construct the receipt before
|
||||
fallible post-send processing. No suspension, readiness wait, fallible allocation,
|
||||
or fallible state operation may intervene between acceptance and commitment;
|
||||
the Tokio poll must never return `Pending` after acceptance.
|
||||
|
||||
The managed driver consumes receipts for cadence/stagger acceptance and constructs
|
||||
`EchoSent` with its scheduled timestamp and timer error. A post-commit failure
|
||||
advances cadence and publishes that send before failure cleanup; the underlying
|
||||
cause remains a sending failure. `NotCommitted` and `Pending` advance neither
|
||||
cadence nor stagger gating. Counters are cumulative accounting, never evidence
|
||||
of one operation's commitment.
|
||||
|
||||
Tokio retains one prepared probe across cancellation/readiness false positives
|
||||
and `WouldBlock`, samples a fresh private anchor on each retry, and clears the
|
||||
prepared probe after commitment even when post-send processing fails. Failed
|
||||
submissions still invalidate kernel TX ID correlation as documented below.
|
||||
|
||||
## Linux kernel TX timestamp capture
|
||||
|
||||
On Linux, with the `ancillary` feature, the client best-effort upgrades its
|
||||
socket from RX-only `SO_TIMESTAMPING` to RX+TX immediately after a successful
|
||||
Open, before the first Echo probe can be sent:
|
||||
|
||||
```
|
||||
socket created
|
||||
-> RX timestamping configured (RX_SOFTWARE | SOFTWARE)
|
||||
-> Open attempts/retries (untimestamped)
|
||||
-> successful Open
|
||||
-> best-effort enable TX timestamping (+ TX_SOFTWARE | OPT_ID | OPT_TSONLY)
|
||||
-> first Echo probe
|
||||
```
|
||||
|
||||
`TX_SOFTWARE` asks the kernel for a software send timestamp; `OPT_ID` asks it
|
||||
to tag each notification with an automatically incrementing per-socket ID;
|
||||
`OPT_TSONLY` means the notification never needs to carry the original
|
||||
payload, so a zero-length receive buffer is enough. The upgrade is entirely
|
||||
best-effort: if it fails, the client keeps operating on the original RX-only
|
||||
configuration (`try_enable_tx_timestamping` restores it explicitly) and
|
||||
timing falls back to userspace `sent_at` as it always has. TX capability
|
||||
failure is never a public error and is never logged from the library.
|
||||
|
||||
### Best-effort kernel-ID correlation
|
||||
|
||||
The client uses the kernel's automatic `OPT_ID` as the probe's wire-sequence
|
||||
correlation ID — no separate correlation map. On the normal successful-send
|
||||
path, both counters start at zero after Open and advance together. This is
|
||||
not a permanent identity: a kernel ID can theoretically be consumed while a
|
||||
send is being prepared and then survive a subsequent send failure, leaving
|
||||
kernel IDs one or more steps ahead of `next_wire_seq`. Do not restore an
|
||||
assumption that failed or nonblocking sends cannot cause that desynchrony.
|
||||
|
||||
The effect is confined to the optional timing enhancement. A kernel timestamp
|
||||
whose ID cannot be matched to a pending or retained timed-out probe is
|
||||
discarded, as is any implausible timestamp; `sent_at` remains the ordinary
|
||||
userspace fallback. **After every failed probe submission, disable kernel-ID
|
||||
correlation for the rest of that session.** A gap means an otherwise plausible
|
||||
later ID can name the wrong probe, so subsequent error-queue timestamps must
|
||||
be discarded rather than attached by ID. This is safe because:
|
||||
|
||||
- **Normal successful sends track together.** `wire_seq` starts at 0 on every
|
||||
successful Open (`SessionMachine::commit_open` builds a fresh
|
||||
`ActiveSession` with `next_wire_seq: 0`) and advances by exactly 1 only via
|
||||
`commit_probe_sent`, after a confirmed successful socket send. TX
|
||||
timestamping is enabled after that same successful Open and before any probe
|
||||
is sent, so the normal zero points line up.
|
||||
- **One socket, one session, no reopen.** `SessionMachine`'s state machine
|
||||
only reaches `Open` from `Connected`, and `commit_open`/`commit_local_close`
|
||||
never route back to `Connected`. A `Client`/`AsyncClient` cannot reopen a
|
||||
second session on the same socket, so there is no second epoch of wire
|
||||
sequence numbers to collide with a stale kernel ID.
|
||||
- **The one extra send (Close) is harmless.** `Close` sends one datagram
|
||||
after TX timestamping is already enabled, generating one TX timestamp
|
||||
record with no corresponding `wire_seq` (`Close` does not advance
|
||||
`next_wire_seq`). Because the session is terminal immediately after,
|
||||
nothing will ever look up that ID; `record_kernel_tx_timestamp`'s
|
||||
"unknown ID -> ignore" rule discards it safely.
|
||||
|
||||
## MSG_ERRQUEUE drains
|
||||
|
||||
`receive::linux::error_queue` decodes one `MSG_ERRQUEUE` record at a time
|
||||
(`try_recv_error_queue_record`) and classifies it: a genuine send-timestamp
|
||||
completion (`TxTimestamp`), a timestamp-origin record that is not a usable
|
||||
`SND` completion (`MalformedOrUnsupportedTimestamp` — never treated as
|
||||
fatal, since this client only ever requests `TX_SOFTWARE`/`SND`, so anything
|
||||
else on that origin is unexpected metadata, not a network error), a genuine
|
||||
non-timestamp socket/network error (`SocketError`), or nothing (`Ignored`).
|
||||
Origin (`SO_EE_ORIGIN_TIMESTAMPING` or not) is the only thing that decides
|
||||
between the last two categories.
|
||||
|
||||
`receive::linux::drain_tx_timestamps` reads up to
|
||||
`MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN` (32) records per call: nonblocking, one
|
||||
record at a time, no heap allocation, stopping early once the queue is
|
||||
empty. It is a starvation guard, not a queue-capacity promise — one TX
|
||||
timestamp is generated per successful probe, and drains happen at several
|
||||
natural choke points, so a modest constant comfortably covers realistic
|
||||
backlog. `Client`/`AsyncClient` call it after a successful probe send,
|
||||
before processing an inbound reply, and before evaluating timeouts; it never
|
||||
waits for a timestamp; an absent timestamp after all of those opportunities
|
||||
simply stays absent. A genuine `SocketError` record stops the drain and is
|
||||
surfaced as `ClientError::Socket`. During post-send processing it is wrapped in
|
||||
`SendProbeError::AfterCommit` with the accepted send's receipt; receive/timeout
|
||||
operations retain the ordinary `ClientError` path.
|
||||
|
||||
The Tokio adapter uses the same raw nonblocking `recvmsg(MSG_ERRQUEUE |
|
||||
MSG_DONTWAIT)` rather than `try_io`/`poll_recv_ready`: error-queue readiness
|
||||
is not ordinary read readiness, and driving it through the Tokio reactor
|
||||
risks clearing readiness state that the normal Echo receive path depends on.
|
||||
There is no background task or second `AsyncFd` around the Tokio-owned
|
||||
socket.
|
||||
|
||||
### Testing kernel timestamp behavior
|
||||
|
||||
The requirements above describe production behavior, not a required unit-test
|
||||
architecture.
|
||||
|
||||
Prefer Linux tests that exercise the real socket and kernel timestamp path
|
||||
through production APIs. A test intended to prove TX timestamp support must
|
||||
actually require and observe a kernel TX timestamp when running on a supported
|
||||
Linux environment; accepting the userspace fallback does not prove that path.
|
||||
|
||||
Likewise, prefer observable socket/client behavior for genuine error-queue
|
||||
errors and bounded draining where it can reasonably be induced.
|
||||
|
||||
Do not restore synthetic `ControlMessageOwned` permutations, classifier-unit
|
||||
suites, raw ancillary fixtures, test-only socket hooks, or other internal seams
|
||||
solely to cover every `MSG_ERRQUEUE` branch. `nix` owns ancillary-message
|
||||
decoding and ABI safety; `irtt-client` tests should primarily prove its own
|
||||
observable interpretation and behavior through the real path.
|
||||
|
||||
If some invariant cannot reasonably be induced through a production interface,
|
||||
a small focused internal test may be retained, but the burden is on that test to
|
||||
justify why integration coverage is insufficient.
|
||||
|
||||
## Retained state
|
||||
|
||||
`PendingProbe` carries an optional `kernel_tx_timestamp: Option<SystemTime>`
|
||||
alongside the userspace `sent_at`. It survives pending -> timed-out retention
|
||||
(`SessionMachine::record_kernel_tx_timestamp` updates whichever of
|
||||
`PendingMap`/`TimedOutMap` still holds the probe) and is otherwise bounded by
|
||||
the same limits those maps already enforce — no new unbounded state. First
|
||||
valid observation wins on duplicates.
|
||||
|
||||
### Pre-send anchor vs. post-send `sent_at` (F-04)
|
||||
|
||||
The probe send transaction splits into two timestamps around the socket
|
||||
send, not one:
|
||||
|
||||
- A **private pre-send anchor**, sampled immediately before the fallible
|
||||
commit preflight (`SessionMachine::finalize_probe_commit`) that must
|
||||
happen before the socket send: timeout deadline `checked_add` arithmetic
|
||||
(so `DurationOverflow` still guarantees nothing was transmitted) and the
|
||||
kernel TX lower-plausibility bound. Its monotonic half becomes
|
||||
`PendingProbe::timeout_at`'s basis; its wall half becomes
|
||||
`PendingProbe::tx_not_before_wall`. It is never exposed publicly.
|
||||
- The **public `sent_at`**, a paired wall/monotonic `ClientTimestamp`
|
||||
captured immediately after the socket send returns successfully
|
||||
(`SessionMachine::commit_probe_sent`, which is infallible — all fallible
|
||||
work already happened against the pre-send anchor). This is the RTT
|
||||
endpoint, the userspace upstream-OWD fallback endpoint, `timer_error`'s
|
||||
basis, and the value surfaced on `EchoSent`/`EchoReply`/`EchoLoss`/
|
||||
`LateReply`.
|
||||
|
||||
So `timeout_at` is deliberately **not** derived from `sent_at.mono` — it
|
||||
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; 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.
|
||||
|
||||
**The kernel TX timestamp is eligible for exactly one measurement: the
|
||||
upstream (`client_to_server`) one-way delay send endpoint.**
|
||||
`compute_one_way`'s private `preferred_send_wall` helper prefers it over the
|
||||
userspace `sent_at.wall` sample when
|
||||
|
||||
tx_not_before_wall <= kernel_tx_timestamp <= reply_received_at.wall
|
||||
|
||||
(inclusive, compared only against the client's own wall clock — never
|
||||
against the remote server's wall timestamp). The lower bound is the
|
||||
pre-send `tx_not_before_wall`, not the post-send `sent_at.wall`: a
|
||||
legitimate `TX_SOFTWARE` timestamp can be generated while the send syscall
|
||||
is still in flight, so it can legitimately precede the post-send `sent_at`
|
||||
sample without being implausible. Otherwise, including when no kernel
|
||||
timestamp was observed, the upstream endpoint falls back to `sent_at.wall`
|
||||
(the post-send sample); a rejected observation is never erased from
|
||||
`PendingProbe`, only skipped for that measurement. There is no
|
||||
maximum-lag bound: unlike the kernel receive side, legitimate client
|
||||
send-to-reply time can exceed a second.
|
||||
|
||||
`sent_at` remains the RTT endpoint and the userspace fallback for the
|
||||
upstream (`client_to_server`) one-way delay, exactly as before this
|
||||
capability existed. It is not an input to the downstream
|
||||
(`server_to_client`) one-way delay, which is derived solely from
|
||||
`ReceiveMeta::preferred_receive_wall` and the negotiated server send
|
||||
timestamp — unaffected by this capability or by F-04. `TX_SOFTWARE` is
|
||||
still a software kernel timestamp near the driver handoff, not a NIC
|
||||
hardware transmit time. `irtt-stats`'s `ReplySample`
|
||||
copies public `sent_at` straight into `client_send_mono`/`client_send_wall_ns`,
|
||||
which `send_ipdv_ns` compares across probes for the upstream IPDV component —
|
||||
so that component shifts endpoint along with `sent_at` itself (pre-send vs.
|
||||
post-send) exactly as RTT and the upstream OWD fallback do; it is not a
|
||||
separate invariant. A normal
|
||||
reply and a measurable late reply (one whose `PendingProbe` is still
|
||||
retained in `TimedOutMap`) apply the identical selection policy; an
|
||||
untracked late reply has no retained probe and stays unmeasurable, and a
|
||||
kernel timestamp that arrives after a reply has already been processed is
|
||||
simply never observed by that reply.
|
||||
@@ -1,25 +1,47 @@
|
||||
[package]
|
||||
name = "irtt-client"
|
||||
version = "0.2.0"
|
||||
version = "0.5.3"
|
||||
description = "IRTT-compatible Rust client library"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
readme = "README.md"
|
||||
keywords = ["irtt", "networking", "latency", "rtt"]
|
||||
categories = ["network-programming"]
|
||||
|
||||
[dependencies]
|
||||
irtt-proto.workspace = true
|
||||
libc = { version = "0.2.186", optional = true }
|
||||
socket2 = { version = "0.6.3", features = ["all"] }
|
||||
thiserror = "2.0.18"
|
||||
socket2 = { version = "0.6.5", features = ["all"] }
|
||||
thiserror = "2.0.21"
|
||||
tokio = { version = "1.53.2", default-features = false, features = ["rt", "net", "time", "sync"], optional = true}
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
nix = { version = "0.31.3", default-features = false, features = ["net", "uio"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ancillary = ["dep:libc"]
|
||||
ancillary = ["dep:nix"]
|
||||
tokio = ["dep:tokio"]
|
||||
|
||||
[dev-dependencies]
|
||||
irtt-server.workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||
|
||||
[[example]]
|
||||
name = "blocking_client"
|
||||
doc-scrape-examples = true
|
||||
|
||||
[[example]]
|
||||
name = "tokio_client"
|
||||
required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
|
||||
[[example]]
|
||||
name = "managed_client"
|
||||
required-features = ["tokio"]
|
||||
doc-scrape-examples = true
|
||||
@@ -0,0 +1,101 @@
|
||||
# irtt-client
|
||||
|
||||
Reusable client/session library for IRTT-compatible round-trip-time probing:
|
||||
socket lifecycle, open negotiation, probe send/receive, loss/duplicate/late
|
||||
classification, and a typed event stream.
|
||||
|
||||
## Looking for the CLI?
|
||||
|
||||
If you just want to run probes from the command line, use the
|
||||
[`irtt-rs`](https://crates.io/crates/irtt-rs) application package instead,
|
||||
which installs the `irtt-client` binary. This crate is the library the
|
||||
binaries are built on.
|
||||
|
||||
## API tiers
|
||||
|
||||
- `Client` — runtime-free blocking adapter. No Tokio dependency; this is the
|
||||
default build.
|
||||
- `AsyncClient` (feature `tokio`) — low-level Tokio adapter for callers that
|
||||
own a runtime and drive readiness directly.
|
||||
- `managed` (feature `tokio`) — a unified managed driver: `ManagedClientTask`
|
||||
/ `ManagedClientHandle` for multi-target orchestration under a Tokio
|
||||
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
|
||||
sends return `Result<SendReceipt, SendProbeError>` instead of event lists.
|
||||
A `SendReceipt` carries the committed sequence, remote, post-send timestamp,
|
||||
accepted bytes, and send-call duration. `NotCommitted(ClientError)` guarantees
|
||||
no accepted datagram or committed probe; `AfterCommit { receipt, source }`
|
||||
preserves the accepted send when subsequent validation or TX processing fails.
|
||||
Use `ClientEvent::from(receipt)` for an `EchoSent` event with `scheduled_at` and
|
||||
`timer_error` absent; the managed driver supplies those schedule measurements
|
||||
and publishes committed sends even when post-send processing fails.
|
||||
|
||||
Tokio stays optional: the default build has no runtime and no Tokio
|
||||
dependency at all.
|
||||
|
||||
See `examples/` in the repository for runnable examples of each tier.
|
||||
|
||||
## Reusable configuration
|
||||
|
||||
`ClientConfig` contains no endpoint. The low-level APIs take it separately:
|
||||
`Client::connect(endpoint, config)` and
|
||||
`AsyncClient::connect(endpoint, config).await`, accepting `String` or `&str`
|
||||
endpoints. An omitted port defaults to 2112.
|
||||
|
||||
Configuration is grouped by ownership:
|
||||
|
||||
- `address_family`: `AddressFamily::{Any, Ipv4, Ipv6}`, defaulting to `Any`.
|
||||
It filters remote resolution; `Ipv6` also sets `IPV6_V6ONLY`.
|
||||
- `socket`: `SocketConfig` for local bind, device/routing options, and TTL.
|
||||
- `request`: `SessionRequest` for duration, interval, length, received stats,
|
||||
timestamps, clock, DSCP, server fill, and run mode.
|
||||
- `open`: `OpenPolicy` for attempt `timeouts` and `negotiation` policy.
|
||||
- `auth`, `probe_timeout`, and `max_pending_probes`: authentication and local
|
||||
probe tracking policy.
|
||||
|
||||
Clone one config to connect to multiple endpoints. Managed targets retain their
|
||||
own ID, endpoint, and authentication inheritance/override; shared
|
||||
`ManagedClientConfig.client` is reusable without endpoint mutation.
|
||||
|
||||
Blocking receive policy belongs to `Client`:
|
||||
`client.set_recv_timeout(Some(duration))?` sets it before or after opening,
|
||||
and `client.set_recv_timeout(None)?` restores the default indefinite wait.
|
||||
Open attempts use only `open.timeouts`, restoring the adapter setting afterward.
|
||||
Async and managed clients have no blocking receive-timeout setting.
|
||||
|
||||
This breaks the former flat config, endpoint-in-config, socket family flags,
|
||||
and `SocketConfig.recv_timeout` APIs. Use the grouped fields and adapter method
|
||||
directly; defaults and protocol/runtime behavior are preserved.
|
||||
|
||||
## Authentication
|
||||
|
||||
`ClientConfig.auth` is concrete: `Authentication::Unauthenticated` or
|
||||
`Authentication::Hmac(HmacKey::new(bytes))`. Keys share their bytes across
|
||||
clones and print only `HmacKey([REDACTED])` through `Debug`. Borrowing key
|
||||
material requires an explicit `as_bytes()` call. An empty key still selects
|
||||
HMAC authentication; textual key syntax belongs to the application.
|
||||
|
||||
For managed targets, `ManagedTargetConfig.auth` defaults to `TargetAuth::Inherit`.
|
||||
`TargetAuth::Override(Authentication::Unauthenticated)` disables shared
|
||||
authentication, and `TargetAuth::Override(Authentication::Hmac(key))` replaces
|
||||
the shared key. Initial construction and update planning resolve inheritance
|
||||
into concrete session authentication before execution. Switching between
|
||||
inheritance and an override replaces the target generation even when their
|
||||
resolved authentication is equal.
|
||||
|
||||
This replaces the former `ClientConfig.hmac_key` and `ClientAuthConfig` API;
|
||||
there are no compatibility aliases.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full API documentation is on [docs.rs/irtt-client](https://docs.rs/irtt-client).
|
||||
|
||||
## Project
|
||||
|
||||
Part of [irtt-rs](https://github.com/Lochnair/irtt-rs), an independent Rust
|
||||
implementation of an IRTT-compatible protocol stack.
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Runtime-free blocking client example.
|
||||
//!
|
||||
//! Opens a session against a local IRTT-compatible server, sends a few
|
||||
//! probes, and prints the events observed. Uses only [`Client`], which has no
|
||||
//! Tokio dependency and works in a default (`--no-default-features`) build.
|
||||
//!
|
||||
//! Run a server first, then run this example:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||
//! cargo run -p irtt-client --example blocking_client
|
||||
//! ```
|
||||
//!
|
||||
//! Without a reachable server this exits quickly with an open-timeout error
|
||||
//! instead of hanging, because the example shortens `open.timeouts` for a
|
||||
//! fast demonstration; a real caller would normally keep the default retry
|
||||
//! schedule.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{Client, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest};
|
||||
|
||||
const RUN_DURATION: Duration = Duration::from_secs(2);
|
||||
|
||||
fn main() {
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
},
|
||||
request: SessionRequest {
|
||||
duration: Some(RUN_DURATION),
|
||||
interval: Duration::from_millis(200),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut client = match Client::connect("127.0.0.1:2112", config) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("failed to prepare client socket: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = client.set_recv_timeout(Some(Duration::from_millis(100))) {
|
||||
eprintln!("failed to configure receive timeout: {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
let negotiated = match client.open() {
|
||||
Ok(OpenOutcome::Started(started)) => {
|
||||
println!("session opened: {started:?}");
|
||||
started.negotiation
|
||||
}
|
||||
Ok(OpenOutcome::NoTestCompleted(completed)) => {
|
||||
println!("{completed:?}");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// This loop owns cadence and duration. The transport sends whenever called.
|
||||
let interval = negotiated.accepted.interval;
|
||||
let start = Instant::now();
|
||||
let end = start
|
||||
+ negotiated
|
||||
.accepted
|
||||
.duration
|
||||
.expect("example requests a finite run");
|
||||
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(receipt) => {
|
||||
print_event(&receipt.into());
|
||||
while next_send <= Instant::now() {
|
||||
next_send += interval;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||
print_event(&(*receipt).into());
|
||||
}
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match client.recv_once() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("receive failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
match client.poll_timeouts() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("timeout polling failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if client.is_peer_closed() {
|
||||
return;
|
||||
}
|
||||
match client.close() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_event(event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||
println!("seq {seq}: rtt {:?}", rtt.effective);
|
||||
}
|
||||
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
|
||||
other => println!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Managed multi-target client example (`tokio` feature).
|
||||
//!
|
||||
//! [`BlockingManagedClient`] runs one or more targets under a dedicated
|
||||
//! current-thread Tokio runtime and exposes a synchronous handle, so a caller
|
||||
//! that does not want to touch Tokio directly can still get managed
|
||||
//! multi-target orchestration, retries, and a lossy event stream.
|
||||
//!
|
||||
//! Run a server first, then run this example:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||
//! cargo run -p irtt-client --example managed_client --features tokio
|
||||
//! ```
|
||||
//!
|
||||
//! This example does not wait for the managed run to finish naturally: it
|
||||
//! requests a stop after a fixed short window so it exits promptly even
|
||||
//! without a reachable server.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::managed::{
|
||||
BlockingManagedClient, ManagedClientConfig, ManagedCompletionPolicy, ManagedEvent,
|
||||
ManagedTargetConfig,
|
||||
};
|
||||
use irtt_client::{ClientConfig, OpenPolicy, SessionRequest};
|
||||
|
||||
fn main() {
|
||||
let config = ManagedClientConfig {
|
||||
client: ClientConfig {
|
||||
open: OpenPolicy {
|
||||
timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
},
|
||||
request: SessionRequest {
|
||||
duration: None,
|
||||
interval: Duration::from_millis(200),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
completion: ManagedCompletionPolicy::ExplicitStop,
|
||||
..Default::default()
|
||||
};
|
||||
let targets = vec![ManagedTargetConfig::new("local", "127.0.0.1:2112")];
|
||||
|
||||
let (owner, mut events) = match BlockingManagedClient::start_with_subscription(config, targets)
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(err) => {
|
||||
eprintln!("failed to start managed client: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stop_after = Duration::from_secs(1);
|
||||
let deadline = std::time::Instant::now() + stop_after;
|
||||
while std::time::Instant::now() < deadline {
|
||||
match events.try_recv() {
|
||||
Ok(event) => print_event(&event),
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(50)),
|
||||
}
|
||||
}
|
||||
|
||||
// `stop()` requests the stop synchronously; the returned receipt is only
|
||||
// useful for awaiting durable acknowledgement, which this blocking
|
||||
// example does not need.
|
||||
std::mem::drop(owner.handle().stop());
|
||||
match owner.join() {
|
||||
Ok(outcome) => println!("managed run ended: {:?}", outcome.end_reason),
|
||||
Err(err) => eprintln!("managed worker did not join cleanly: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_event(event: &ManagedEvent) {
|
||||
match event {
|
||||
ManagedEvent::Client { target, event } => println!("{}: {event:?}", target.id),
|
||||
other => println!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Low-level Tokio client example (`tokio` feature).
|
||||
//!
|
||||
//! Drives [`AsyncClient`] directly on a caller-owned runtime. This is the
|
||||
//! adapter to reach for when you already have a Tokio runtime and want to
|
||||
//! await sends/receives yourself, rather than use the higher-level `managed`
|
||||
//! driver (see the `managed_client` example).
|
||||
//!
|
||||
//! Run a server first, then run this example:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p irtt-rs --bin irtt-server --features server
|
||||
//! cargo run -p irtt-client --example tokio_client --features tokio
|
||||
//! ```
|
||||
//!
|
||||
//! Without a reachable server this exits quickly with an open-timeout error
|
||||
//! instead of hanging, because the example shortens `open.timeouts` for a
|
||||
//! fast demonstration.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use irtt_client::{
|
||||
AsyncClient, ClientConfig, ClientEvent, OpenOutcome, OpenPolicy, SessionRequest,
|
||||
};
|
||||
|
||||
/// Upper bound on how long the receive wait can go without also calling
|
||||
/// `poll_timeouts()`. `AsyncClient` does not expose the earliest pending
|
||||
/// probe's own timeout deadline, so this example polls on a short fixed
|
||||
/// cadence instead of waiting for the full `probe_timeout()` from whenever
|
||||
/// this loop last ran — that would let poll_timeouts() classify a lost
|
||||
/// probe (and its dependent statistics/session state) far later than it
|
||||
/// actually became lost.
|
||||
const TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
fn main() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("failed to build Tokio runtime");
|
||||
|
||||
runtime.block_on(run());
|
||||
}
|
||||
|
||||
async fn run() {
|
||||
let config = ClientConfig {
|
||||
open: OpenPolicy {
|
||||
// A single short attempt keeps this example fast when no server is
|
||||
// reachable; production callers should generally keep the default.
|
||||
timeouts: vec![Duration::from_millis(300)],
|
||||
..Default::default()
|
||||
},
|
||||
request: SessionRequest {
|
||||
duration: Some(Duration::from_secs(2)),
|
||||
interval: Duration::from_millis(200),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut client = match AsyncClient::connect("127.0.0.1:2112", config).await {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
eprintln!("failed to prepare client socket: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let negotiated = match client.open().await {
|
||||
Ok(OpenOutcome::Started(started)) => {
|
||||
println!("session opened: {started:?}");
|
||||
started.negotiation
|
||||
}
|
||||
Ok(OpenOutcome::NoTestCompleted(completed)) => {
|
||||
println!("{completed:?}");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("open failed (is a server running at 127.0.0.1:2112?): {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// This loop owns cadence and duration. The transport sends whenever called.
|
||||
let interval = negotiated.accepted.interval;
|
||||
let start = Instant::now();
|
||||
let end = start
|
||||
+ negotiated
|
||||
.accepted
|
||||
.duration
|
||||
.expect("example requests a finite run");
|
||||
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 = (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()
|
||||
.unwrap();
|
||||
|
||||
let recv_result = tokio::select! {
|
||||
events = client.recv() => Some(events),
|
||||
() = tokio::time::sleep_until(tokio::time::Instant::from_std(wake_at)) => None,
|
||||
};
|
||||
if let Some(result) = recv_result {
|
||||
match result {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("receive failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Instant::now() < end && Instant::now() >= next_send {
|
||||
match client.send_probe().await {
|
||||
Ok(receipt) => {
|
||||
print_event(&receipt.into());
|
||||
while next_send <= Instant::now() {
|
||||
next_send += interval;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if let irtt_client::SendProbeError::AfterCommit { receipt, .. } = &err {
|
||||
print_event(&(*receipt).into());
|
||||
}
|
||||
eprintln!("send failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match client.poll_timeouts() {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => {
|
||||
eprintln!("timeout polling failed: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if client.is_peer_closed() {
|
||||
return;
|
||||
}
|
||||
match client.close().await {
|
||||
Ok(events) => events.iter().for_each(print_event),
|
||||
Err(err) => eprintln!("close failed: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_event(event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::EchoReply { seq, rtt, .. } => {
|
||||
println!("seq {seq}: rtt {:?}", rtt.effective);
|
||||
}
|
||||
ClientEvent::EchoLoss { seq, .. } => println!("seq {seq}: lost"),
|
||||
other => println!("{other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
use std::{
|
||||
future::{poll_fn, Future},
|
||||
io,
|
||||
net::SocketAddr,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tokio::time;
|
||||
|
||||
use crate::{
|
||||
client::validate_datagram_length,
|
||||
config::ClientConfig,
|
||||
error::ClientError,
|
||||
event::{ClientEvent, OpenOutcome},
|
||||
receive::{drain_tx_timestamps, try_enable_tx_timestamping, try_recv_tokio_datagram},
|
||||
session::machine::{
|
||||
recv_buffer_size, OpenDatagramDisposition, PreparedOpenAcceptance, PreparedOpenRequest,
|
||||
PreparedProbe, SessionMachine, TimeoutBatch, MAX_OPEN_PACKET_SIZE,
|
||||
},
|
||||
socket::{connect_tokio_udp_socket, resolve_remote_tokio},
|
||||
socket_options::{apply_traffic_class_to_tokio_socket, clear_dscp_on_tokio_socket},
|
||||
timing::ClientTimestamp,
|
||||
SendProbeError, SendReceipt,
|
||||
};
|
||||
|
||||
/// Maximum amount of immediately available opening work performed by one poll.
|
||||
///
|
||||
/// Opening may consume ignored UDP datagrams, roll over an expired attempt, or
|
||||
/// encounter readiness false positives without ever awaiting. Bound that work
|
||||
/// so one managed target cannot retain the current-thread runtime.
|
||||
const OPEN_POLL_WORK_BUDGET: usize = 64;
|
||||
|
||||
/// Maximum immediately available receive work performed by one
|
||||
/// [`AsyncClient::poll_recv`] poll.
|
||||
///
|
||||
/// A `WouldBlock` readiness false positive is naturally bounded: `try_io`
|
||||
/// clears the reactor's readiness on `WouldBlock`, so the next
|
||||
/// `poll_recv_ready` genuinely awaits a fresh edge. An `Interrupted`
|
||||
/// (`EINTR`) nonblocking `recvmsg` does not clear that readiness — the
|
||||
/// syscall was interrupted, not told there was nothing to read — so without
|
||||
/// an explicit bound a sustained signal delivery rate could otherwise retry
|
||||
/// in a tight loop on this executor thread. Bounding both cases the same way
|
||||
/// this crate already bounds open-attempt work keeps that impossible.
|
||||
const RECV_POLL_WORK_BUDGET: usize = 64;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedAsyncOpen {
|
||||
machine: PreparedOpenAcceptance,
|
||||
recv_buffer_len: Option<usize>,
|
||||
negotiated_traffic_class: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedAsyncOpenFailure {
|
||||
primary: ClientError,
|
||||
machine: PreparedOpenAcceptance,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AsyncOpenCleanup {
|
||||
Acceptance {
|
||||
primary: ClientError,
|
||||
packet: Option<Box<[u8]>>,
|
||||
},
|
||||
Adapter(Box<PreparedAsyncOpenFailure>),
|
||||
}
|
||||
|
||||
impl AsyncOpenCleanup {
|
||||
fn packet(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
Self::Acceptance { packet, .. } => packet.as_deref(),
|
||||
Self::Adapter(failure) => failure.machine.cleanup_close_packet(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_primary(self) -> ClientError {
|
||||
match self {
|
||||
Self::Acceptance { primary, .. } => primary,
|
||||
Self::Adapter(failure) => failure.primary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned state for one asynchronous open transaction.
|
||||
///
|
||||
/// This state deliberately contains no reference to [`AsyncClient`], allowing
|
||||
/// a managed target to store the client and opening state side by side.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AsyncOpenState {
|
||||
attempt: usize,
|
||||
deadline: Option<Instant>,
|
||||
deadline_timer: Option<Pin<Box<time::Sleep>>>,
|
||||
request_submitted: bool,
|
||||
stop_after_current_attempt: bool,
|
||||
stopped_after_current_attempt: bool,
|
||||
buffer: [u8; MAX_OPEN_PACKET_SIZE],
|
||||
cleanup: Option<AsyncOpenCleanup>,
|
||||
}
|
||||
|
||||
impl AsyncOpenState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
attempt: 0,
|
||||
deadline: None,
|
||||
deadline_timer: None,
|
||||
request_submitted: false,
|
||||
stop_after_current_attempt: false,
|
||||
stopped_after_current_attempt: false,
|
||||
buffer: [0_u8; MAX_OPEN_PACKET_SIZE],
|
||||
cleanup: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_attempt(&mut self, timeout: Duration) -> Result<(), ClientError> {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
self.deadline = Some(deadline);
|
||||
self.deadline_timer = Some(Box::pin(time::sleep_until(deadline.into())));
|
||||
self.request_submitted = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_attempt(&mut self) {
|
||||
self.attempt += 1;
|
||||
self.deadline = None;
|
||||
self.deadline_timer = None;
|
||||
self.request_submitted = false;
|
||||
self.stopped_after_current_attempt = self.stop_after_current_attempt;
|
||||
}
|
||||
|
||||
pub(crate) fn has_in_flight_work(&self) -> bool {
|
||||
self.request_submitted || self.cleanup.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn request_stop_after_current_attempt(&mut self) {
|
||||
self.stop_after_current_attempt = true;
|
||||
}
|
||||
|
||||
pub(crate) fn stopped_after_current_attempt(&self) -> bool {
|
||||
self.stopped_after_current_attempt
|
||||
}
|
||||
|
||||
fn deadline(&self) -> Instant {
|
||||
self.deadline
|
||||
.expect("an open attempt has an active deadline")
|
||||
}
|
||||
|
||||
fn deadline_timer(&mut self) -> &mut Pin<Box<time::Sleep>> {
|
||||
self.deadline_timer
|
||||
.as_mut()
|
||||
.expect("an open attempt has an active deadline timer")
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-level Tokio IRTT client for one connected UDP target.
|
||||
///
|
||||
/// `AsyncClient` does not construct, own, or store a Tokio runtime. Its async
|
||||
/// methods are polled by the caller and require a current Tokio runtime with I/O
|
||||
/// and time enabled.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Use this low-level adapter when the application already owns Tokio and wants
|
||||
/// to drive the session lifecycle itself. This example type-checks without
|
||||
/// contacting a server.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{AsyncClient, ClientConfig};
|
||||
///
|
||||
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let mut client = AsyncClient::connect("127.0.0.1:2112", ClientConfig::default()).await?;
|
||||
/// let outcome = client.open().await?;
|
||||
/// println!("opened: {outcome:?}");
|
||||
/// let sent = client.send_probe().await?;
|
||||
/// println!("sent: {sent:?}");
|
||||
/// let received = client.recv().await?;
|
||||
/// println!("received: {received:?}");
|
||||
/// let timed_out = client.poll_timeouts()?;
|
||||
/// println!("timeouts: {timed_out:?}");
|
||||
/// let closed = client.close().await?;
|
||||
/// println!("closed: {closed:?}");
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct AsyncClient {
|
||||
socket: tokio::net::UdpSocket,
|
||||
machine: SessionMachine,
|
||||
remote: SocketAddr,
|
||||
recv_buffer: Vec<u8>,
|
||||
applied_traffic_class: Option<u8>,
|
||||
prepared_open: Option<PreparedOpenRequest>,
|
||||
prepared_probe: Option<PreparedProbe>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl AsyncClient {
|
||||
/// Resolve the endpoint and construct one connected Tokio UDP
|
||||
/// socket.
|
||||
///
|
||||
/// Polling this future without a current Tokio runtime returns
|
||||
/// [`ClientError::NoTokioRuntime`]. A runtime without enabled I/O or time
|
||||
/// drivers is outside this type's runtime contract.
|
||||
///
|
||||
/// The endpoint accepts a name or address; an omitted port defaults to 2112.
|
||||
/// IPv6 literals may be bracketed or unbracketed with the default port.
|
||||
/// The reusable config contains no endpoint identity.
|
||||
pub async fn connect(
|
||||
endpoint: impl AsRef<str>,
|
||||
config: ClientConfig,
|
||||
) -> Result<Self, ClientError> {
|
||||
tokio::runtime::Handle::try_current().map_err(|_| ClientError::NoTokioRuntime)?;
|
||||
config.open.validate()?;
|
||||
let remote = resolve_remote_tokio(endpoint.as_ref(), config.address_family).await?;
|
||||
let machine = SessionMachine::new(config.clone(), remote)?;
|
||||
let prepared_open = machine.prepare_open_request()?;
|
||||
let socket = connect_tokio_udp_socket(&config.socket, remote, config.address_family)?;
|
||||
|
||||
Ok(Self {
|
||||
socket,
|
||||
machine,
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the IRTT open exchange.
|
||||
///
|
||||
/// Each configured attempt uses one absolute deadline for one successful
|
||||
/// request submission and all replies inspected during that attempt.
|
||||
/// Malformed, unrelated, wrong-direction, and unauthenticated datagrams are
|
||||
/// ignored without resending the request.
|
||||
///
|
||||
/// Dropping this future before trusted acceptance leaves the local machine
|
||||
/// 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 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
|
||||
/// probe without advancing state before kernel acceptance. Cancellation or
|
||||
/// `WouldBlock` before submission is uncommitted. After acceptance there is
|
||||
/// no suspension before infallible commitment, and the prepared probe is
|
||||
/// cleared even if post-send processing returns [`SendProbeError::AfterCommit`].
|
||||
/// That failure retains the authoritative receipt; earlier failures return
|
||||
/// [`SendProbeError::NotCommitted`].
|
||||
pub async fn send_probe(&mut self) -> Result<SendReceipt, SendProbeError> {
|
||||
poll_fn(|cx| self.poll_send_probe(cx)).await
|
||||
}
|
||||
|
||||
/// Await and classify one complete UDP datagram.
|
||||
///
|
||||
/// State is validated before readiness is awaited. Readiness false positives
|
||||
/// retry without changing protocol state. Authenticated peer-close events
|
||||
/// remain authoritative even if best-effort DSCP cleanup fails.
|
||||
pub async fn recv(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
poll_fn(|cx| self.poll_recv(cx)).await
|
||||
}
|
||||
|
||||
/// Poll protocol timeouts using a newly captured monotonic timestamp.
|
||||
pub fn poll_timeouts(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.poll_timeouts_at(Instant::now())
|
||||
}
|
||||
|
||||
/// Poll protocol timeouts using the caller's monotonic timestamp.
|
||||
pub fn poll_timeouts_at(&mut self, now: Instant) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
self.drain_tx_timestamps()?;
|
||||
self.machine.poll_timeouts_at(now)
|
||||
}
|
||||
|
||||
pub(crate) fn poll_timeouts_bounded_at(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
limit: usize,
|
||||
) -> Result<TimeoutBatch, ClientError> {
|
||||
self.drain_tx_timestamps()?;
|
||||
self.machine.poll_timeouts_bounded_at(now, limit)
|
||||
}
|
||||
|
||||
/// Send the retained close packet and commit local close exactly once.
|
||||
///
|
||||
/// DSCP is cleared only after write readiness and immediately before the
|
||||
/// nonblocking send attempt. A `WouldBlock` result restores it before the
|
||||
/// next await, so dropping this future at a suspension point cannot leave
|
||||
/// an otherwise-open session with cleared DSCP.
|
||||
pub async fn close(&mut self) -> Result<Vec<ClientEvent>, ClientError> {
|
||||
poll_fn(|cx| self.poll_close(cx)).await
|
||||
}
|
||||
|
||||
pub(crate) fn poll_open(
|
||||
&mut self,
|
||||
state: &mut AsyncOpenState,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<OpenOutcome, ClientError>> {
|
||||
if self.prepared_open.is_none() {
|
||||
if let Err(error) = self.machine.prepare_open_request() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
unreachable!("connected async clients retain an open request");
|
||||
}
|
||||
|
||||
for _ in 0..OPEN_POLL_WORK_BUDGET {
|
||||
if state.cleanup.is_some() {
|
||||
return self.poll_open_cleanup(state, cx);
|
||||
}
|
||||
|
||||
let attempt_count = self.machine.config().open.timeouts.len();
|
||||
if state.attempt >= attempt_count {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
if state.stop_after_current_attempt && state.deadline.is_none() {
|
||||
state.stopped_after_current_attempt = true;
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
if state.deadline.is_none() {
|
||||
let timeout = self.machine.config().open.timeouts[state.attempt];
|
||||
if let Err(error) = state.start_attempt(timeout) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
}
|
||||
|
||||
if !state.request_submitted {
|
||||
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
Poll::Ready(Ok(false)) => {
|
||||
state.finish_attempt();
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Ok(true)) => {}
|
||||
}
|
||||
|
||||
let request = self
|
||||
.prepared_open
|
||||
.as_ref()
|
||||
.expect("connected clients retain their prepared open request");
|
||||
let send_result = self.socket.try_send(&request.bytes);
|
||||
match send_result {
|
||||
Ok(bytes) => {
|
||||
state.request_submitted = true;
|
||||
if let Err(error) = validate_datagram_length(request.bytes.len(), bytes) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue,
|
||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||
}
|
||||
}
|
||||
|
||||
match poll_readable_until(&self.socket, state.deadline_timer(), cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
|
||||
Poll::Ready(Ok(false)) => {
|
||||
state.finish_attempt();
|
||||
if state.stopped_after_current_attempt() {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Ok(true)) => {}
|
||||
}
|
||||
|
||||
let datagram = match try_recv_tokio_datagram(&self.socket, &mut state.buffer) {
|
||||
Ok(datagram) => datagram,
|
||||
// WouldBlock is a readiness false positive; Interrupted
|
||||
// (EINTR) is a transient nonblocking-recvmsg interruption.
|
||||
// Both retry through the surrounding `OPEN_POLL_WORK_BUDGET`-
|
||||
// bounded loop above, which re-checks readiness before the
|
||||
// next attempt and yields back to the executor once that
|
||||
// budget is exhausted.
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
|
||||
) =>
|
||||
{
|
||||
continue
|
||||
}
|
||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||
};
|
||||
if datagram.received_at.mono > state.deadline() {
|
||||
state.finish_attempt();
|
||||
if state.stopped_after_current_attempt() {
|
||||
return Poll::Ready(Err(ClientError::OpenTimeout));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let reply = match self
|
||||
.machine
|
||||
.inspect_open_datagram(&state.buffer[..datagram.len])
|
||||
{
|
||||
Ok(OpenDatagramDisposition::Ignore) => continue,
|
||||
Ok(OpenDatagramDisposition::Trusted(reply)) => reply,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
let machine = match self
|
||||
.machine
|
||||
.prepare_open_acceptance(reply, datagram.received_at)
|
||||
{
|
||||
Ok(machine) => machine,
|
||||
Err(failure) => {
|
||||
state.cleanup = Some(AsyncOpenCleanup::Acceptance {
|
||||
primary: failure.primary,
|
||||
packet: failure.cleanup_close,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let prepared = match self.prepare_async_open(machine) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(failure) => {
|
||||
state.cleanup = Some(AsyncOpenCleanup::Adapter(failure));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
return Poll::Ready(Ok(self.commit_async_open(prepared)));
|
||||
}
|
||||
|
||||
// We deliberately truncated known immediate opening work. Arrange for
|
||||
// another poll even if no new socket-readiness edge occurs.
|
||||
cx.waker().wake_by_ref();
|
||||
Poll::Pending
|
||||
}
|
||||
|
||||
pub(crate) fn poll_send_probe(
|
||||
&mut self,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<SendReceipt, SendProbeError>> {
|
||||
if let Err(error) = self.machine.ensure_open() {
|
||||
return Poll::Ready(Err(SendProbeError::NotCommitted(error)));
|
||||
}
|
||||
if self.prepared_probe.is_none() {
|
||||
match self.machine.prepare_probe() {
|
||||
Ok(prepared) => self.prepared_probe = Some(prepared),
|
||||
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||
}
|
||||
}
|
||||
loop {
|
||||
match self.socket.poll_send_ready(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {}
|
||||
Poll::Ready(Err(error)) => {
|
||||
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
|
||||
error,
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
let prepared = self
|
||||
.prepared_probe
|
||||
.as_ref()
|
||||
.expect("prepared probe was retained across readiness");
|
||||
let machine_preflight = match self.machine.preflight_probe_commit(prepared) {
|
||||
Ok(preflight) => preflight,
|
||||
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||
};
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
// 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).
|
||||
let send_anchor = ClientTimestamp::now();
|
||||
let machine_commit = match self
|
||||
.machine
|
||||
.finalize_probe_commit(machine_preflight, send_anchor)
|
||||
{
|
||||
Ok(commit) => commit,
|
||||
Err(error) => return Poll::Ready(Err(SendProbeError::NotCommitted(error))),
|
||||
};
|
||||
let send_call_start = Instant::now();
|
||||
let send_result = self.socket.try_send(&prepared.bytes);
|
||||
let bytes = match send_result {
|
||||
Ok(bytes) => bytes,
|
||||
// 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();
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
self.machine.invalidate_kernel_tx_correlation();
|
||||
return Poll::Ready(Err(SendProbeError::NotCommitted(ClientError::Socket(
|
||||
error,
|
||||
))));
|
||||
}
|
||||
};
|
||||
let send_finished_at = Instant::now();
|
||||
|
||||
// Public post-send measurement, captured only after the
|
||||
// successful send above. The subsequent machine commit
|
||||
// is infallible.
|
||||
let sent_at = ClientTimestamp::now();
|
||||
let sent = self
|
||||
.machine
|
||||
.commit_probe_sent(machine_commit, sent_at, bytes);
|
||||
self.prepared_probe = None;
|
||||
let send_call = send_finished_at.saturating_duration_since(send_call_start);
|
||||
|
||||
let receipt = SendReceipt {
|
||||
seq: sent.seq,
|
||||
remote: self.remote,
|
||||
sent_at: sent.sent_at,
|
||||
bytes: sent.bytes,
|
||||
send_call,
|
||||
};
|
||||
if let Err(source) = validate_datagram_length(expected_bytes, bytes) {
|
||||
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||
receipt,
|
||||
source: Box::new(source),
|
||||
}));
|
||||
}
|
||||
if let Err(source) = self.drain_tx_timestamps() {
|
||||
return Poll::Ready(Err(SendProbeError::AfterCommit {
|
||||
receipt,
|
||||
source: Box::new(source),
|
||||
}));
|
||||
}
|
||||
return Poll::Ready(Ok(receipt));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn poll_recv(
|
||||
&mut self,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
|
||||
if let Err(error) = self.machine.ensure_open() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
|
||||
for _ in 0..RECV_POLL_WORK_BUDGET {
|
||||
match self.socket.poll_recv_ready(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {}
|
||||
Poll::Ready(Err(error)) => {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// positive and an interrupted nonblocking recvmsg retry
|
||||
// within this bounded loop rather than failing the receive.
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
|
||||
) =>
|
||||
{
|
||||
continue
|
||||
}
|
||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||
};
|
||||
|
||||
// One final bounded drain before processing the reply, so a TX
|
||||
// timestamp that raced the reply is still associated with its
|
||||
// probe before that probe is looked up and possibly removed
|
||||
// below.
|
||||
if let Err(error) = self.drain_tx_timestamps() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
|
||||
let events = match self.machine.process_received_echo_packet(
|
||||
&self.recv_buffer[..datagram.len],
|
||||
datagram.received_at,
|
||||
datagram.meta,
|
||||
) {
|
||||
Ok(events) => events,
|
||||
Err(error) => return Poll::Ready(Err(error)),
|
||||
};
|
||||
if self.machine.is_peer_closed() {
|
||||
self.prepared_probe = None;
|
||||
if clear_dscp_on_tokio_socket(&self.socket, self.remote).is_ok() {
|
||||
self.applied_traffic_class = None;
|
||||
}
|
||||
}
|
||||
return Poll::Ready(Ok(events));
|
||||
}
|
||||
|
||||
// Truncated known immediate receive work, exactly as `poll_open`
|
||||
// does: arrange for another poll even if no new socket-readiness
|
||||
// edge occurs, rather than spinning this executor thread further.
|
||||
cx.waker().wake_by_ref();
|
||||
Poll::Pending
|
||||
}
|
||||
|
||||
pub(crate) fn poll_close(
|
||||
&mut self,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<Vec<ClientEvent>, ClientError>> {
|
||||
loop {
|
||||
let prepared = match self.machine.prepare_close() {
|
||||
Ok(prepared) => prepared,
|
||||
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 {
|
||||
operation: "close event result",
|
||||
source,
|
||||
}));
|
||||
}
|
||||
let previous_traffic_class = self.applied_traffic_class;
|
||||
let expected_bytes = prepared.bytes.len();
|
||||
|
||||
match self.socket.poll_send_ready(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Ok(())) => {}
|
||||
Poll::Ready(Err(error)) => {
|
||||
return Poll::Ready(Err(ClientError::Socket(error)));
|
||||
}
|
||||
}
|
||||
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);
|
||||
let send_result = self.socket.try_send(prepared.bytes);
|
||||
let bytes = match send_result {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
if let Err(error) = rollback.restore() {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Poll::Ready(Err(ClientError::Socket(error))),
|
||||
};
|
||||
|
||||
let close_sent_at = ClientTimestamp::now();
|
||||
|
||||
let event = self
|
||||
.machine
|
||||
.commit_local_close(prepared.commit, close_sent_at);
|
||||
rollback.disarm();
|
||||
self.prepared_probe = None;
|
||||
self.applied_traffic_class = None;
|
||||
|
||||
if let Err(error) = validate_datagram_length(expected_bytes, bytes) {
|
||||
return Poll::Ready(Err(error));
|
||||
}
|
||||
events.push(event);
|
||||
return Poll::Ready(Ok(events));
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the configured local probe timeout.
|
||||
pub fn probe_timeout(&self) -> Duration {
|
||||
self.machine.probe_timeout()
|
||||
}
|
||||
|
||||
/// Accepted semantics and exact peer parameters of the currently live session.
|
||||
///
|
||||
/// Returns `None` before Open, after Close, and after no-test completion.
|
||||
pub fn negotiation(&self) -> Option<&crate::NegotiationResult> {
|
||||
self.machine.negotiation()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn is_peer_closed(&self) -> bool {
|
||||
self.machine.is_peer_closed()
|
||||
}
|
||||
|
||||
pub(crate) fn remote_addr(&self) -> SocketAddr {
|
||||
self.remote
|
||||
}
|
||||
|
||||
pub(crate) fn packets_sent(&self) -> u64 {
|
||||
self.machine.packets_sent()
|
||||
}
|
||||
|
||||
/// Earliest timeout deadline among probes still awaiting a reply.
|
||||
pub fn next_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.machine.next_probe_timeout_deadline()
|
||||
}
|
||||
|
||||
pub(crate) fn latest_probe_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.machine.latest_probe_timeout_deadline()
|
||||
}
|
||||
|
||||
pub(crate) fn discard_prepared_probe(&mut self) {
|
||||
self.prepared_probe = None;
|
||||
}
|
||||
|
||||
fn prepare_async_open(
|
||||
&mut self,
|
||||
machine: PreparedOpenAcceptance,
|
||||
) -> Result<PreparedAsyncOpen, Box<PreparedAsyncOpenFailure>> {
|
||||
let Some(negotiated) = machine.normal_negotiated() else {
|
||||
return Ok(PreparedAsyncOpen {
|
||||
machine,
|
||||
recv_buffer_len: None,
|
||||
negotiated_traffic_class: None,
|
||||
});
|
||||
};
|
||||
let recv_buffer_len = match recv_buffer_size(self.machine.has_hmac(), Some(negotiated)) {
|
||||
Ok(size) => size,
|
||||
Err(primary) => {
|
||||
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
||||
}
|
||||
};
|
||||
let negotiated_traffic_class = negotiated.accepted.dscp << 2;
|
||||
let additional = recv_buffer_len.saturating_sub(self.recv_buffer.len());
|
||||
if let Err(source) = self.recv_buffer.try_reserve(additional) {
|
||||
return Err(Box::new(PreparedAsyncOpenFailure {
|
||||
primary: ClientError::AllocationFailed {
|
||||
operation: "negotiated receive buffer",
|
||||
source,
|
||||
},
|
||||
machine,
|
||||
}));
|
||||
}
|
||||
|
||||
let dscp_result = apply_traffic_class_to_tokio_socket(
|
||||
&self.socket,
|
||||
self.remote,
|
||||
negotiated_traffic_class,
|
||||
);
|
||||
if let Err(primary) = dscp_result {
|
||||
self.restore_dscp_best_effort(self.applied_traffic_class);
|
||||
return Err(Box::new(PreparedAsyncOpenFailure { primary, machine }));
|
||||
}
|
||||
|
||||
Ok(PreparedAsyncOpen {
|
||||
machine,
|
||||
recv_buffer_len: Some(recv_buffer_len),
|
||||
negotiated_traffic_class: Some(negotiated_traffic_class),
|
||||
})
|
||||
}
|
||||
|
||||
fn commit_async_open(&mut self, prepared: PreparedAsyncOpen) -> OpenOutcome {
|
||||
if let Some(recv_buffer_len) = prepared.recv_buffer_len {
|
||||
self.recv_buffer.resize(recv_buffer_len, 0);
|
||||
}
|
||||
let outcome = self.machine.commit_open(prepared.machine);
|
||||
|
||||
self.applied_traffic_class = prepared.negotiated_traffic_class;
|
||||
self.prepared_open = None;
|
||||
self.tx_timestamping_enabled = try_enable_tx_timestamping(&self.socket);
|
||||
outcome
|
||||
}
|
||||
|
||||
/// See `Client::drain_tx_timestamps` in `client.rs`: same bounded,
|
||||
/// nonblocking, best-effort drain, adapted to the Tokio socket.
|
||||
fn drain_tx_timestamps(&mut self) -> Result<(), ClientError> {
|
||||
if !self.tx_timestamping_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let machine = &mut self.machine;
|
||||
drain_tx_timestamps(&self.socket, |id, timestamp| {
|
||||
machine.record_kernel_tx_timestamp(id, timestamp);
|
||||
})
|
||||
.map_err(ClientError::Socket)
|
||||
}
|
||||
|
||||
fn poll_open_cleanup(
|
||||
&self,
|
||||
state: &mut AsyncOpenState,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<OpenOutcome, ClientError>> {
|
||||
let Some(packet) = state
|
||||
.cleanup
|
||||
.as_ref()
|
||||
.expect("cleanup polling requires a retained primary error")
|
||||
.packet()
|
||||
else {
|
||||
return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()));
|
||||
};
|
||||
let send_result = self.socket.try_send(packet);
|
||||
match send_result {
|
||||
Ok(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
|
||||
Err(_) => return Poll::Ready(Err(state.cleanup.take().unwrap().into_primary())),
|
||||
}
|
||||
|
||||
match poll_writable_until(&self.socket, state.deadline_timer(), cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Ok(true)) => {
|
||||
// Retain cleanup state and resume the send in a fresh poll. A
|
||||
// writable readiness false positive must not spin this future.
|
||||
cx.waker().wake_by_ref();
|
||||
Poll::Pending
|
||||
}
|
||||
Poll::Ready(Ok(false) | Err(_)) => {
|
||||
Poll::Ready(Err(state.cleanup.take().unwrap().into_primary()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_dscp_best_effort(&self, previous_traffic_class: Option<u8>) {
|
||||
let _ = match previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
apply_traffic_class_to_tokio_socket(&self.socket, self.remote, traffic_class)
|
||||
}
|
||||
None => clear_dscp_on_tokio_socket(&self.socket, self.remote),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
struct DscpRollback<'a> {
|
||||
socket: &'a tokio::net::UdpSocket,
|
||||
remote: SocketAddr,
|
||||
previous_traffic_class: Option<u8>,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl<'a> DscpRollback<'a> {
|
||||
fn armed(
|
||||
socket: &'a tokio::net::UdpSocket,
|
||||
remote: SocketAddr,
|
||||
previous_traffic_class: Option<u8>,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
remote,
|
||||
previous_traffic_class,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> Result<(), ClientError> {
|
||||
if !self.armed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.previous_traffic_class {
|
||||
Some(traffic_class) => {
|
||||
apply_traffic_class_to_tokio_socket(self.socket, self.remote, traffic_class)?
|
||||
}
|
||||
None => clear_dscp_on_tokio_socket(self.socket, self.remote)?,
|
||||
}
|
||||
self.armed = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DscpRollback<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
let _ = self.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_writable_until(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
deadline: &mut Pin<Box<time::Sleep>>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<bool, ClientError>> {
|
||||
match socket.poll_send_ready(cx) {
|
||||
Poll::Ready(Ok(())) => Poll::Ready(Ok(true)),
|
||||
Poll::Ready(Err(error)) => Poll::Ready(Err(ClientError::Socket(error))),
|
||||
Poll::Pending => match deadline.as_mut().poll(cx) {
|
||||
Poll::Ready(()) => Poll::Ready(Ok(false)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_readable_until(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
deadline: &mut Pin<Box<time::Sleep>>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<bool, ClientError>> {
|
||||
match socket.poll_recv_ready(cx) {
|
||||
Poll::Ready(Ok(())) => Poll::Ready(Ok(true)),
|
||||
Poll::Ready(Err(error)) => Poll::Ready(Err(ClientError::Socket(error))),
|
||||
Poll::Pending => match deadline.as_mut().poll(cx) {
|
||||
Poll::Ready(()) => Poll::Ready(Ok(false)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
/// Shared HMAC key bytes. Debug output never includes the bytes or a fingerprint.
|
||||
///
|
||||
/// An empty key is still an explicitly configured HMAC key.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct HmacKey(Arc<[u8]>);
|
||||
|
||||
impl HmacKey {
|
||||
/// Store key bytes without interpreting textual key syntax.
|
||||
pub fn new(bytes: impl Into<Arc<[u8]>>) -> Self {
|
||||
Self(bytes.into())
|
||||
}
|
||||
|
||||
/// Explicitly borrow the key material for authentication.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HmacKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("HmacKey([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Concrete authentication for a low-level client session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Authentication {
|
||||
/// Send and accept packets without HMAC authentication.
|
||||
#[default]
|
||||
Unauthenticated,
|
||||
/// Authenticate packets with this key, including when it is empty.
|
||||
Hmac(HmacKey),
|
||||
}
|
||||
|
||||
impl Authentication {
|
||||
pub(crate) fn hmac_key(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
Self::Unauthenticated => None,
|
||||
Self::Hmac(key) => Some(key.as_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
+536
-839
File diff suppressed because it is too large.
Load diff
@@ -1,172 +0,0 @@
|
||||
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 server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyClosed)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_sends_one_close_packet_with_negotiated_token() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
let events = client.close().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
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 send_probe_fails_after_close() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
loop {
|
||||
let mut buf = [0_u8; 512];
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((size, _)) => {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::AlreadyClosed)
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_clears_timed_out_metadata() {
|
||||
let params = default_params();
|
||||
let server = silent_open_server(params);
|
||||
let config = ClientConfig {
|
||||
probe_timeout: Duration::from_millis(40),
|
||||
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_open_started(client.open().unwrap());
|
||||
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(60));
|
||||
client.poll_timeouts().unwrap();
|
||||
assert_eq!(client.session.as_ref().unwrap().timed_out.len(), 1);
|
||||
|
||||
client.close().unwrap();
|
||||
assert!(client.session.is_none());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_flagged_echo_reply_emits_reply_then_closes_without_sending_close() {
|
||||
let params = default_params();
|
||||
let server = start_fake_server({
|
||||
let params = params.clone();
|
||||
move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
let (request, _) = recv_request(&socket, &tx);
|
||||
let seq = u32::from_le_bytes(request[12..16].try_into().unwrap());
|
||||
let reply = echo_reply_packet_with_flags(
|
||||
TOKEN,
|
||||
seq,
|
||||
¶ms,
|
||||
&TimestampFields::default(),
|
||||
None,
|
||||
FLAG_REPLY | flags::FLAG_CLOSE,
|
||||
);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(250)))
|
||||
.unwrap();
|
||||
while recv_request_timeout(&socket, &tx).is_some() {}
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(ClientEvent::EchoReply { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
events.get(1),
|
||||
Some(ClientEvent::SessionClosed { token: TOKEN, .. })
|
||||
));
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(client.next_send_deadline().is_none());
|
||||
assert!(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 normal_echo_reply_does_not_close_session() {
|
||||
let params = default_params();
|
||||
let (mut client, server) = open_client_with_echo_server(¶ms);
|
||||
client.send_probe().unwrap();
|
||||
|
||||
let events = client.recv_once().unwrap();
|
||||
assert!(matches!(events.as_slice(), [ClientEvent::EchoReply { .. }]));
|
||||
assert!(client.next_send_deadline().is_some());
|
||||
assert!(client.send_probe().is_ok());
|
||||
|
||||
client.close().unwrap();
|
||||
server.join();
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
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, 46, "config DSCP codepoint must not be shifted");
|
||||
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_udp_payload_length_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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_oversized_udp_payload_length() {
|
||||
let config = ClientConfig {
|
||||
length: MAX_UDP_PAYLOAD_LENGTH + 1,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason }) if reason.contains("packet length")
|
||||
));
|
||||
}
|
||||
|
||||
#[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_zero_finite_duration() {
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::ZERO),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason })
|
||||
if reason == "duration must be greater than zero; use None for continuous mode"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_zero_interval() {
|
||||
let config = ClientConfig {
|
||||
interval: Duration::ZERO,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason }) if reason == "interval must be greater than zero"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_nanosecond_encoding_overflow_as_invalid_config() {
|
||||
let i64_max_ns = u64::try_from(i64::MAX).unwrap();
|
||||
let config = ClientConfig {
|
||||
duration: Some(Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1)),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason })
|
||||
if reason == "duration is too large to encode as nanoseconds"
|
||||
));
|
||||
|
||||
let config = ClientConfig {
|
||||
interval: Duration::from_nanos(i64_max_ns) + Duration::from_nanos(1),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { reason })
|
||||
if reason == "interval is too large to encode as nanoseconds"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_accepts_max_dscp_codepoint() {
|
||||
let config = ClientConfig {
|
||||
dscp: 63,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert_eq!(params_from_config(&config).unwrap().dscp, 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_invalid_dscp_codepoint() {
|
||||
let config = ClientConfig {
|
||||
dscp: 64,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_empty_server_fill() {
|
||||
let config = ClientConfig {
|
||||
server_fill: Some("".to_owned()),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_from_config_rejects_oversized_server_fill() {
|
||||
let config = ClientConfig {
|
||||
server_fill: Some("0123456789abcdef0123456789abcdefx".to_owned()),
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
params_from_config(&config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_open_timeout_under_200ms_is_rejected() {
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(199)],
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::OpenTimeoutTooSmall { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_open_timeouts_is_rejected() {
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![],
|
||||
..ClientConfig::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::NoOpenTimeouts)
|
||||
));
|
||||
}
|
||||
@@ -1,68 +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_dscp_after_open_and_close_clears_it() {
|
||||
let mut params = default_params();
|
||||
params.dscp = 46;
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.dscp = 46;
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
0
|
||||
);
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap() & 0xfc,
|
||||
184
|
||||
);
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
||||
0
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(any(
|
||||
target_os = "fuchsia",
|
||||
target_os = "redox",
|
||||
target_os = "solaris",
|
||||
target_os = "illumos",
|
||||
target_os = "haiku",
|
||||
)))]
|
||||
fn normal_open_uses_negotiated_dscp_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!(
|
||||
socket_traffic_class(&client.socket, client.remote).unwrap(),
|
||||
0
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
@@ -1,184 +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_hmac(&key, &request, HMAC_OFFSET).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_rejects_missing_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);
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::Protocol(
|
||||
irtt_proto::ProtoError::HmacPresenceMismatch
|
||||
))
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_open_rejects_bad_hmac() {
|
||||
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);
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&wrong_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::Protocol(irtt_proto::ProtoError::BadHmac))
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_close_packet_includes_valid_hmac() {
|
||||
let key = b"secret".to_vec();
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
config.hmac_key = Some(key.clone());
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.close().unwrap();
|
||||
let packets: Vec<_> = server.rx.iter().take(2).collect();
|
||||
let close = &packets[1];
|
||||
assert_eq!(close[3], flags::FLAG_CLOSE | FLAG_HMAC);
|
||||
verify_hmac(&key, close, HMAC_OFFSET).unwrap();
|
||||
assert_eq!(
|
||||
u64::from_le_bytes(close[4 + HMAC_SIZE..12 + HMAC_SIZE].try_into().unwrap()),
|
||||
TOKEN
|
||||
);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_hmac_reply_is_dropped() {
|
||||
let key = b"secret".to_vec();
|
||||
let wrong_key = b"wrong".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
let seq = u32::from_le_bytes(
|
||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields::default();
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, Some(&wrong_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(30));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], ClientEvent::Warning { .. }));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_echo_request_reply_works() {
|
||||
let key = b"testkey".to_vec();
|
||||
let params = default_params();
|
||||
let server_key = key.clone();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, Some(&server_key));
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buf = [0_u8; 2048];
|
||||
if let Ok((size, _)) = socket.recv_from(&mut buf) {
|
||||
tx.send(buf[..size].to_vec()).unwrap();
|
||||
verify_hmac(&server_key, &buf[..size], HMAC_OFFSET).unwrap();
|
||||
let seq = u32::from_le_bytes(
|
||||
buf[4 + HMAC_SIZE + 8..4 + HMAC_SIZE + 12]
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
let ts = TimestampFields {
|
||||
recv_mono: Some(100),
|
||||
send_mono: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
let reply_packet = echo_reply_packet(TOKEN, seq, ¶ms, &ts, Some(&server_key));
|
||||
socket.send_to(&reply_packet, peer).unwrap();
|
||||
}
|
||||
});
|
||||
let config = ClientConfig {
|
||||
hmac_key: Some(key),
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
client.send_probe().unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
let events = client.recv_once().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(&events[0], ClientEvent::EchoReply { .. }));
|
||||
server.join();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::{NegotiationPolicy, DEFAULT_OPEN_TIMEOUTS};
|
||||
use irtt_proto::{
|
||||
echo_packet_len, flags::FLAG_HMAC, flags::FLAG_OPEN, flags::FLAG_REPLY, verify_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,146 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_accepts_identical_params() {
|
||||
let config = ClientConfig::default();
|
||||
let params = params_from_config(&config).unwrap();
|
||||
assert!(validate_negotiated_params(¶ms, ¶ms, NegotiationPolicy::Strict).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_negotiation_rejects_changed_params() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = 1;
|
||||
assert!(matches!(
|
||||
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Strict),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_accepts_server_restricted_params() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
let mut returned = requested.clone();
|
||||
returned.duration_ns /= 2;
|
||||
returned.length = 0;
|
||||
assert!(validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose).is_ok());
|
||||
}
|
||||
|
||||
#[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!(matches!(
|
||||
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
|
||||
Err(ClientError::NegotiationRejected { reason })
|
||||
if reason == "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!(validate_negotiated_params(
|
||||
&requested_finite,
|
||||
&returned_same_finite,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
let mut returned_shorter_finite = requested_finite.clone();
|
||||
returned_shorter_finite.duration_ns /= 2;
|
||||
assert!(validate_negotiated_params(
|
||||
&requested_finite,
|
||||
&returned_shorter_finite,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
let continuous_config = ClientConfig {
|
||||
duration: None,
|
||||
..ClientConfig::default()
|
||||
};
|
||||
let requested_continuous = params_from_config(&continuous_config).unwrap();
|
||||
|
||||
let returned_continuous = requested_continuous.clone();
|
||||
assert!(validate_negotiated_params(
|
||||
&requested_continuous,
|
||||
&returned_continuous,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.is_ok());
|
||||
|
||||
let mut returned_finite = requested_continuous.clone();
|
||||
returned_finite.duration_ns = 1_000_000_000;
|
||||
assert!(validate_negotiated_params(
|
||||
&requested_continuous,
|
||||
&returned_finite,
|
||||
NegotiationPolicy::Loose
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[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!(matches!(
|
||||
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
|
||||
Err(ClientError::NegotiationRejected { reason })
|
||||
if reason == "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!(matches!(
|
||||
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
|
||||
Err(ClientError::NegotiationRejected { reason }) if reason == "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!(matches!(
|
||||
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Strict),
|
||||
Err(ClientError::NegotiationRejected { reason }) if reason == "length must be non-negative"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_negotiation_rejects_runtime_invalid_returned_dscp() {
|
||||
let config = ClientConfig::default();
|
||||
let requested = params_from_config(&config).unwrap();
|
||||
|
||||
for dscp in [-1, 64] {
|
||||
let mut returned = requested.clone();
|
||||
returned.dscp = dscp;
|
||||
assert!(matches!(
|
||||
validate_negotiated_params(&requested, &returned, NegotiationPolicy::Loose),
|
||||
Err(ClientError::NegotiationRejected { reason })
|
||||
if reason == "dscp must be in range 0..=63"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_test_open_close_succeeds_on_open_reply_close() {
|
||||
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();
|
||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
assert_eq!(client.negotiated.as_ref(), Some(&negotiated));
|
||||
assert!(matches!(client.close(), Err(ClientError::NotOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[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();
|
||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
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 server = open_success_server(params);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::UnexpectedNoTestReply)
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 server = no_test_server(params, TOKEN);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NonZeroNoTestToken { token: TOKEN })
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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();
|
||||
params.dscp = 1;
|
||||
let server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(
|
||||
client.open(),
|
||||
Err(ClientError::NegotiationRejected { .. })
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 mut params = params_from_config(&config).unwrap();
|
||||
params.duration_ns /= 2;
|
||||
let server = no_test_server(params.clone(), 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let negotiated = assert_no_test_completed(client.open().unwrap());
|
||||
assert_eq!(negotiated.params, params);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_no_test_completed(client.open().unwrap());
|
||||
assert!(matches!(
|
||||
client.send_probe(),
|
||||
Err(ClientError::AlreadyCompleted)
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 server = no_test_server(params, 0);
|
||||
config.server_addr = server.addr.to_string();
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert_no_test_completed(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyCompleted)));
|
||||
server.join();
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn address_resolution_connects_to_local_fake_server() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let _ = recv_request(&socket, &tx);
|
||||
});
|
||||
let client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
client.socket.send(b"ping").unwrap();
|
||||
assert_eq!(server.rx.recv().unwrap(), b"ping");
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[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!(matches!(client.phase, ClientPhase::Open { token: TOKEN }));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fails_when_already_open() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = open_success_server(params);
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert_open_started(client.open().unwrap());
|
||||
assert!(matches!(client.open(), Err(ClientError::AlreadyOpen)));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_retries_after_first_timeout() {
|
||||
let server = start_fake_server(|socket, tx| {
|
||||
let (first, _) = recv_request(&socket, &tx);
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let params = Params::decode(&first[4..]).unwrap();
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms, None);
|
||||
assert_eq!(first[3] & FLAG_OPEN, FLAG_OPEN);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200), Duration::from_millis(500)],
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
assert_open_started(outcome);
|
||||
assert_eq!(server.rx.iter().take(2).count(), 2);
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_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)],
|
||||
..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 open_restores_configured_read_timeout_after_timeout() {
|
||||
let server = timeout_server(Duration::from_millis(700));
|
||||
let config = ClientConfig {
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
socket_config: crate::SocketConfig {
|
||||
recv_timeout: Some(Duration::from_millis(450)),
|
||||
..crate::SocketConfig::default()
|
||||
},
|
||||
..default_test_config(server.addr)
|
||||
};
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::OpenTimeout)));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let mut buf = [0_u8; 1];
|
||||
assert!(client.socket.recv(&mut buf).is_err());
|
||||
assert!(start.elapsed() >= Duration::from_millis(350));
|
||||
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, .. })
|
||||
));
|
||||
server.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_rejection_fails_in_normal_mode() {
|
||||
let config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 1)));
|
||||
let params = params_from_config(&config).unwrap();
|
||||
let server = start_fake_server(move |socket, tx| {
|
||||
let (_, peer) = recv_request(&socket, &tx);
|
||||
let reply = open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms, None);
|
||||
socket.send_to(&reply, peer).unwrap();
|
||||
});
|
||||
let mut client = Client::connect(default_test_config(server.addr)).unwrap();
|
||||
assert!(matches!(client.open(), Err(ClientError::ServerRejected)));
|
||||
server.join();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,44 +0,0 @@
|
||||
use super::*;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::{
|
||||
probe::{CompletedSet, PendingMap, TimedOutMap},
|
||||
session::ActiveSession,
|
||||
};
|
||||
|
||||
#[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 session = ActiveSession {
|
||||
next_wire_seq: 0,
|
||||
highest_received_seq: Some(u32::MAX),
|
||||
packets_sent: 0,
|
||||
start_mono: Instant::now(),
|
||||
end_mono: None,
|
||||
next_send_at: Instant::now(),
|
||||
pending: PendingMap::new(8),
|
||||
timed_out: TimedOutMap::new(8),
|
||||
completed: CompletedSet::new(8),
|
||||
sending_done: false,
|
||||
};
|
||||
|
||||
update_highest_received(&mut session, 0);
|
||||
assert_eq!(session.highest_received_seq, Some(0));
|
||||
update_highest_received(&mut session, u32::MAX);
|
||||
assert_eq!(session.highest_received_seq, Some(0));
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
use crate::socket_options::socket_ttl;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_accepts_valid_ttl_config_values() {
|
||||
for ttl in [None, Some(1), Some(255)] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = ttl;
|
||||
Client::connect(config).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_applies_configured_ipv4_ttl_before_open_and_close_preserves_it() {
|
||||
let params = Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: 3_000_000_000,
|
||||
interval_ns: 1_000_000_000,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
..Params::default()
|
||||
};
|
||||
let server = open_success_server(params.clone());
|
||||
let mut config = default_test_config(server.addr);
|
||||
config.socket_config.ttl = Some(64);
|
||||
let mut client = Client::connect(config).unwrap();
|
||||
|
||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
||||
|
||||
client.open().unwrap();
|
||||
server.join();
|
||||
|
||||
client.close().unwrap();
|
||||
assert_eq!(socket_ttl(&client.socket, client.remote).unwrap(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_rejects_invalid_ttl_values() {
|
||||
for ttl in [0, 256] {
|
||||
let mut config = default_test_config(SocketAddr::from(([127, 0, 0, 1], 9)));
|
||||
config.socket_config.ttl = Some(ttl);
|
||||
assert!(matches!(
|
||||
Client::connect(config),
|
||||
Err(ClientError::InvalidConfig { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use crate::{Authentication, ClientError};
|
||||
|
||||
/// Protocol compatibility bound for a requested `server_fill` value, in UTF-8
|
||||
/// bytes.
|
||||
///
|
||||
/// This is the maximum encoded server-fill string accepted by this client and
|
||||
/// protocol decoder. [`SessionRequest::server_fill`] enforces the same bound
|
||||
/// before opening a session.
|
||||
pub use irtt_proto::MAX_SERVER_FILL_BYTES;
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
|
||||
pub(crate) const DEFAULT_PORT: u16 = 2112;
|
||||
/// Largest valid DSCP codepoint accepted by client configuration.
|
||||
///
|
||||
/// DSCP is a six-bit value in the range `0..=63`. The client shifts it left
|
||||
/// by two bits into the raw IP TOS / Traffic Class byte carried by
|
||||
/// [`irtt_proto::Params::dscp`] and applied to the socket during the active
|
||||
/// test phase.
|
||||
pub const MAX_DSCP_CODEPOINT: u8 = 63;
|
||||
/// Largest IPv4 TTL or IPv6 hop-limit value accepted by client configuration.
|
||||
///
|
||||
/// This is the public user-configuration bound for
|
||||
/// [`SocketConfig::ttl`]. A value of zero is rejected separately because socket
|
||||
/// TTL and hop-limit settings are configured as `1..=255`.
|
||||
pub const MAX_TTL: u32 = 255;
|
||||
/// Largest UDP payload length accepted by client configuration, in bytes.
|
||||
///
|
||||
/// This is the maximum UDP payload size excluding IP and UDP headers. It caps
|
||||
/// [`SessionRequest::length`] before protocol packets are encoded or sent.
|
||||
pub const MAX_UDP_PAYLOAD_LENGTH: u32 = 65_507;
|
||||
pub const MAX_SERVER_FILL_BYTES: usize = 32;
|
||||
pub(crate) const DEFAULT_DURATION: Duration = Duration::from_secs(3);
|
||||
pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(1);
|
||||
pub(crate) const DEFAULT_OPEN_TIMEOUTS: [Duration; 4] = [
|
||||
@@ -19,30 +42,151 @@ pub(crate) const MIN_OPEN_TIMEOUT: Duration = Duration::from_millis(200);
|
||||
pub(crate) const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
pub(crate) const DEFAULT_MAX_PENDING: usize = 4096;
|
||||
|
||||
/// Configuration for opening and running an IRTT client session.
|
||||
///
|
||||
/// This type describes both the protocol parameters sent in the IRTT open
|
||||
/// request and the local client behavior used to drive the UDP socket. Values
|
||||
/// that are negotiated by the server are available after opening the session
|
||||
/// through [`NegotiationResult`](crate::NegotiationResult).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Configure a finite run with larger probes and accept documented server
|
||||
/// restrictions. Use `duration: None` for continuous mode, or
|
||||
/// [`RunMode::NoTest`] for negotiation without probes.
|
||||
///
|
||||
/// ```
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// use irtt_client::{ClientConfig, NegotiationPolicy, OpenPolicy, SessionRequest};
|
||||
///
|
||||
/// let config = ClientConfig {
|
||||
/// request: SessionRequest {
|
||||
/// duration: Some(Duration::from_secs(10)),
|
||||
/// interval: Duration::from_millis(250),
|
||||
/// length: 1200,
|
||||
/// ..SessionRequest::default()
|
||||
/// },
|
||||
/// open: OpenPolicy {
|
||||
/// negotiation: NegotiationPolicy::Loose,
|
||||
/// ..OpenPolicy::default()
|
||||
/// },
|
||||
/// ..ClientConfig::default()
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(config.request.duration, Some(Duration::from_secs(10)));
|
||||
/// assert_eq!(config.request.interval, Duration::from_millis(250));
|
||||
/// assert_eq!(config.request.length, 1200);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
pub server_addr: String,
|
||||
pub duration: Option<Duration>,
|
||||
pub interval: Duration,
|
||||
pub length: u32,
|
||||
pub received_stats: ReceivedStats,
|
||||
pub stamp_at: StampAt,
|
||||
pub clock: Clock,
|
||||
pub dscp: u8,
|
||||
pub hmac_key: Option<Vec<u8>>,
|
||||
pub server_fill: Option<String>,
|
||||
pub open_timeouts: Vec<Duration>,
|
||||
pub run_mode: RunMode,
|
||||
pub negotiation_policy: NegotiationPolicy,
|
||||
pub socket_config: SocketConfig,
|
||||
/// Remote address families allowed during resolution and socket creation.
|
||||
pub address_family: AddressFamily,
|
||||
/// Local UDP socket properties.
|
||||
pub socket: SocketConfig,
|
||||
/// Protocol and session values requested from the server.
|
||||
pub request: SessionRequest,
|
||||
/// Open retry timeouts and negotiation policy.
|
||||
pub open: OpenPolicy,
|
||||
/// Concrete authentication for open, echo, and close packets.
|
||||
///
|
||||
/// HMAC peers must use the same key. An empty HMAC key remains authenticated.
|
||||
pub auth: Authentication,
|
||||
/// Time after sending a probe before the client reports it as lost.
|
||||
///
|
||||
/// This timeout is local client behavior; it is not negotiated with the
|
||||
/// server. It must be greater than zero.
|
||||
pub probe_timeout: Duration,
|
||||
/// Maximum number of probes tracked as pending/timed-out/completed.
|
||||
///
|
||||
/// This bounds memory used for reply classification and must be greater
|
||||
/// than zero. A very small value can reject sends when replies are still
|
||||
/// outstanding.
|
||||
pub max_pending_probes: usize,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_addr: format!("127.0.0.1:{DEFAULT_PORT}"),
|
||||
address_family: AddressFamily::default(),
|
||||
socket: SocketConfig::default(),
|
||||
request: SessionRequest::default(),
|
||||
open: OpenPolicy::default(),
|
||||
auth: Authentication::Unauthenticated,
|
||||
probe_timeout: DEFAULT_PROBE_TIMEOUT,
|
||||
max_pending_probes: DEFAULT_MAX_PENDING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Allowed remote address families. IPv6-only selection also sets IPV6_V6ONLY.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AddressFamily {
|
||||
/// Accept IPv4 and IPv6 addresses.
|
||||
#[default]
|
||||
Any,
|
||||
/// Accept only IPv4 addresses.
|
||||
Ipv4,
|
||||
/// Accept only IPv6 addresses and use IPv6-only sockets.
|
||||
Ipv6,
|
||||
}
|
||||
|
||||
/// Requested IRTT protocol and session semantics, reusable across endpoints.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionRequest {
|
||||
/// Requested run duration.
|
||||
///
|
||||
/// `Some(duration)` requests a finite test and must be greater than zero.
|
||||
/// `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.
|
||||
///
|
||||
/// The value must fit within the UDP payload limit after protocol overhead.
|
||||
/// A server may reduce the requested length during negotiation.
|
||||
pub length: u32,
|
||||
/// Requested server receive-statistics fields in echo replies.
|
||||
pub received_stats: ReceivedStats,
|
||||
/// Requested timestamp placement in echo replies.
|
||||
pub stamp_at: StampAt,
|
||||
/// Requested server clock sources for timestamp fields.
|
||||
///
|
||||
/// [`Clock::Unspecified`] is the wire default meaning "no clock tag was
|
||||
/// sent" and is not a client mode; opening a session with it is rejected.
|
||||
/// Request [`Clock::Wall`], [`Clock::Monotonic`], or [`Clock::Both`].
|
||||
pub clock: Clock,
|
||||
/// Requested DSCP codepoint.
|
||||
///
|
||||
/// This is the six-bit DSCP value, not the full traffic-class byte, and
|
||||
/// must be less than or equal to [`MAX_DSCP_CODEPOINT`].
|
||||
pub dscp: u8,
|
||||
/// Optional server payload fill request.
|
||||
///
|
||||
/// `None` leaves server fill behavior unspecified. `Some(value)` requests
|
||||
/// a non-empty server fill mode/value and must not exceed
|
||||
/// [`MAX_SERVER_FILL_BYTES`] bytes when UTF-8 encoded.
|
||||
pub server_fill: Option<String>,
|
||||
/// Whether opening the session should start a probe test or perform a
|
||||
/// negotiation-only no-test exchange.
|
||||
pub run_mode: RunMode,
|
||||
}
|
||||
|
||||
impl Default for SessionRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
duration: Some(DEFAULT_DURATION),
|
||||
interval: DEFAULT_INTERVAL,
|
||||
length: 0,
|
||||
@@ -50,41 +194,131 @@ impl Default for ClientConfig {
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 0,
|
||||
hmac_key: None,
|
||||
server_fill: None,
|
||||
open_timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
|
||||
run_mode: RunMode::Normal,
|
||||
negotiation_policy: NegotiationPolicy::Strict,
|
||||
socket_config: SocketConfig::default(),
|
||||
probe_timeout: DEFAULT_PROBE_TIMEOUT,
|
||||
max_pending_probes: DEFAULT_MAX_PENDING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SocketConfig {
|
||||
pub bind_addr: Option<SocketAddr>,
|
||||
pub ttl: Option<u32>,
|
||||
pub ipv4_only: bool,
|
||||
pub ipv6_only: bool,
|
||||
pub recv_timeout: Option<Duration>,
|
||||
/// Opening behavior shared by blocking, async, and managed clients.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenPolicy {
|
||||
/// Per-attempt receive timeouts used while opening the session.
|
||||
///
|
||||
/// The client sends an open request for each entry until a valid open reply
|
||||
/// is received. The list must not be empty, and each timeout must be at
|
||||
/// least 200 ms.
|
||||
pub timeouts: Vec<Duration>,
|
||||
/// Policy for server changes to negotiable protocol parameters.
|
||||
pub negotiation: NegotiationPolicy,
|
||||
}
|
||||
|
||||
impl OpenPolicy {
|
||||
pub(crate) fn validate(&self) -> Result<(), ClientError> {
|
||||
if self.timeouts.is_empty() {
|
||||
return Err(ClientError::NoOpenTimeouts);
|
||||
}
|
||||
for timeout in &self.timeouts {
|
||||
if *timeout < MIN_OPEN_TIMEOUT {
|
||||
return Err(ClientError::OpenTimeoutTooSmall {
|
||||
timeout: *timeout,
|
||||
minimum: MIN_OPEN_TIMEOUT,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpenPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeouts: DEFAULT_OPEN_TIMEOUTS.to_vec(),
|
||||
negotiation: NegotiationPolicy::Strict,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Local UDP socket options used by [`ClientConfig`].
|
||||
///
|
||||
/// These settings affect how the client binds and sends from the socket. They
|
||||
/// do not change the IRTT protocol parameters negotiated with the server.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Bind to a chosen local endpoint and set the outgoing IPv4 TTL:
|
||||
///
|
||||
/// ```
|
||||
/// use std::net::{Ipv4Addr, SocketAddr};
|
||||
///
|
||||
/// use irtt_client::{AddressFamily, ClientConfig, SocketConfig};
|
||||
///
|
||||
/// let config = ClientConfig {
|
||||
/// address_family: AddressFamily::Ipv4,
|
||||
/// socket: SocketConfig {
|
||||
/// bind_addr: Some(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))),
|
||||
/// ttl: Some(32),
|
||||
/// ..SocketConfig::default()
|
||||
/// },
|
||||
/// ..ClientConfig::default()
|
||||
/// };
|
||||
///
|
||||
/// assert_eq!(config.socket.ttl, Some(32));
|
||||
/// assert_eq!(config.address_family, AddressFamily::Ipv4);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SocketConfig {
|
||||
/// Local address to bind before connecting the UDP socket.
|
||||
///
|
||||
/// `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
|
||||
/// platform socket option range; [`MAX_TTL`] is the public configuration
|
||||
/// bound used by this crate.
|
||||
pub ttl: Option<u32>,
|
||||
}
|
||||
|
||||
/// How strictly to handle server-side negotiation restrictions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NegotiationPolicy {
|
||||
/// Reject any negotiated parameter that is more restrictive or different
|
||||
/// than requested.
|
||||
Strict,
|
||||
/// Accept documented server restrictions and report them in
|
||||
/// [`NegotiationResult`](crate::NegotiationResult).
|
||||
Loose,
|
||||
}
|
||||
|
||||
/// Mode requested during the IRTT open exchange.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RunMode {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Bound for a single receive-drain operation.
|
||||
///
|
||||
/// This is used by lower-level callers that drive [`Client`](crate::Client)
|
||||
/// directly and want to cap how many datagrams are processed before returning
|
||||
/// to their own event loop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RecvBudget {
|
||||
/// Maximum number of datagrams to process before returning.
|
||||
pub max_packets: usize,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +1,189 @@
|
||||
use std::{io, time::Duration};
|
||||
use std::{collections::TryReserveError, io, time::Duration};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Failure reported while configuring, opening, running, receiving, closing, or
|
||||
/// joining an IRTT client session.
|
||||
///
|
||||
/// Configuration validation errors usually require changing
|
||||
/// [`ClientConfig`](crate::ClientConfig) or related options before retrying.
|
||||
/// Network, protocol, and runtime errors may be transient, but can also reflect
|
||||
/// peer incompatibility or an invalid session state.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ClientError {
|
||||
/// A Tokio-backed client operation was polled without a current runtime.
|
||||
#[cfg(feature = "tokio")]
|
||||
#[error("Tokio AsyncClient requires a current Tokio runtime")]
|
||||
NoTokioRuntime,
|
||||
/// The configured server address could not be resolved to a socket address.
|
||||
///
|
||||
/// Check the host, port, address-family restrictions, and name service
|
||||
/// configuration before retrying.
|
||||
#[error("failed to resolve server address {addr:?}")]
|
||||
Resolve { addr: String },
|
||||
/// A UDP socket operation failed while binding, connecting, sending, or
|
||||
/// receiving.
|
||||
///
|
||||
/// This can be a transient network or OS error, but may also reflect local
|
||||
/// permissions, routing, or socket configuration.
|
||||
#[error("socket error: {0}")]
|
||||
Socket(#[from] io::Error),
|
||||
/// Applying or reading a socket option failed.
|
||||
///
|
||||
/// This is usually caused by an unsupported option, an invalid local value,
|
||||
/// or a platform-specific socket limitation. Changing socket configuration
|
||||
/// is more likely to help than retrying unchanged.
|
||||
#[error("socket option error while trying to {operation} for {remote}: {source}")]
|
||||
SocketOption {
|
||||
operation: &'static str,
|
||||
remote: std::net::SocketAddr,
|
||||
source: io::Error,
|
||||
},
|
||||
/// Restoring the configured read timeout after open negotiation failed.
|
||||
///
|
||||
/// The prepared session was not committed, and the client remains
|
||||
/// connected rather than partially open.
|
||||
#[error("failed to restore configured socket read timeout after open negotiation: {source}")]
|
||||
ReadTimeoutRestore { source: io::Error },
|
||||
/// A packet failed IRTT protocol parsing or authentication.
|
||||
///
|
||||
/// Likely causes include malformed input, unsupported protocol data, or an
|
||||
/// HMAC mismatch between client and server.
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(#[from] irtt_proto::ProtoError),
|
||||
/// No valid open reply was received before all configured open attempts
|
||||
/// timed out.
|
||||
///
|
||||
/// Retrying can succeed when the server or network is temporarily
|
||||
/// unavailable. Persistent failures usually require changing the address,
|
||||
/// authentication key, server configuration, or timeout settings.
|
||||
#[error("all open requests timed out")]
|
||||
OpenTimeout,
|
||||
/// An open-attempt timeout is below the client's supported minimum.
|
||||
///
|
||||
/// Increase the configured timeout before retrying.
|
||||
#[error("open timeout {timeout:?} is below the minimum {minimum:?}")]
|
||||
OpenTimeoutTooSmall {
|
||||
timeout: Duration,
|
||||
minimum: Duration,
|
||||
},
|
||||
/// The configured open-attempt timeout list is empty.
|
||||
///
|
||||
/// Provide at least one timeout before opening the session.
|
||||
#[error("open_timeouts must not be empty")]
|
||||
NoOpenTimeouts,
|
||||
/// The server explicitly rejected the open request.
|
||||
///
|
||||
/// Retrying unchanged is unlikely to help unless the server configuration or
|
||||
/// load has changed.
|
||||
#[error("server rejected the open request")]
|
||||
ServerRejected,
|
||||
/// A no-test open request received a reply that did not have no-test close
|
||||
/// semantics.
|
||||
///
|
||||
/// This indicates an incompatible or unexpected peer response.
|
||||
#[error("unexpected no-test open reply")]
|
||||
UnexpectedNoTestReply,
|
||||
/// A no-test open reply included a connection token.
|
||||
///
|
||||
/// No-test negotiation must not establish a probe session, so a non-zero
|
||||
/// token indicates an incompatible or unexpected peer response.
|
||||
#[error("no-test reply included a non-zero connection token: {token}")]
|
||||
NonZeroNoTestToken { token: u64 },
|
||||
/// The peer replied with a protocol version different from the requested
|
||||
/// version.
|
||||
///
|
||||
/// This is a protocol compatibility error and requires compatible client
|
||||
/// and server versions.
|
||||
#[error("protocol version mismatch: requested {requested}, received {received}")]
|
||||
ProtocolVersionMismatch { requested: i64, received: i64 },
|
||||
/// The server returned a zero connection token for a normal test session.
|
||||
///
|
||||
/// A normal session requires a non-zero token to correlate echo traffic, so
|
||||
/// this indicates an invalid peer response.
|
||||
#[error("server returned a zero connection token")]
|
||||
ZeroToken,
|
||||
/// Negotiated parameters were rejected by the configured negotiation policy.
|
||||
///
|
||||
/// Use a less restrictive configuration or
|
||||
/// [`NegotiationPolicy::Loose`](crate::NegotiationPolicy::Loose) when the
|
||||
/// caller accepts documented server restrictions.
|
||||
#[error("strict negotiation rejected changed params: {reason}")]
|
||||
NegotiationRejected { reason: String },
|
||||
/// The requested operation requires an open session, but the client is only
|
||||
/// connected.
|
||||
#[error("client is not open")]
|
||||
NotOpen,
|
||||
/// The session is already open.
|
||||
///
|
||||
/// Continue running, receiving, or closing the existing session instead of
|
||||
/// opening it again.
|
||||
#[error("client session is already open")]
|
||||
AlreadyOpen,
|
||||
/// The no-test session has already completed.
|
||||
///
|
||||
/// Create a new client session to perform another negotiation.
|
||||
#[error("client session is already completed")]
|
||||
AlreadyCompleted,
|
||||
/// The session is already closed.
|
||||
///
|
||||
/// Create a new client session before opening or running another test.
|
||||
#[error("client session is already closed")]
|
||||
AlreadyClosed,
|
||||
/// A duration could not be represented as nanoseconds for scheduling or
|
||||
/// protocol encoding.
|
||||
///
|
||||
/// Use smaller configured or negotiated durations.
|
||||
#[error("duration is too large to encode as nanoseconds")]
|
||||
DurationOverflow,
|
||||
/// A client-maintained counter would overflow.
|
||||
///
|
||||
/// This is a runtime limit rather than a network failure. Start a new
|
||||
/// session if the test duration or send volume reaches this limit.
|
||||
#[error("counter {counter} overflowed")]
|
||||
CounterOverflow { counter: &'static str },
|
||||
/// Sending another probe would exceed the configured pending-probe limit.
|
||||
///
|
||||
/// Increase [`ClientConfig::max_pending_probes`](crate::ClientConfig::max_pending_probes),
|
||||
/// reduce send rate, or receive replies and timeouts more often before
|
||||
/// sending again.
|
||||
#[error("pending probe limit exceeded ({limit})")]
|
||||
PendingLimitExceeded { limit: usize },
|
||||
/// A wrapping wire sequence is still represented by an in-flight probe.
|
||||
///
|
||||
/// The existing probe must be replied to or timed out before that sequence
|
||||
/// can be reused.
|
||||
#[error("wire sequence {seq} is still pending")]
|
||||
PendingSequenceCollision { seq: u32 },
|
||||
/// A prepared probe no longer matches the session's next wire sequence.
|
||||
///
|
||||
/// Prepare the probe again after any intervening successful send.
|
||||
#[error(
|
||||
"prepared probe sequence {prepared_seq} is stale; next wire sequence is {next_wire_seq}"
|
||||
)]
|
||||
StalePreparedProbe {
|
||||
prepared_seq: u32,
|
||||
next_wire_seq: u32,
|
||||
},
|
||||
/// Reserving memory required by an operation failed before any datagram was
|
||||
/// sent or logical protocol state was changed.
|
||||
#[error("failed to reserve memory for {operation}: {source}")]
|
||||
AllocationFailed {
|
||||
operation: &'static str,
|
||||
#[source]
|
||||
source: TryReserveError,
|
||||
},
|
||||
/// UDP reported successful acceptance of a datagram with an unexpected
|
||||
/// byte count.
|
||||
///
|
||||
/// The send is committed locally before this transport invariant failure
|
||||
/// is returned because the kernel reported success.
|
||||
#[error("UDP accepted {actual} bytes for a {expected}-byte datagram")]
|
||||
DatagramLengthMismatch { expected: usize, actual: usize },
|
||||
/// Client configuration failed validation before the requested operation.
|
||||
///
|
||||
/// The reported reason names a value that must be changed before retrying.
|
||||
#[error("invalid configuration: {reason}")]
|
||||
InvalidConfig { reason: String },
|
||||
#[error("managed client worker thread panicked")]
|
||||
WorkerPanicked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EventSubscriptionError {
|
||||
#[error("event subscription is disconnected")]
|
||||
Disconnected,
|
||||
}
|
||||
+293
-49
@@ -3,141 +3,394 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{session::NegotiatedParams, timing::ClientTimestamp};
|
||||
use crate::{session::NegotiationResult, timing::ClientTimestamp};
|
||||
|
||||
/// Successful negotiation with a live session ready for echo probes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OpenOutcome {
|
||||
Started {
|
||||
remote: SocketAddr,
|
||||
token: u64,
|
||||
negotiated: NegotiatedParams,
|
||||
event: ClientEvent,
|
||||
},
|
||||
NoTestCompleted {
|
||||
remote: SocketAddr,
|
||||
negotiated: NegotiatedParams,
|
||||
event: ClientEvent,
|
||||
},
|
||||
pub struct SessionStarted {
|
||||
/// Resolved remote socket address.
|
||||
pub remote: SocketAddr,
|
||||
/// Session token used on Echo and Close packets.
|
||||
pub token: u64,
|
||||
pub negotiation: NegotiationResult,
|
||||
/// Client timestamp when the Open reply was accepted.
|
||||
pub at: ClientTimestamp,
|
||||
}
|
||||
|
||||
/// Successful negotiation-only exchange with no live probe session or token.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NoTestCompleted {
|
||||
pub remote: SocketAddr,
|
||||
pub negotiation: NegotiationResult,
|
||||
/// Client timestamp when the Open reply was accepted.
|
||||
pub at: ClientTimestamp,
|
||||
}
|
||||
|
||||
/// Result of the IRTT Open exchange, before any echo probes are driven.
|
||||
///
|
||||
/// Managed sessions publish the same payload as a [`ClientEvent`] after Open.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OpenOutcome {
|
||||
Started(SessionStarted),
|
||||
NoTestCompleted(NoTestCompleted),
|
||||
}
|
||||
|
||||
/// Event emitted by an IRTT client session.
|
||||
///
|
||||
/// Events form the public lifecycle and measurement stream for callers. UIs
|
||||
/// usually display lifecycle, warning, and per-packet events directly; summary
|
||||
/// reporting should aggregate `EchoSent`, `EchoReply`, `EchoLoss`,
|
||||
/// `LateReply`, and `DuplicateReply` events or pass the stream to
|
||||
/// `irtt-stats`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClientEvent {
|
||||
SessionStarted {
|
||||
remote: SocketAddr,
|
||||
token: u64,
|
||||
negotiated: NegotiatedParams,
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
NoTestCompleted {
|
||||
remote: SocketAddr,
|
||||
negotiated: NegotiatedParams,
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
/// A normal session has opened and is ready to send echo probes.
|
||||
SessionStarted(SessionStarted),
|
||||
/// A no-test exchange completed without creating a live session.
|
||||
///
|
||||
/// No `EchoSent`, `EchoReply`, `EchoLoss`, or `SessionClosed` events are
|
||||
/// expected for this outcome.
|
||||
NoTestCompleted(NoTestCompleted),
|
||||
/// The session closed locally or through an authenticated peer close.
|
||||
///
|
||||
/// For a peer close-flagged echo reply, the reply's measurement or
|
||||
/// classification event is emitted immediately before this event.
|
||||
/// Managed sessions disconnect event subscribers after the worker exits,
|
||||
/// while leaving already queued events available to drain.
|
||||
SessionClosed {
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Session token that was closed.
|
||||
token: u64,
|
||||
/// Client timestamp when the close request was sent.
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
|
||||
/// An echo request was sent.
|
||||
///
|
||||
/// This records local scheduling and send-call timing. It does not imply a
|
||||
/// reply will arrive; callers should pair it with `EchoReply`, `EchoLoss`,
|
||||
/// `LateReply`, or `DuplicateReply` by sequence number when displaying
|
||||
/// packet lifecycle state.
|
||||
EchoSent {
|
||||
/// Wire sequence number sent in the echo request.
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
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
|
||||
/// timeout deadline is computed from an earlier, private, pre-send
|
||||
/// timestamp instead — see the client crate's `AGENTS.md`.
|
||||
sent_at: ClientTimestamp,
|
||||
/// Number of bytes passed to the UDP socket.
|
||||
bytes: usize,
|
||||
/// Elapsed time spent in the socket send call, excluding protocol and
|
||||
/// scheduler commit work.
|
||||
send_call: Duration,
|
||||
timer_error: Duration,
|
||||
/// Absolute difference between the scheduled send time and the
|
||||
/// post-send `sent_at` timestamp above; absent for low-level sends.
|
||||
timer_error: Option<Duration>,
|
||||
},
|
||||
|
||||
/// First in-window reply for a pending echo request.
|
||||
///
|
||||
/// This is the primary successful measurement event. RTT and optional
|
||||
/// server timing/statistics fields should generally be aggregated from this
|
||||
/// variant.
|
||||
EchoReply {
|
||||
/// Wire sequence number from the echo reply.
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Client timestamp recorded immediately after the matching request
|
||||
/// finished sending (see `ClientEvent::EchoSent::sent_at`).
|
||||
sent_at: ClientTimestamp,
|
||||
/// Client timestamp recorded when the reply was received.
|
||||
received_at: ClientTimestamp,
|
||||
/// Round-trip timing sample for this reply.
|
||||
rtt: RttSample,
|
||||
/// Optional server-reported timestamps and processing time.
|
||||
server_timing: Option<ServerTiming>,
|
||||
/// Optional one-way delay values derived from client/server wall clocks.
|
||||
one_way: Option<OneWayDelaySample>,
|
||||
/// Optional server-reported received-statistics sample.
|
||||
received_stats: Option<ReceivedStatsSample>,
|
||||
/// UDP datagram size received from the socket.
|
||||
bytes: usize,
|
||||
/// Receive-side metadata observed outside the IRTT wire payload.
|
||||
packet_meta: PacketMeta,
|
||||
},
|
||||
|
||||
/// A sent echo request exceeded the local probe timeout before a reply was
|
||||
/// matched.
|
||||
///
|
||||
/// A later datagram for the same sequence may still arrive and will be
|
||||
/// reported as [`ClientEvent::LateReply`] rather than changing this event.
|
||||
EchoLoss {
|
||||
/// Wire sequence number that timed out.
|
||||
seq: u32,
|
||||
/// Client timestamp recorded immediately after the request finished
|
||||
/// sending (see `ClientEvent::EchoSent::sent_at`). The timeout
|
||||
/// deadline below is anchored to an earlier, private, pre-send
|
||||
/// timestamp, not to this one.
|
||||
sent_at: ClientTimestamp,
|
||||
/// Monotonic deadline at which the probe was declared lost.
|
||||
timeout_at: std::time::Instant,
|
||||
},
|
||||
|
||||
/// Additional reply for a sequence that was already completed.
|
||||
///
|
||||
/// Duplicates are not primary RTT samples. They are useful for diagnostics
|
||||
/// and packet counters, but should not be aggregated as successful first
|
||||
/// replies.
|
||||
DuplicateReply {
|
||||
/// Wire sequence number from the duplicate reply.
|
||||
seq: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Client timestamp recorded when the duplicate was received.
|
||||
received_at: ClientTimestamp,
|
||||
/// UDP datagram size received from the socket.
|
||||
bytes: usize,
|
||||
},
|
||||
|
||||
/// Reply for a sequence that was no longer pending.
|
||||
///
|
||||
/// This commonly means the probe had already been reported as lost, or the
|
||||
/// reply arrived out of the client's current tracking window. When the
|
||||
/// original send metadata is still retained, RTT and one-way timing are
|
||||
/// included; otherwise those fields are `None`.
|
||||
LateReply {
|
||||
/// Wire sequence number from the late reply.
|
||||
seq: u32,
|
||||
/// Highest sequence number accepted as an in-window first reply so far.
|
||||
highest_seen: u32,
|
||||
/// Resolved remote socket address.
|
||||
remote: SocketAddr,
|
||||
/// Original post-send client timestamp, when still retained (see
|
||||
/// `ClientEvent::EchoSent::sent_at`).
|
||||
sent_at: Option<ClientTimestamp>,
|
||||
/// Client timestamp recorded when the late reply was received.
|
||||
received_at: ClientTimestamp,
|
||||
/// RTT sample computed when the original send timestamp is available.
|
||||
rtt: Option<RttSample>,
|
||||
/// Optional server-reported timestamps and processing time.
|
||||
server_timing: Option<ServerTiming>,
|
||||
/// Optional one-way delay values derived from client/server wall clocks.
|
||||
one_way: Option<OneWayDelaySample>,
|
||||
/// Optional server-reported received-statistics sample.
|
||||
received_stats: Option<ReceivedStatsSample>,
|
||||
/// UDP datagram size received from the socket.
|
||||
bytes: usize,
|
||||
/// Receive-side metadata observed outside the IRTT wire payload.
|
||||
packet_meta: PacketMeta,
|
||||
},
|
||||
|
||||
/// Non-fatal condition observed while receiving or classifying packets.
|
||||
///
|
||||
/// Warning events are diagnostic. They do not close the session by
|
||||
/// themselves and are not measurement samples.
|
||||
Warning {
|
||||
/// Machine-readable warning category.
|
||||
kind: WarningKind,
|
||||
/// Human-readable diagnostic text.
|
||||
message: String,
|
||||
/// Client timestamp when the warning was emitted.
|
||||
at: ClientTimestamp,
|
||||
},
|
||||
}
|
||||
|
||||
/// Category for a non-fatal [`ClientEvent::Warning`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum WarningKind {
|
||||
/// A received datagram could not be decoded as a relevant IRTT packet.
|
||||
MalformedOrUnrelatedPacket,
|
||||
/// A decoded packet used a token that does not match the open session.
|
||||
WrongToken,
|
||||
/// A decoded reply could not be matched to tracked probe state.
|
||||
UntrackedReply,
|
||||
}
|
||||
|
||||
/// Round-trip timing for one echo reply.
|
||||
///
|
||||
/// `raw` is the client-observed elapsed time from sending the echo request to
|
||||
/// receiving the reply and is therefore non-negative. When the server reports
|
||||
/// enough monotonic timing to compute processing time, `adjusted` subtracts
|
||||
/// that server processing from `raw`. `effective` is the value used by reporting
|
||||
/// and statistics: adjusted when available, otherwise raw converted to a signed
|
||||
/// value.
|
||||
///
|
||||
/// Adjusted and effective RTT values are signed. They may be negative if the
|
||||
/// reported server processing time is greater than the raw client-observed RTT.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RttSample {
|
||||
/// Client-observed RTT from send to receive.
|
||||
pub raw: Duration,
|
||||
pub adjusted: Option<Duration>,
|
||||
pub effective: Duration,
|
||||
pub adjusted_signed: Option<SignedDuration>,
|
||||
pub effective_signed: SignedDuration,
|
||||
/// Signed RTT adjusted for server processing when server processing is available.
|
||||
pub adjusted: Option<SignedDuration>,
|
||||
/// Signed effective RTT used by stats and CLI reporting.
|
||||
///
|
||||
/// This is `adjusted` when server processing is available, otherwise `raw`
|
||||
/// converted to a signed duration.
|
||||
pub effective: SignedDuration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// Signed nanosecond-backed duration used for derived timing values.
|
||||
///
|
||||
/// This type preserves negative values that can arise after subtracting server
|
||||
/// processing time or comparing wall-clock timestamps from different hosts. It
|
||||
/// should not be used to represent raw elapsed wall-clock or monotonic time,
|
||||
/// which is non-negative and represented by [`Duration`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Signed timing values retain a negative result instead of clamping clock
|
||||
/// skew or server processing adjustments to zero:
|
||||
///
|
||||
/// ```
|
||||
/// use irtt_client::SignedDuration;
|
||||
///
|
||||
/// let adjusted_rtt = SignedDuration::from_nanos(-250_000);
|
||||
/// assert!(adjusted_rtt.is_negative());
|
||||
/// assert_eq!(adjusted_rtt.as_micros(), -250);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct SignedDuration {
|
||||
pub ns: i128,
|
||||
ns: i128,
|
||||
}
|
||||
|
||||
impl SignedDuration {
|
||||
/// Construct a signed duration from nanoseconds.
|
||||
pub const fn from_nanos(ns: i128) -> Self {
|
||||
Self { ns }
|
||||
}
|
||||
|
||||
/// Convert a non-negative [`Duration`] into a signed duration.
|
||||
///
|
||||
/// Durations larger than `i128::MAX` nanoseconds saturate to `i128::MAX`.
|
||||
pub fn from_duration(duration: Duration) -> Self {
|
||||
Self {
|
||||
ns: i128::try_from(duration.as_nanos()).unwrap_or(i128::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the duration in nanoseconds.
|
||||
pub const fn as_nanos(self) -> i128 {
|
||||
self.ns
|
||||
}
|
||||
|
||||
/// Return the duration in whole microseconds, truncated toward zero.
|
||||
pub const fn as_micros(self) -> i128 {
|
||||
self.ns / 1_000
|
||||
}
|
||||
|
||||
/// Return the duration in whole milliseconds, truncated toward zero.
|
||||
pub const fn as_millis(self) -> i128 {
|
||||
self.ns / 1_000_000
|
||||
}
|
||||
|
||||
/// Return whether this duration is less than zero.
|
||||
pub const fn is_negative(self) -> bool {
|
||||
self.ns < 0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Duration> for SignedDuration {
|
||||
fn from(value: Duration) -> Self {
|
||||
Self::from_duration(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-reported timing fields decoded from an echo reply.
|
||||
///
|
||||
/// These values are optional because the negotiated timestamp mode, clock mode,
|
||||
/// server implementation, or packet contents may omit individual fields.
|
||||
/// Timestamp values are nanoseconds in the server's reported wall-clock or
|
||||
/// monotonic clock domain. `None` means unavailable; `Some(0)` means the server
|
||||
/// reported a zero timestamp.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ServerTiming {
|
||||
/// Server wall-clock receive timestamp, in nanoseconds.
|
||||
pub receive_wall_ns: Option<i64>,
|
||||
/// Server monotonic receive timestamp, in nanoseconds.
|
||||
pub receive_mono_ns: Option<i64>,
|
||||
/// Server wall-clock send timestamp, in nanoseconds.
|
||||
pub send_wall_ns: Option<i64>,
|
||||
/// Server monotonic send timestamp, in nanoseconds.
|
||||
pub send_mono_ns: Option<i64>,
|
||||
/// Server wall-clock midpoint timestamp, in nanoseconds.
|
||||
pub midpoint_wall_ns: Option<i64>,
|
||||
/// Server monotonic midpoint timestamp, in nanoseconds.
|
||||
pub midpoint_mono_ns: Option<i64>,
|
||||
/// Server processing time computed from monotonic receive/send timestamps.
|
||||
///
|
||||
/// `None` means the required monotonic timestamps were unavailable or
|
||||
/// unusable. `Some(Duration::ZERO)` means the computed processing interval
|
||||
/// was available and zero.
|
||||
pub processing: Option<Duration>,
|
||||
}
|
||||
|
||||
/// Directional one-way delay samples computed from wall-clock timestamps.
|
||||
///
|
||||
/// Each directional field is an `Option<SignedDuration>`. `None` means the
|
||||
/// required client or server wall-clock timestamp was unavailable, or the
|
||||
/// timestamp arithmetic was out of range. `Some(value)` means the delay was
|
||||
/// computed from available timestamps.
|
||||
///
|
||||
/// Negative computed values are preserved as `Some` instead of being clamped to
|
||||
/// zero or dropped. A negative one-way delay usually indicates wall-clock skew
|
||||
/// between the client and server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OneWayDelaySample {
|
||||
pub client_to_server: Option<Duration>,
|
||||
pub server_to_client: Option<Duration>,
|
||||
/// Client-to-server delay.
|
||||
///
|
||||
/// This is computed from the client send wall time and the server receive
|
||||
/// wall time when both are available. The client send endpoint is the
|
||||
/// correlated Linux kernel TX timestamp for this send when one is
|
||||
/// available and plausible for this send, and the userspace post-send wall
|
||||
/// time otherwise. The server receive endpoint is whatever wall time the
|
||||
/// peer reported; against this crate's own Tokio server runtime on Linux
|
||||
/// (the `irtt-server` applet), that is its kernel receive timestamp when
|
||||
/// one is available and plausible, and its userspace receive wall time
|
||||
/// otherwise. Against another IRTT-compatible peer, or an application
|
||||
/// driving `irtt_server::ServerCore::handle_datagram` directly, the
|
||||
/// client has no way to know which clock source produced it — the public
|
||||
/// `ServerCore` API never supplies a kernel timestamp of its own accord.
|
||||
/// `RttSample::raw` is unaffected and remains a userspace
|
||||
/// monotonic measurement, but `adjusted` and `effective` can still shift:
|
||||
/// when the session negotiated wall-clock timing rather than monotonic,
|
||||
/// server processing time is derived from this same server receive
|
||||
/// endpoint.
|
||||
pub client_to_server: Option<SignedDuration>,
|
||||
/// Server-to-client delay.
|
||||
///
|
||||
/// This is computed from the server send wall time and the client receive
|
||||
/// wall time when both are available. The client receive endpoint is the
|
||||
/// kernel receive timestamp reported with the datagram when one is
|
||||
/// available and plausible for this receive, and the userspace receive wall
|
||||
/// time otherwise. RTT is unaffected and remains a userspace monotonic
|
||||
/// measurement.
|
||||
pub server_to_client: Option<SignedDuration>,
|
||||
}
|
||||
|
||||
/// Server-reported receive counters decoded from an echo reply.
|
||||
///
|
||||
/// These fields are optional because the negotiated received-statistics mode or
|
||||
/// server implementation may omit them. `None` means unavailable; `Some(0)`
|
||||
/// means the server reported the value and it was zero.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReceivedStatsSample {
|
||||
/// Server-reported count of received packets, when available.
|
||||
pub count: Option<u32>,
|
||||
/// Raw server-reported receive window bitmap, when available.
|
||||
///
|
||||
/// The bitmap is preserved exactly as it appeared on the wire; the client
|
||||
/// does not interpret its bits.
|
||||
pub window: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -150,20 +403,11 @@ pub struct PacketMeta {
|
||||
pub traffic_class: Option<u8>,
|
||||
pub dscp: Option<u8>,
|
||||
pub ecn: Option<u8>,
|
||||
/// Kernel software receive timestamp reported with the datagram, where the
|
||||
/// platform provides one.
|
||||
///
|
||||
/// This is the raw observed value, reported whether or not it was accepted
|
||||
/// as the downstream one-way delay endpoint. It is a kernel software
|
||||
/// timestamp, not a NIC hardware ingress time.
|
||||
pub kernel_rx_timestamp: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PacketMeta;
|
||||
|
||||
#[test]
|
||||
fn packet_meta_default_is_unavailable() {
|
||||
let meta = PacketMeta::default();
|
||||
|
||||
assert_eq!(meta.traffic_class, None);
|
||||
assert_eq!(meta.dscp, None);
|
||||
assert_eq!(meta.ecn, None);
|
||||
assert_eq!(meta.kernel_rx_timestamp, None);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,83 @@
|
||||
#![cfg_attr(
|
||||
not(all(target_os = "linux", feature = "ancillary")),
|
||||
forbid(unsafe_code)
|
||||
)]
|
||||
#![cfg_attr(all(target_os = "linux", feature = "ancillary"), deny(unsafe_code))]
|
||||
//! Reusable client/session/event layer for IRTT-compatible probes.
|
||||
//!
|
||||
//! `irtt-client` opens IRTT-compatible sessions, sends echo probes, receives
|
||||
//! replies, classifies loss/late/duplicate packets, and emits [`ClientEvent`]
|
||||
//! values for callers to consume directly or aggregate with `irtt-stats`.
|
||||
//! With the `tokio` feature enabled, `AsyncClient` provides the same low-level
|
||||
//! lifecycle for callers that own a Tokio runtime and drive readiness directly.
|
||||
//!
|
||||
//! Timing values intentionally preserve signed measurement semantics. When
|
||||
//! server timing is available, [`RttSample::effective`] is adjusted for server
|
||||
//! processing and can be negative if the reported server processing time exceeds
|
||||
//! the raw client-observed RTT. Directional [`OneWayDelaySample`] values are
|
||||
//! also signed when the required wall-clock timestamps are available; negative
|
||||
//! values usually indicate client/server clock skew.
|
||||
//!
|
||||
//! [`Client`] is the runtime-free low-level blocking adapter and requires no
|
||||
//! Tokio dependency at all; it is what a caller without a runtime, or that
|
||||
//! 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. Both adapters send one probe whenever called; callers own cadence
|
||||
//! and run duration, using `negotiation()` after Open. Sends return a
|
||||
//! [`SendReceipt`] after socket acceptance and session commitment;
|
||||
//! [`SendProbeError`] distinguishes uncommitted failures from post-commit
|
||||
//! failures carrying that receipt. Convert a receipt to [`ClientEvent`] for
|
||||
//! presentation or statistics. [`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
|
||||
//! current-thread runtime for callers that want managed multi-target
|
||||
//! behavior without touching Tokio themselves.
|
||||
//!
|
||||
//! A successful [`OpenOutcome`] carries either [`SessionStarted`] (with a live
|
||||
//! token) or [`NoTestCompleted`] (terminal, with no probe session). Lifecycle
|
||||
//! events use these same payload types. Their [`NegotiationResult`] separates
|
||||
//! typed [`AcceptedSessionParameters`] from the exact decoded peer `Params` and
|
||||
//! accepted [`NegotiationChange`] records. Use `accepted.interval` and
|
||||
//! `accepted.duration` for cadence and lifetime, `accepted.dscp` for the public
|
||||
//! DSCP codepoint, and `peer_params` for wire diagnostics. Strict/Loose policy
|
||||
//! compares the actual request and reply wire values before deriving accepted
|
||||
//! semantics. Both adapters' `negotiation()` accessors return only a live
|
||||
//! session's result; no-test completion leaves no current session.
|
||||
//!
|
||||
//! See `examples/` in the repository for a runnable example of each tier.
|
||||
//!
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
mod async_client;
|
||||
mod auth;
|
||||
mod client;
|
||||
mod config;
|
||||
mod error;
|
||||
mod event;
|
||||
mod managed;
|
||||
pub mod managed;
|
||||
mod metadata;
|
||||
mod probe;
|
||||
mod receive;
|
||||
mod send;
|
||||
mod session;
|
||||
mod socket;
|
||||
mod socket_options;
|
||||
mod timing;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use async_client::AsyncClient;
|
||||
pub use auth::{Authentication, HmacKey};
|
||||
pub use client::Client;
|
||||
pub use config::{
|
||||
ClientConfig, NegotiationPolicy, RecvBudget, RunMode, SocketConfig, MAX_DSCP_CODEPOINT,
|
||||
MAX_SERVER_FILL_BYTES, MAX_TTL, MAX_UDP_PAYLOAD_LENGTH,
|
||||
AddressFamily, ClientConfig, NegotiationPolicy, OpenPolicy, RecvBudget, RunMode,
|
||||
SessionRequest, SocketConfig, MAX_DSCP_CODEPOINT, MAX_SERVER_FILL_BYTES, MAX_TTL,
|
||||
MAX_UDP_PAYLOAD_LENGTH,
|
||||
};
|
||||
pub use error::{ClientError, EventSubscriptionError};
|
||||
pub use error::ClientError;
|
||||
pub use event::{
|
||||
ClientEvent, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample, RttSample,
|
||||
ServerTiming, SignedDuration, WarningKind,
|
||||
ClientEvent, NoTestCompleted, OneWayDelaySample, OpenOutcome, PacketMeta, ReceivedStatsSample,
|
||||
RttSample, ServerTiming, SessionStarted, SignedDuration, WarningKind,
|
||||
};
|
||||
pub use managed::{
|
||||
CancellationToken, EventHub, EventSubscription, ManagedClient, ManagedClientSession,
|
||||
SessionEndReason, SessionOutcome, SubscriberConfig, SubscriberOverflow,
|
||||
};
|
||||
pub use session::NegotiatedParams;
|
||||
pub use send::{SendProbeError, SendReceipt};
|
||||
pub use session::{AcceptedSessionParameters, NegotiationChange, NegotiationResult};
|
||||
pub use timing::ClientTimestamp;
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::{
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::runtime::{Builder, Runtime};
|
||||
|
||||
use super::{
|
||||
ManagedClient, ManagedClientConfig, ManagedClientHandle, ManagedClientTask, ManagedConfigError,
|
||||
ManagedEventSubscription, ManagedOutcome, ManagedSubscribeError, ManagedTargetConfig,
|
||||
};
|
||||
|
||||
const BLOCKING_RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Owns the runtime used exclusively by the blocking managed worker.
|
||||
pub(super) struct WorkerRuntime {
|
||||
runtime: Option<Runtime>,
|
||||
}
|
||||
|
||||
impl WorkerRuntime {
|
||||
pub(super) fn new(runtime: Runtime) -> Self {
|
||||
Self {
|
||||
runtime: Some(runtime),
|
||||
}
|
||||
}
|
||||
|
||||
fn block_on<F: std::future::Future>(&self, task: F) -> F::Output {
|
||||
self.runtime
|
||||
.as_ref()
|
||||
.expect("worker runtime is available until shutdown")
|
||||
.block_on(task)
|
||||
}
|
||||
|
||||
/// Shut down residual runtime work without delaying managed completion indefinitely.
|
||||
pub(super) fn shutdown(mut self) {
|
||||
self.runtime
|
||||
.take()
|
||||
.expect("worker runtime is available until shutdown")
|
||||
.shutdown_timeout(BLOCKING_RUNTIME_SHUTDOWN_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkerRuntime {
|
||||
fn drop(&mut self) {
|
||||
if let Some(runtime) = self.runtime.take() {
|
||||
runtime.shutdown_background();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure to start a blocking managed client owner.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BlockingManagedStartError {
|
||||
/// The managed task configuration was invalid.
|
||||
#[error(transparent)]
|
||||
Config(#[from] ManagedConfigError),
|
||||
|
||||
/// The managed event subscription could not be created before startup.
|
||||
#[error(transparent)]
|
||||
Subscribe(#[from] ManagedSubscribeError),
|
||||
|
||||
/// The dedicated Tokio runtime could not be created.
|
||||
#[error("failed to create blocking managed Tokio runtime: {source}")]
|
||||
Runtime {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// The dedicated worker thread could not be spawned.
|
||||
#[error("failed to spawn blocking managed worker thread: {source}")]
|
||||
ThreadSpawn {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Failure to obtain completion from a blocking managed client owner.
|
||||
#[derive(Debug, Error, Eq, PartialEq)]
|
||||
pub enum BlockingManagedJoinError {
|
||||
/// The dedicated managed worker thread panicked.
|
||||
#[error("blocking managed worker thread panicked")]
|
||||
WorkerPanicked,
|
||||
}
|
||||
|
||||
/// Blocking completion owner for one unified managed Tokio task.
|
||||
///
|
||||
/// This owner runs exactly one [`ManagedClient`] task on one dedicated OS thread
|
||||
/// with its own current-thread Tokio runtime. Dropping a live owner requests a
|
||||
/// graceful stop and synchronously joins that worker, so drop may block while
|
||||
/// bounded managed cleanup completes.
|
||||
#[must_use = "dropping the blocking managed owner requests stop and joins its worker"]
|
||||
pub struct BlockingManagedClient {
|
||||
handle: ManagedClientHandle,
|
||||
worker: Option<JoinHandle<ManagedOutcome>>,
|
||||
}
|
||||
|
||||
impl BlockingManagedClient {
|
||||
/// Validate configuration, create a dedicated runtime, and start its worker.
|
||||
pub fn start(
|
||||
config: ManagedClientConfig,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<Self, BlockingManagedStartError> {
|
||||
let (task, handle) = ManagedClient::task(config, targets)?;
|
||||
Self::start_task(task, handle)
|
||||
}
|
||||
|
||||
/// Validate configuration, subscribe to presentation events, and start a
|
||||
/// dedicated managed worker.
|
||||
///
|
||||
/// The returned subscription exists before the managed task receives its
|
||||
/// first poll, so it cannot miss startup events because of subscription
|
||||
/// ordering.
|
||||
pub fn start_with_subscription(
|
||||
config: ManagedClientConfig,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<(Self, ManagedEventSubscription), BlockingManagedStartError> {
|
||||
let (task, handle) = ManagedClient::task(config, targets)?;
|
||||
let subscription = handle.subscribe()?;
|
||||
let owner = Self::start_task(task, handle)?;
|
||||
Ok((owner, subscription))
|
||||
}
|
||||
|
||||
fn start_task(
|
||||
task: ManagedClientTask,
|
||||
handle: ManagedClientHandle,
|
||||
) -> Result<Self, BlockingManagedStartError> {
|
||||
let runtime = Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.map_err(|source| BlockingManagedStartError::Runtime { source })?;
|
||||
let runtime = WorkerRuntime::new(runtime);
|
||||
let worker = thread::Builder::new()
|
||||
.name("irtt-managed".to_owned())
|
||||
.spawn(move || {
|
||||
let outcome = runtime.block_on(task);
|
||||
runtime.shutdown();
|
||||
outcome
|
||||
})
|
||||
.map_err(|source| BlockingManagedStartError::ThreadSpawn { source })?;
|
||||
|
||||
Ok(Self {
|
||||
handle,
|
||||
worker: Some(worker),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a cloned managed control and observation capability.
|
||||
pub fn handle(&self) -> ManagedClientHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
|
||||
/// Wait synchronously for the managed task's authoritative outcome.
|
||||
pub fn join(mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
||||
self.join_worker()
|
||||
}
|
||||
|
||||
fn join_worker(&mut self) -> Result<ManagedOutcome, BlockingManagedJoinError> {
|
||||
self.worker
|
||||
.take()
|
||||
.expect("blocking managed worker is joined at most once")
|
||||
.join()
|
||||
.map_err(|_| BlockingManagedJoinError::WorkerPanicked)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BlockingManagedClient {
|
||||
fn drop(&mut self) {
|
||||
if self.worker.is_some() {
|
||||
let _receipt = self.handle.stop();
|
||||
let _ = self.join_worker();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CancellationToken {
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CancellationToken {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_starts_uncancelled() {
|
||||
assert!(!CancellationToken::new().is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_is_idempotent_and_visible_through_clones() {
|
||||
let token = CancellationToken::new();
|
||||
let clone = token.clone();
|
||||
|
||||
token.cancel();
|
||||
token.cancel();
|
||||
|
||||
assert!(token.is_cancelled());
|
||||
assert!(clone.is_cancelled());
|
||||
}
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Condvar, Mutex, Weak,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{error::EventSubscriptionError, event::ClientEvent};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SubscriberConfig {
|
||||
pub capacity: usize,
|
||||
pub overflow: SubscriberOverflow,
|
||||
}
|
||||
|
||||
impl Default for SubscriberConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
capacity: 1024,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overflow behavior for a bounded event subscription queue.
|
||||
///
|
||||
/// These policies are applied independently per subscriber when that
|
||||
/// subscriber's queue is full.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SubscriberOverflow {
|
||||
/// Leave the existing queue unchanged and discard the newly published event.
|
||||
DropNewest,
|
||||
/// Remove the oldest queued event and enqueue the newly published event.
|
||||
DropOldest,
|
||||
/// Disconnect the subscriber and clear its queue.
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventHub {
|
||||
inner: Arc<HubInner>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HubInner {
|
||||
next_id: AtomicU64,
|
||||
subscribers: Mutex<HashMap<u64, Arc<SubscriberInner>>>,
|
||||
}
|
||||
|
||||
/// Handle for receiving managed client events.
|
||||
///
|
||||
/// Each subscription has its own bounded queue configured by
|
||||
/// [`SubscriberConfig`]. The configured [`SubscriberOverflow`] policy applies
|
||||
/// when that queue is full. Dropping the handle unregisters the subscriber.
|
||||
#[must_use = "dropping the subscription unregisters it"]
|
||||
#[derive(Debug)]
|
||||
pub struct EventSubscription {
|
||||
id: u64,
|
||||
hub: Weak<HubInner>,
|
||||
inner: Arc<SubscriberInner>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SubscriberInner {
|
||||
state: Mutex<SubscriberState>,
|
||||
available: Condvar,
|
||||
config: SubscriberConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SubscriberState {
|
||||
queue: VecDeque<ClientEvent>,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
impl EventHub {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(HubInner {
|
||||
next_id: AtomicU64::new(1),
|
||||
subscribers: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
config: SubscriberConfig,
|
||||
) -> Result<EventSubscription, crate::ClientError> {
|
||||
if config.capacity == 0 {
|
||||
return Err(crate::ClientError::InvalidConfig {
|
||||
reason: "subscriber capacity must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let inner = Arc::new(SubscriberInner {
|
||||
state: Mutex::new(SubscriberState {
|
||||
queue: VecDeque::with_capacity(config.capacity),
|
||||
connected: true,
|
||||
}),
|
||||
available: Condvar::new(),
|
||||
config,
|
||||
});
|
||||
self.inner
|
||||
.subscribers
|
||||
.lock()
|
||||
.expect("event hub mutex poisoned")
|
||||
.insert(id, inner.clone());
|
||||
|
||||
Ok(EventSubscription {
|
||||
id,
|
||||
hub: Arc::downgrade(&self.inner),
|
||||
inner,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn publish(&self, event: ClientEvent) {
|
||||
let subscribers: Vec<(u64, Arc<SubscriberInner>)> = self
|
||||
.inner
|
||||
.subscribers
|
||||
.lock()
|
||||
.expect("event hub mutex poisoned")
|
||||
.iter()
|
||||
.map(|(id, subscriber)| (*id, subscriber.clone()))
|
||||
.collect();
|
||||
|
||||
let mut disconnected = Vec::new();
|
||||
for (id, subscriber) in subscribers {
|
||||
// Events are cloned per subscriber; bounded queues keep slow
|
||||
// consumers from causing unbounded memory growth.
|
||||
if !subscriber.publish(event.clone()) {
|
||||
disconnected.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if disconnected.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut subscribers = self
|
||||
.inner
|
||||
.subscribers
|
||||
.lock()
|
||||
.expect("event hub mutex poisoned");
|
||||
for id in disconnected {
|
||||
subscribers.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect_all(&self) {
|
||||
let subscribers: Vec<Arc<SubscriberInner>> = self
|
||||
.inner
|
||||
.subscribers
|
||||
.lock()
|
||||
.expect("event hub mutex poisoned")
|
||||
.drain()
|
||||
.map(|(_, subscriber)| subscriber)
|
||||
.collect();
|
||||
|
||||
for subscriber in subscribers {
|
||||
subscriber.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn subscriber_count(&self) -> usize {
|
||||
self.inner
|
||||
.subscribers
|
||||
.lock()
|
||||
.expect("event hub mutex poisoned")
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventHub {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSubscription {
|
||||
/// Block until the next queued event is available.
|
||||
///
|
||||
/// If the subscription is disconnected, this returns
|
||||
/// [`EventSubscriptionError::Disconnected`] after any already queued events
|
||||
/// have been drained.
|
||||
pub fn recv(&self) -> Result<ClientEvent, EventSubscriptionError> {
|
||||
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
|
||||
loop {
|
||||
if let Some(event) = state.queue.pop_front() {
|
||||
return Ok(event);
|
||||
}
|
||||
if !state.connected {
|
||||
return Err(EventSubscriptionError::Disconnected);
|
||||
}
|
||||
state = self
|
||||
.inner
|
||||
.available
|
||||
.wait(state)
|
||||
.expect("subscriber mutex poisoned");
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to receive one queued event without blocking.
|
||||
///
|
||||
/// Returns `Ok(None)` when the subscription is still connected but no event
|
||||
/// is queued. If the subscription is disconnected, this returns
|
||||
/// [`EventSubscriptionError::Disconnected`] after any already queued events
|
||||
/// have been drained.
|
||||
pub fn try_recv(&self) -> Result<Option<ClientEvent>, EventSubscriptionError> {
|
||||
let mut state = self.inner.state.lock().expect("subscriber mutex poisoned");
|
||||
if let Some(event) = state.queue.pop_front() {
|
||||
return Ok(Some(event));
|
||||
}
|
||||
if !state.connected {
|
||||
return Err(EventSubscriptionError::Disconnected);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EventSubscription {
|
||||
fn drop(&mut self) {
|
||||
self.inner.disconnect();
|
||||
if let Some(hub) = self.hub.upgrade() {
|
||||
hub.subscribers
|
||||
.lock()
|
||||
.expect("event hub mutex poisoned")
|
||||
.remove(&self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubscriberInner {
|
||||
fn publish(&self, event: ClientEvent) -> bool {
|
||||
let mut state = self.state.lock().expect("subscriber mutex poisoned");
|
||||
if !state.connected {
|
||||
return false;
|
||||
}
|
||||
|
||||
if state.queue.len() < self.config.capacity {
|
||||
state.queue.push_back(event);
|
||||
self.available.notify_one();
|
||||
return true;
|
||||
}
|
||||
|
||||
match self.config.overflow {
|
||||
SubscriberOverflow::DropNewest => true,
|
||||
SubscriberOverflow::DropOldest => {
|
||||
state.queue.pop_front();
|
||||
state.queue.push_back(event);
|
||||
self.available.notify_one();
|
||||
true
|
||||
}
|
||||
SubscriberOverflow::Disconnect => {
|
||||
state.queue.clear();
|
||||
state.connected = false;
|
||||
self.available.notify_all();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect(&self) {
|
||||
let mut state = self.state.lock().expect("subscriber mutex poisoned");
|
||||
state.connected = false;
|
||||
self.available.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ClientTimestamp, WarningKind};
|
||||
|
||||
fn event(n: usize) -> ClientEvent {
|
||||
ClientEvent::Warning {
|
||||
kind: WarningKind::UntrackedReply,
|
||||
message: format!("event-{n}"),
|
||||
at: ClientTimestamp::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_message(event: ClientEvent) -> String {
|
||||
match event {
|
||||
ClientEvent::Warning { message, .. } => message,
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_to_one_subscriber() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
|
||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publishes_to_multiple_subscribers() {
|
||||
let hub = EventHub::new();
|
||||
let a = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
let b = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
|
||||
assert_eq!(event_message(a.try_recv().unwrap().unwrap()), "event-1");
|
||||
assert_eq!(event_message(b.try_recv().unwrap().unwrap()), "event-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_newest_keeps_existing_events() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 1,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
hub.publish(event(2));
|
||||
|
||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-1");
|
||||
assert!(sub.try_recv().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_oldest_replaces_existing_events() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 1,
|
||||
overflow: SubscriberOverflow::DropOldest,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
hub.publish(event(2));
|
||||
|
||||
assert_eq!(event_message(sub.try_recv().unwrap().unwrap()), "event-2");
|
||||
assert!(sub.try_recv().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_removes_full_subscriber() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 1,
|
||||
overflow: SubscriberOverflow::Disconnect,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
hub.publish(event(2));
|
||||
|
||||
assert_eq!(
|
||||
sub.try_recv().unwrap_err(),
|
||||
EventSubscriptionError::Disconnected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_subscriber_does_not_prevent_other_delivery() {
|
||||
let hub = EventHub::new();
|
||||
let slow = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 1,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
})
|
||||
.unwrap();
|
||||
let fast = hub
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 4,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
hub.publish(event(1));
|
||||
hub.publish(event(2));
|
||||
|
||||
assert_eq!(event_message(slow.try_recv().unwrap().unwrap()), "event-1");
|
||||
assert_eq!(event_message(fast.try_recv().unwrap().unwrap()), "event-1");
|
||||
assert_eq!(event_message(fast.try_recv().unwrap().unwrap()), "event-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribing_after_events_does_not_replay() {
|
||||
let hub = EventHub::new();
|
||||
hub.publish(event(1));
|
||||
|
||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
|
||||
assert!(sub.try_recv().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_subscription_unregisters_from_hub() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
assert_eq!(hub.subscriber_count(), 1);
|
||||
|
||||
drop(sub);
|
||||
|
||||
assert_eq!(hub.subscriber_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_all_wakes_blocking_receivers() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
hub.disconnect_all();
|
||||
|
||||
assert_eq!(
|
||||
sub.recv().unwrap_err(),
|
||||
EventSubscriptionError::Disconnected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_closed_event_can_be_queued() {
|
||||
let hub = EventHub::new();
|
||||
let sub = hub.subscribe(SubscriberConfig::default()).unwrap();
|
||||
hub.publish(ClientEvent::SessionClosed {
|
||||
remote: "127.0.0.1:1".parse().unwrap(),
|
||||
token: 1,
|
||||
at: ClientTimestamp::now(),
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
sub.recv().unwrap(),
|
||||
ClientEvent::SessionClosed { token: 1, .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
mod cancellation;
|
||||
mod hub;
|
||||
mod runner;
|
||||
mod target_id;
|
||||
|
||||
pub use cancellation::CancellationToken;
|
||||
pub use hub::{EventHub, EventSubscription, SubscriberConfig, SubscriberOverflow};
|
||||
pub use runner::{ManagedClient, ManagedClientSession, SessionEndReason, SessionOutcome};
|
||||
pub use target_id::TargetId;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
mod blocking;
|
||||
#[cfg(feature = "tokio")]
|
||||
mod model;
|
||||
#[cfg(feature = "tokio")]
|
||||
mod task;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use blocking::*;
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use model::*;
|
||||
#[cfg(feature = "tokio")]
|
||||
pub use task::*;
|
||||
|
||||
#[cfg(feature = "tokio")]
|
||||
mod schedule;
|
||||
@@ -0,0 +1,497 @@
|
||||
use std::{fmt, net::SocketAddr, sync::Arc, time::Duration};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{Authentication, ClientConfig, ClientError, ClientEvent};
|
||||
|
||||
use super::TargetId;
|
||||
|
||||
/// Default capacity of the lossy managed event channel.
|
||||
pub const DEFAULT_MANAGED_EVENT_CAPACITY: usize = 256;
|
||||
/// Default capacity of the bounded managed control queue.
|
||||
pub const DEFAULT_MANAGED_COMMAND_CAPACITY: usize = 64;
|
||||
/// Default number of recent target outcomes retained in status and output.
|
||||
pub const DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT: usize = 256;
|
||||
/// Default maximum number of simultaneously live target generations.
|
||||
pub const DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS: usize = 256;
|
||||
/// Default time retained for late replies after the final committed timeout.
|
||||
pub const DEFAULT_MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
|
||||
|
||||
/// One globally allocated incarnation of a target within a managed task.
|
||||
///
|
||||
/// Generations increase globally across the task, not independently per ID.
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct TargetInstance {
|
||||
pub id: TargetId,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
/// Authentication specification for a managed target.
|
||||
///
|
||||
/// Equality preserves the specification: inheritance and an explicit override
|
||||
/// remain distinct even when they currently resolve to the same authentication.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub enum TargetAuth {
|
||||
/// Use the shared client configuration's authentication.
|
||||
#[default]
|
||||
Inherit,
|
||||
/// Replace shared authentication. `Unauthenticated` explicitly disables it.
|
||||
Override(Authentication),
|
||||
}
|
||||
|
||||
impl TargetAuth {
|
||||
pub(crate) fn resolve(&self, shared: &Authentication) -> Authentication {
|
||||
match self {
|
||||
Self::Inherit => shared.clone(),
|
||||
Self::Override(auth) => auth.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Endpoint and authentication specification for one managed target.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedTargetConfig {
|
||||
pub id: TargetId,
|
||||
pub server_addr: String,
|
||||
pub auth: TargetAuth,
|
||||
}
|
||||
|
||||
impl ManagedTargetConfig {
|
||||
pub fn new(id: impl Into<TargetId>, server_addr: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
server_addr: server_addr.into(),
|
||||
auth: TargetAuth::Inherit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coordination mode for sends across active targets.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum ManagedPacing {
|
||||
#[default]
|
||||
Staggered,
|
||||
Burst,
|
||||
}
|
||||
|
||||
/// Policy controlling top-level natural completion.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum ManagedCompletionPolicy {
|
||||
#[default]
|
||||
FinishWhenQuiescent,
|
||||
ExplicitStop,
|
||||
}
|
||||
|
||||
/// Shared configuration for one managed task.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedClientConfig {
|
||||
/// Reusable session/socket configuration shared across target generations.
|
||||
/// Each target supplies its endpoint and resolves its authentication policy.
|
||||
pub client: ClientConfig,
|
||||
pub pacing: ManagedPacing,
|
||||
pub completion: ManagedCompletionPolicy,
|
||||
pub event_capacity: usize,
|
||||
pub command_capacity: usize,
|
||||
pub outcome_history_limit: usize,
|
||||
pub max_live_target_generations: usize,
|
||||
pub final_drain: Duration,
|
||||
}
|
||||
|
||||
impl Default for ManagedClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
client: ClientConfig::default(),
|
||||
pacing: ManagedPacing::default(),
|
||||
completion: ManagedCompletionPolicy::default(),
|
||||
event_capacity: DEFAULT_MANAGED_EVENT_CAPACITY,
|
||||
command_capacity: DEFAULT_MANAGED_COMMAND_CAPACITY,
|
||||
outcome_history_limit: DEFAULT_MANAGED_OUTCOME_HISTORY_LIMIT,
|
||||
max_live_target_generations: DEFAULT_MANAGED_MAX_LIVE_TARGET_GENERATIONS,
|
||||
final_drain: DEFAULT_MANAGED_FINAL_DRAIN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable top-level lifecycle.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedLifecycle {
|
||||
NotStarted,
|
||||
Running,
|
||||
Stopping,
|
||||
Completed,
|
||||
Failed,
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
/// Durable lifecycle for one target generation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedTargetLifecycle {
|
||||
Pending,
|
||||
Connecting,
|
||||
Opening,
|
||||
Active,
|
||||
Draining,
|
||||
Closing,
|
||||
Terminal,
|
||||
}
|
||||
|
||||
/// Status for one target generation.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedTargetStatus {
|
||||
pub target: TargetInstance,
|
||||
pub desired: bool,
|
||||
pub lifecycle: ManagedTargetLifecycle,
|
||||
/// Authoritative completed accounting for this target generation.
|
||||
///
|
||||
/// Present if and only if `lifecycle` is [`ManagedTargetLifecycle::Terminal`].
|
||||
/// This shares the terminal phase's outcome with [`ManagedEvent::TargetFinished`],
|
||||
/// so terminal details remain durable even when presentation events are lost.
|
||||
pub outcome: Option<Arc<ManagedTargetOutcome>>,
|
||||
pub server_addr: Arc<str>,
|
||||
pub remote: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
/// Authoritative immutable managed status snapshot.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedStatus {
|
||||
pub lifecycle: ManagedLifecycle,
|
||||
/// Whether the task durably observed an explicit stop request.
|
||||
///
|
||||
/// This remains false until the task observes the request latch. Natural
|
||||
/// stopping and terminality alone do not imply a stop request was observed.
|
||||
pub stop_requested: bool,
|
||||
pub applied_command_sequence: u64,
|
||||
pub desired_target_count: usize,
|
||||
pub connecting_target_count: usize,
|
||||
pub opening_target_count: usize,
|
||||
pub active_target_count: usize,
|
||||
pub draining_target_count: usize,
|
||||
pub closing_target_count: usize,
|
||||
pub terminal_target_count: usize,
|
||||
pub total_target_outcomes: u64,
|
||||
pub successful_target_outcomes: u64,
|
||||
pub failed_target_outcomes: u64,
|
||||
pub peer_closed_target_outcomes: u64,
|
||||
pub discarded_target_outcomes: u64,
|
||||
pub targets: Arc<[ManagedTargetStatus]>,
|
||||
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
|
||||
/// Authoritative final accounting, present if and only if `lifecycle` is
|
||||
/// [`ManagedLifecycle::Completed`] or [`ManagedLifecycle::Failed`].
|
||||
///
|
||||
/// Shares the terminal task phase's outcome with [`ManagedEvent::Completed`]
|
||||
/// or [`ManagedEvent::Failed`]. Abandoned deliberately has no outcome.
|
||||
pub final_outcome: Option<Arc<ManagedOutcome>>,
|
||||
}
|
||||
|
||||
/// Receiving half of authoritative durable latest-state observation.
|
||||
///
|
||||
/// The current snapshot is immediately readable. Updates may coalesce, and the
|
||||
/// final snapshot remains available after the sender closes at task termination.
|
||||
/// Use [`ManagedEventSubscription`] for lossy presentation events.
|
||||
pub type ManagedStatusSubscription = tokio::sync::watch::Receiver<Arc<ManagedStatus>>;
|
||||
|
||||
/// Lossy presentation event emitted by the managed task.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedEvent {
|
||||
Started,
|
||||
TargetStateChanged {
|
||||
target: TargetInstance,
|
||||
lifecycle: ManagedTargetLifecycle,
|
||||
},
|
||||
Client {
|
||||
target: TargetInstance,
|
||||
event: ClientEvent,
|
||||
},
|
||||
TargetFinished {
|
||||
outcome: Arc<ManagedTargetOutcome>,
|
||||
},
|
||||
Stopping,
|
||||
Completed {
|
||||
outcome: Arc<ManagedOutcome>,
|
||||
},
|
||||
Failed {
|
||||
outcome: Arc<ManagedOutcome>,
|
||||
},
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
/// Receiving half of the lossy managed event stream.
|
||||
pub type ManagedEventSubscription = tokio::sync::broadcast::Receiver<ManagedEvent>;
|
||||
|
||||
/// Immediate result of attempting to receive a managed presentation event.
|
||||
pub type ManagedEventTryRecvError = tokio::sync::broadcast::error::TryRecvError;
|
||||
|
||||
/// Final authoritative outcome returned by the task.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedOutcome {
|
||||
pub end_reason: ManagedEndReason,
|
||||
pub applied_command_sequence: u64,
|
||||
pub total_target_outcomes: u64,
|
||||
pub successful_target_outcomes: u64,
|
||||
pub failed_target_outcomes: u64,
|
||||
pub peer_closed_target_outcomes: u64,
|
||||
pub discarded_target_outcomes: u64,
|
||||
pub recent_target_outcomes: Arc<[ManagedTargetOutcome]>,
|
||||
}
|
||||
|
||||
/// Why the managed task ended.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedEndReason {
|
||||
TargetsComplete,
|
||||
StopRequested,
|
||||
DriverFailed(ManagedDriverFailure),
|
||||
}
|
||||
|
||||
/// Durable outcome for one target generation.
|
||||
///
|
||||
/// These counters are durable: unlike [`ManagedEvent`], which is a lossy
|
||||
/// presentation stream, they account for everything the target observed.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedTargetOutcome {
|
||||
pub target: TargetInstance,
|
||||
pub server_addr: Arc<str>,
|
||||
pub remote: Option<SocketAddr>,
|
||||
pub end_reason: ManagedTargetEndReason,
|
||||
/// Probes the underlying client reported as sent.
|
||||
pub packets_sent: u64,
|
||||
/// Unique echo replies that arrived in order and before their probe timed
|
||||
/// out.
|
||||
///
|
||||
/// A unique reply that arrives behind an already-received sequence, or
|
||||
/// after its own probe timed out, is counted by `late` instead.
|
||||
pub replies_received: u64,
|
||||
/// Replies for a sequence that had already been answered.
|
||||
pub duplicates: u64,
|
||||
/// Late replies: those arriving behind an already-received sequence
|
||||
/// (reordering) or after their own probe timed out.
|
||||
///
|
||||
/// This counts every late reply, whether or not the client could still
|
||||
/// match it to retained send state.
|
||||
pub late: u64,
|
||||
/// Warning events observed for this target.
|
||||
pub warning_events: u64,
|
||||
pub cleanup_failure: Option<ManagedTargetFailure>,
|
||||
}
|
||||
|
||||
/// Why one target generation ended.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedTargetEndReason {
|
||||
TestComplete,
|
||||
NoTestComplete,
|
||||
PeerClosed,
|
||||
Removed,
|
||||
Replaced,
|
||||
Stopped,
|
||||
Failed(ManagedTargetFailure),
|
||||
}
|
||||
|
||||
/// Phase in which a target-local failure occurred.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedTargetFailurePhase {
|
||||
Connecting,
|
||||
Opening,
|
||||
Sending,
|
||||
Receiving,
|
||||
Timing,
|
||||
Closing,
|
||||
}
|
||||
|
||||
impl fmt::Display for ManagedTargetFailurePhase {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let text = match self {
|
||||
Self::Connecting => "connecting",
|
||||
Self::Opening => "opening",
|
||||
Self::Sending => "sending",
|
||||
Self::Receiving => "receiving",
|
||||
Self::Timing => "timing",
|
||||
Self::Closing => "closing",
|
||||
};
|
||||
f.write_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable category for a target-local failure.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ManagedTargetFailureKind {
|
||||
Resolve,
|
||||
Socket,
|
||||
SocketOption,
|
||||
Protocol,
|
||||
Timeout,
|
||||
Configuration,
|
||||
ResourceExhausted,
|
||||
InvalidState,
|
||||
/// A failure that deliberately has no more specific category.
|
||||
///
|
||||
/// This is a classification decision, not a fallback: every current
|
||||
/// [`ClientError`] variant is classified explicitly, so nothing reaches
|
||||
/// this category by default.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl fmt::Display for ManagedTargetFailureKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let text = match self {
|
||||
Self::Resolve => "resolve",
|
||||
Self::Socket => "socket",
|
||||
Self::SocketOption => "socket option",
|
||||
Self::Protocol => "protocol",
|
||||
Self::Timeout => "timeout",
|
||||
Self::Configuration => "configuration",
|
||||
Self::ResourceExhausted => "resource exhausted",
|
||||
Self::InvalidState => "invalid state",
|
||||
Self::Other => "other",
|
||||
};
|
||||
f.write_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Durable target-local failure details.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedTargetFailure {
|
||||
pub phase: ManagedTargetFailurePhase,
|
||||
pub kind: ManagedTargetFailureKind,
|
||||
pub message: Arc<str>,
|
||||
}
|
||||
|
||||
/// Configuration failure detected before any runtime work begins.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManagedConfigError {
|
||||
#[error("FinishWhenQuiescent requires at least one initial target")]
|
||||
EmptyInitialTargets,
|
||||
#[error("duplicate managed target id {id}")]
|
||||
DuplicateTargetId { id: TargetId },
|
||||
#[error("configured {configured} targets exceeds live-generation limit {limit}")]
|
||||
TooManyTargets { configured: usize, limit: usize },
|
||||
#[error("managed event capacity must be greater than zero")]
|
||||
ZeroEventCapacity,
|
||||
#[error("managed event capacity {configured} exceeds Tokio's maximum {maximum}")]
|
||||
EventCapacityTooLarge { configured: usize, maximum: usize },
|
||||
#[error("managed command capacity must be greater than zero")]
|
||||
ZeroCommandCapacity,
|
||||
#[error("managed command capacity {configured} exceeds Tokio's maximum {maximum}")]
|
||||
CommandCapacityTooLarge { configured: usize, maximum: usize },
|
||||
#[error("managed live-generation limit must be greater than zero")]
|
||||
ZeroLiveGenerationLimit,
|
||||
#[error("managed final drain {duration:?} cannot be scheduled from the current instant")]
|
||||
UnschedulableFinalDrain { duration: Duration },
|
||||
#[error("invalid managed target {id}: {source}")]
|
||||
InvalidTarget {
|
||||
id: TargetId,
|
||||
#[source]
|
||||
source: ClientError,
|
||||
},
|
||||
#[error("managed target generation space is exhausted")]
|
||||
GenerationExhausted,
|
||||
}
|
||||
|
||||
/// Immediate failure to submit a managed target update.
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub enum ManagedCommandError {
|
||||
#[error("managed target updates are no longer accepted")]
|
||||
Stopping,
|
||||
#[error("managed target update contains {configured} targets but the limit is {limit}")]
|
||||
TooManyTargets { configured: usize, limit: usize },
|
||||
#[error("managed command queue is full")]
|
||||
QueueFull,
|
||||
#[error("managed command receiver is closed")]
|
||||
DriverClosed,
|
||||
}
|
||||
|
||||
/// Failure while applying an accepted managed target update.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManagedCommandApplyError {
|
||||
#[error("managed target updates are no longer accepted")]
|
||||
Stopping,
|
||||
#[error("duplicate target id {id}")]
|
||||
DuplicateTargetId { id: TargetId },
|
||||
#[error("invalid configuration for target {id}: {source}")]
|
||||
InvalidTarget {
|
||||
id: TargetId,
|
||||
#[source]
|
||||
source: ClientError,
|
||||
},
|
||||
#[error("target update requires {required} live generations but limit is {limit}")]
|
||||
LiveGenerationLimitExceeded { required: usize, limit: usize },
|
||||
#[error("managed target generation space is exhausted")]
|
||||
GenerationExhausted,
|
||||
#[error("managed command sequence is exhausted")]
|
||||
CommandSequenceExhausted,
|
||||
#[error("managed task ended before command acknowledgement")]
|
||||
AcknowledgementDisconnected,
|
||||
#[error("managed driver failed before command application: {0}")]
|
||||
DriverFailed(ManagedDriverFailure),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ManagedCommandAcknowledgement {
|
||||
pub sequence: u64,
|
||||
pub status: Arc<ManagedStatus>,
|
||||
}
|
||||
|
||||
/// Failure to create a new event subscription.
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub enum ManagedSubscribeError {
|
||||
#[error("managed event stream is closed")]
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Whole-driver failure.
|
||||
#[derive(Clone, Debug, Eq, Error, PartialEq)]
|
||||
pub enum ManagedDriverFailure {
|
||||
#[error("ManagedClientTask requires a current Tokio runtime")]
|
||||
NoTokioRuntime,
|
||||
#[error("managed driver exhausted resources for {operation}")]
|
||||
ResourceExhausted { operation: &'static str },
|
||||
#[error("managed driver invariant failed: {message}")]
|
||||
Internal { message: Arc<str> },
|
||||
}
|
||||
|
||||
/// Map a [`ClientError`] onto its durable managed failure classification.
|
||||
///
|
||||
/// The match is deliberately exhaustive over every [`ClientError`] variant. A
|
||||
/// new variant must fail to compile here until its real classification is
|
||||
/// chosen, rather than silently degrading to
|
||||
/// [`ManagedTargetFailureKind::Other`].
|
||||
pub(crate) fn classify_client_error(
|
||||
phase: ManagedTargetFailurePhase,
|
||||
error: &ClientError,
|
||||
) -> ManagedTargetFailure {
|
||||
let kind = match error {
|
||||
ClientError::Resolve { .. } => ManagedTargetFailureKind::Resolve,
|
||||
ClientError::Socket(_) => ManagedTargetFailureKind::Socket,
|
||||
ClientError::SocketOption { .. } | ClientError::ReadTimeoutRestore { .. } => {
|
||||
ManagedTargetFailureKind::SocketOption
|
||||
}
|
||||
ClientError::Protocol(_)
|
||||
| ClientError::ProtocolVersionMismatch { .. }
|
||||
| ClientError::ZeroToken
|
||||
| ClientError::UnexpectedNoTestReply
|
||||
| ClientError::NonZeroNoTestToken { .. }
|
||||
| ClientError::ServerRejected
|
||||
| ClientError::NegotiationRejected { .. } => ManagedTargetFailureKind::Protocol,
|
||||
ClientError::OpenTimeout => ManagedTargetFailureKind::Timeout,
|
||||
ClientError::InvalidConfig { .. }
|
||||
| ClientError::OpenTimeoutTooSmall { .. }
|
||||
| ClientError::NoOpenTimeouts => ManagedTargetFailureKind::Configuration,
|
||||
ClientError::AllocationFailed { .. }
|
||||
| ClientError::CounterOverflow { .. }
|
||||
| ClientError::DurationOverflow
|
||||
| ClientError::PendingLimitExceeded { .. } => ManagedTargetFailureKind::ResourceExhausted,
|
||||
ClientError::DatagramLengthMismatch { .. } => ManagedTargetFailureKind::Socket,
|
||||
#[cfg(feature = "tokio")]
|
||||
ClientError::NoTokioRuntime => ManagedTargetFailureKind::InvalidState,
|
||||
ClientError::NotOpen
|
||||
| ClientError::AlreadyOpen
|
||||
| ClientError::AlreadyCompleted
|
||||
| ClientError::AlreadyClosed
|
||||
| ClientError::StalePreparedProbe { .. }
|
||||
| ClientError::PendingSequenceCollision { .. } => ManagedTargetFailureKind::InvalidState,
|
||||
};
|
||||
ManagedTargetFailure {
|
||||
phase,
|
||||
kind,
|
||||
message: Arc::from(error.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,691 +0,0 @@
|
||||
use std::{
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, RecvBudget},
|
||||
error::ClientError,
|
||||
event::{ClientEvent, OpenOutcome},
|
||||
Client,
|
||||
};
|
||||
|
||||
use super::{
|
||||
cancellation::CancellationToken,
|
||||
hub::{EventHub, EventSubscription, SubscriberConfig},
|
||||
};
|
||||
|
||||
const MANAGED_RECV_TIMEOUT: Duration = Duration::from_millis(20);
|
||||
const MANAGED_RECV_BUDGET: RecvBudget = RecvBudget { max_packets: 64 };
|
||||
const MANAGED_FINAL_DRAIN: Duration = Duration::from_millis(100);
|
||||
const IDLE_SLEEP: Duration = Duration::from_millis(1);
|
||||
const MAX_SLEEP: Duration = Duration::from_millis(20);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedClient;
|
||||
|
||||
/// Running managed client session.
|
||||
///
|
||||
/// [`ManagedClientSession::join`] waits for the worker and returns the session
|
||||
/// outcome or client error. Dropping the session requests cooperative
|
||||
/// cancellation; callers that need the outcome should explicitly join instead
|
||||
/// of relying on drop.
|
||||
#[must_use = "dropping the session cancels the managed client; call join() to wait for completion"]
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedClientSession {
|
||||
hub: EventHub,
|
||||
cancellation: CancellationToken,
|
||||
worker: Option<JoinHandle<Result<SessionOutcome, ClientError>>>,
|
||||
}
|
||||
|
||||
/// Outcome returned by a completed managed client session.
|
||||
///
|
||||
/// These are runner-level lifecycle counters, not statistical summaries. Use
|
||||
/// `irtt-stats` with emitted `ClientEvent` values for RTT, loss, IPDV, and
|
||||
/// related summaries.
|
||||
#[must_use = "managed session outcomes contain completion status and counters"]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionOutcome {
|
||||
pub end_reason: SessionEndReason,
|
||||
pub packets_sent: u64,
|
||||
pub replies_received: u64,
|
||||
pub duplicates: u64,
|
||||
pub late: u64,
|
||||
pub warning_events: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionEndReason {
|
||||
TestComplete,
|
||||
Cancelled,
|
||||
NoTestComplete,
|
||||
}
|
||||
|
||||
impl ManagedClient {
|
||||
pub fn start(config: ClientConfig) -> Result<ManagedClientSession, ClientError> {
|
||||
Self::start_inner(config, None).map(|(session, _)| session)
|
||||
}
|
||||
|
||||
pub fn start_with_subscription(
|
||||
config: ClientConfig,
|
||||
subscriber_config: SubscriberConfig,
|
||||
) -> Result<(ManagedClientSession, EventSubscription), ClientError> {
|
||||
let (session, subscription) = Self::start_inner(config, Some(subscriber_config))?;
|
||||
Ok((
|
||||
session,
|
||||
subscription.expect("initial subscription must be present"),
|
||||
))
|
||||
}
|
||||
|
||||
fn start_inner(
|
||||
mut config: ClientConfig,
|
||||
subscriber_config: Option<SubscriberConfig>,
|
||||
) -> Result<(ManagedClientSession, Option<EventSubscription>), ClientError> {
|
||||
if config.socket_config.recv_timeout.is_none()
|
||||
|| config.socket_config.recv_timeout > Some(MANAGED_RECV_TIMEOUT)
|
||||
{
|
||||
config.socket_config.recv_timeout = Some(MANAGED_RECV_TIMEOUT);
|
||||
}
|
||||
|
||||
let hub = EventHub::new();
|
||||
let initial_subscription = subscriber_config
|
||||
.map(|config| hub.subscribe(config))
|
||||
.transpose()?;
|
||||
|
||||
let mut client = Client::connect(config)?;
|
||||
let outcome = client.open()?;
|
||||
publish_open_outcome(&hub, &outcome);
|
||||
|
||||
let cancellation = CancellationToken::new();
|
||||
let worker_hub = hub.clone();
|
||||
let worker_cancellation = cancellation.clone();
|
||||
let worker =
|
||||
thread::spawn(move || run_client_with_cleanup(client, worker_hub, worker_cancellation));
|
||||
|
||||
Ok((
|
||||
ManagedClientSession {
|
||||
hub,
|
||||
cancellation,
|
||||
worker: Some(worker),
|
||||
},
|
||||
initial_subscription,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedClientSession {
|
||||
pub fn subscribe(&self, config: SubscriberConfig) -> Result<EventSubscription, ClientError> {
|
||||
self.hub.subscribe(config)
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
|
||||
pub fn join(mut self) -> Result<SessionOutcome, ClientError> {
|
||||
let worker = self
|
||||
.worker
|
||||
.take()
|
||||
.expect("ManagedClientSession invariant violated: worker handle missing before join");
|
||||
match worker.join() {
|
||||
Ok(outcome) => {
|
||||
self.hub.disconnect_all();
|
||||
outcome
|
||||
}
|
||||
Err(_) => {
|
||||
self.hub.disconnect_all();
|
||||
Err(ClientError::WorkerPanicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedClientSession {
|
||||
fn drop(&mut self) {
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_open_outcome(hub: &EventHub, outcome: &OpenOutcome) {
|
||||
match outcome {
|
||||
OpenOutcome::Started { event, .. } | OpenOutcome::NoTestCompleted { event, .. } => {
|
||||
hub.publish(event.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_client(
|
||||
mut client: Client,
|
||||
hub: EventHub,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<SessionOutcome, ClientError> {
|
||||
if client.is_run_complete() {
|
||||
return Ok(SessionOutcome {
|
||||
end_reason: SessionEndReason::NoTestComplete,
|
||||
packets_sent: 0,
|
||||
replies_received: 0,
|
||||
duplicates: 0,
|
||||
late: 0,
|
||||
warning_events: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let mut counters = OutcomeCounters::default();
|
||||
let mut cancelled = false;
|
||||
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
cancelled = true;
|
||||
publish_events(
|
||||
&hub,
|
||||
&mut counters,
|
||||
client.recv_available(MANAGED_RECV_BUDGET)?,
|
||||
);
|
||||
publish_events(&hub, &mut counters, client.poll_timeouts()?);
|
||||
break;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if client
|
||||
.next_send_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
{
|
||||
let events = client.send_probe()?;
|
||||
publish_events(&hub, &mut counters, events);
|
||||
continue;
|
||||
}
|
||||
|
||||
publish_events(
|
||||
&hub,
|
||||
&mut counters,
|
||||
client.recv_available(MANAGED_RECV_BUDGET)?,
|
||||
);
|
||||
publish_events(&hub, &mut counters, client.poll_timeouts()?);
|
||||
|
||||
if client.is_run_complete() {
|
||||
break;
|
||||
}
|
||||
|
||||
sleep_until_next_wakeup(client.next_send_deadline());
|
||||
}
|
||||
|
||||
if !cancelled {
|
||||
drain_final_late_replies(&mut client, &hub, &mut counters)?;
|
||||
}
|
||||
|
||||
let packets_sent = client.packets_sent();
|
||||
let close_events = client.close()?;
|
||||
publish_events(&hub, &mut counters, close_events);
|
||||
|
||||
Ok(SessionOutcome {
|
||||
end_reason: if cancelled {
|
||||
SessionEndReason::Cancelled
|
||||
} else {
|
||||
SessionEndReason::TestComplete
|
||||
},
|
||||
packets_sent,
|
||||
replies_received: counters.replies_received,
|
||||
duplicates: counters.duplicates,
|
||||
late: counters.late,
|
||||
warning_events: counters.warning_events,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_client_with_cleanup(
|
||||
client: Client,
|
||||
hub: EventHub,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<SessionOutcome, ClientError> {
|
||||
let outcome = run_client(client, hub.clone(), cancellation);
|
||||
hub.disconnect_all();
|
||||
outcome
|
||||
}
|
||||
|
||||
fn drain_final_late_replies(
|
||||
client: &mut Client,
|
||||
hub: &EventHub,
|
||||
counters: &mut OutcomeCounters,
|
||||
) -> Result<(), ClientError> {
|
||||
if !client.has_timed_out_metadata() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + MANAGED_FINAL_DRAIN;
|
||||
while Instant::now() < deadline && client.has_timed_out_metadata() {
|
||||
let mut published = false;
|
||||
|
||||
let events = client.recv_available(MANAGED_RECV_BUDGET)?;
|
||||
published |= !events.is_empty();
|
||||
publish_events(hub, counters, events);
|
||||
|
||||
let events = client.poll_timeouts()?;
|
||||
published |= !events.is_empty();
|
||||
publish_events(hub, counters, events);
|
||||
|
||||
if !published {
|
||||
thread::sleep(IDLE_SLEEP);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_events(hub: &EventHub, counters: &mut OutcomeCounters, events: Vec<ClientEvent>) {
|
||||
for event in events {
|
||||
counters.observe(&event);
|
||||
hub.publish(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn sleep_until_next_wakeup(deadline: Option<Instant>) {
|
||||
let sleep_for = deadline
|
||||
.and_then(|deadline| deadline.checked_duration_since(Instant::now()))
|
||||
.map(|duration| duration.min(MAX_SLEEP))
|
||||
.unwrap_or(IDLE_SLEEP);
|
||||
if sleep_for > Duration::ZERO {
|
||||
thread::sleep(sleep_for);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct OutcomeCounters {
|
||||
replies_received: u64,
|
||||
duplicates: u64,
|
||||
late: u64,
|
||||
warning_events: u64,
|
||||
}
|
||||
|
||||
impl OutcomeCounters {
|
||||
fn observe(&mut self, event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::EchoReply { .. } => self.replies_received += 1,
|
||||
ClientEvent::DuplicateReply { .. } => self.duplicates += 1,
|
||||
ClientEvent::LateReply { .. } => self.late += 1,
|
||||
ClientEvent::Warning { .. } => self.warning_events += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{config::NegotiationPolicy, SubscriberOverflow};
|
||||
use irtt_proto::{
|
||||
echo_packet_len, flags, flags::FLAG_OPEN, flags::FLAG_REPLY, layout::PacketLayout, Clock,
|
||||
Params, ReceivedStats, StampAt, TimestampFields, MAGIC, PROTOCOL_VERSION,
|
||||
};
|
||||
use std::{
|
||||
net::{SocketAddr, UdpSocket},
|
||||
sync::mpsc,
|
||||
};
|
||||
|
||||
const TOKEN: u64 = 0x1234_5678_90ab_cdef;
|
||||
struct FakeServer {
|
||||
addr: SocketAddr,
|
||||
done: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl FakeServer {
|
||||
fn join(self) {
|
||||
self.done.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn test_params(duration: Option<Duration>, interval: Duration) -> Params {
|
||||
Params {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
duration_ns: duration.map_or(0, test_duration_ns_i64),
|
||||
interval_ns: test_duration_ns_i64(interval),
|
||||
length: 0,
|
||||
received_stats: ReceivedStats::Both,
|
||||
stamp_at: StampAt::Both,
|
||||
clock: Clock::Both,
|
||||
dscp: 0,
|
||||
server_fill: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_duration_ns_i64(duration: Duration) -> i64 {
|
||||
i64::try_from(duration.as_nanos()).expect("test duration fits i64 nanoseconds")
|
||||
}
|
||||
|
||||
fn config(addr: SocketAddr, duration: Option<Duration>) -> ClientConfig {
|
||||
ClientConfig {
|
||||
server_addr: addr.to_string(),
|
||||
duration,
|
||||
interval: Duration::from_millis(10),
|
||||
negotiation_policy: NegotiationPolicy::Strict,
|
||||
open_timeouts: vec![Duration::from_millis(200)],
|
||||
probe_timeout: Duration::from_millis(40),
|
||||
..ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn start_echo_server(params: Params) -> FakeServer {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let done = thread::spawn(move || {
|
||||
let (_, peer) = recv_request(&socket);
|
||||
socket
|
||||
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms), peer)
|
||||
.unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(500)))
|
||||
.unwrap();
|
||||
|
||||
while let Some((packet, peer)) = recv_request_timeout(&socket) {
|
||||
if packet[3] & flags::FLAG_CLOSE != 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(1_000_000_000),
|
||||
recv_mono: Some(100_000),
|
||||
send_wall: Some(1_000_000_000),
|
||||
send_mono: Some(100_000),
|
||||
..Default::default()
|
||||
};
|
||||
socket
|
||||
.send_to(&echo_reply_packet(TOKEN, seq, ¶ms, &ts), peer)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
FakeServer { addr, done }
|
||||
}
|
||||
|
||||
fn start_delayed_reply_server(params: Params, delay: Duration) -> FakeServer {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let done = thread::spawn(move || {
|
||||
let (_, peer) = recv_request(&socket);
|
||||
socket
|
||||
.send_to(&open_reply(FLAG_OPEN | FLAG_REPLY, TOKEN, ¶ms), peer)
|
||||
.unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(500)))
|
||||
.unwrap();
|
||||
|
||||
let Some((packet, peer)) = recv_request_timeout(&socket) else {
|
||||
return;
|
||||
};
|
||||
let seq = u32::from_le_bytes(packet[12..16].try_into().unwrap());
|
||||
thread::sleep(delay);
|
||||
let ts = TimestampFields {
|
||||
recv_wall: Some(1_000_000_000),
|
||||
recv_mono: Some(100_000),
|
||||
send_wall: Some(1_001_000_000),
|
||||
send_mono: Some(1_100_000),
|
||||
..Default::default()
|
||||
};
|
||||
socket
|
||||
.send_to(&echo_reply_packet(TOKEN, seq, ¶ms, &ts), peer)
|
||||
.unwrap();
|
||||
|
||||
while let Some((packet, _)) = recv_request_timeout(&socket) {
|
||||
if packet[3] & flags::FLAG_CLOSE != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
FakeServer { addr, done }
|
||||
}
|
||||
|
||||
fn recv_request(socket: &UdpSocket) -> (Vec<u8>, SocketAddr) {
|
||||
let mut buf = [0_u8; 2048];
|
||||
let (size, peer) = socket.recv_from(&mut buf).unwrap();
|
||||
(buf[..size].to_vec(), peer)
|
||||
}
|
||||
|
||||
fn recv_request_timeout(socket: &UdpSocket) -> Option<(Vec<u8>, SocketAddr)> {
|
||||
let mut buf = [0_u8; 2048];
|
||||
socket
|
||||
.recv_from(&mut buf)
|
||||
.ok()
|
||||
.map(|(size, peer)| (buf[..size].to_vec(), peer))
|
||||
}
|
||||
|
||||
fn open_reply(flags: u8, token: u64, params: &Params) -> Vec<u8> {
|
||||
let mut packet = Vec::new();
|
||||
packet.extend_from_slice(&MAGIC);
|
||||
packet.push(flags);
|
||||
packet.extend_from_slice(&token.to_le_bytes());
|
||||
packet.extend_from_slice(¶ms.encode());
|
||||
packet
|
||||
}
|
||||
|
||||
fn echo_reply_packet(
|
||||
token: u64,
|
||||
seq: u32,
|
||||
params: &Params,
|
||||
timestamps: &TimestampFields,
|
||||
) -> Vec<u8> {
|
||||
let layout = PacketLayout::echo(false, params);
|
||||
let packet_len = echo_packet_len(false, params);
|
||||
let mut packet = Vec::with_capacity(packet_len);
|
||||
|
||||
packet.extend_from_slice(&MAGIC);
|
||||
packet.push(FLAG_REPLY);
|
||||
packet.extend_from_slice(&token.to_le_bytes());
|
||||
packet.extend_from_slice(&seq.to_le_bytes());
|
||||
|
||||
if layout.recv_count {
|
||||
packet.extend_from_slice(&42_u32.to_le_bytes());
|
||||
}
|
||||
if layout.recv_window {
|
||||
packet.extend_from_slice(&0x07_u64.to_le_bytes());
|
||||
}
|
||||
if layout.recv_wall {
|
||||
packet.extend_from_slice(×tamps.recv_wall.unwrap_or(0).to_le_bytes());
|
||||
}
|
||||
if layout.recv_mono {
|
||||
packet.extend_from_slice(×tamps.recv_mono.unwrap_or(0).to_le_bytes());
|
||||
}
|
||||
if layout.midpoint_wall {
|
||||
packet.extend_from_slice(×tamps.midpoint_wall.unwrap_or(0).to_le_bytes());
|
||||
}
|
||||
if layout.midpoint_mono {
|
||||
packet.extend_from_slice(×tamps.midpoint_mono.unwrap_or(0).to_le_bytes());
|
||||
}
|
||||
if layout.send_wall {
|
||||
packet.extend_from_slice(×tamps.send_wall.unwrap_or(0).to_le_bytes());
|
||||
}
|
||||
if layout.send_mono {
|
||||
packet.extend_from_slice(×tamps.send_mono.unwrap_or(0).to_le_bytes());
|
||||
}
|
||||
|
||||
packet.resize(packet_len, 0);
|
||||
packet
|
||||
}
|
||||
|
||||
fn recv_event_with_timeout(sub: &EventSubscription) -> ClientEvent {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
match sub.try_recv() {
|
||||
Ok(Some(event)) => return event,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Ok(None) => panic!("timed out waiting for managed client event"),
|
||||
Err(err) => panic!("subscription ended while waiting for event: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_until_closed(sub: &EventSubscription) -> Vec<ClientEvent> {
|
||||
let mut events = Vec::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
match sub.try_recv() {
|
||||
Ok(Some(event)) => {
|
||||
let closed = matches!(event, ClientEvent::SessionClosed { .. });
|
||||
events.push(event);
|
||||
if closed {
|
||||
return events;
|
||||
}
|
||||
}
|
||||
Ok(None) => thread::sleep(Duration::from_millis(1)),
|
||||
Err(_) => return events,
|
||||
}
|
||||
}
|
||||
panic!("timed out waiting for session close event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_is_idempotent() {
|
||||
let server = start_echo_server(test_params(None, Duration::from_millis(10)));
|
||||
let session = ManagedClient::start(config(server.addr, None)).unwrap();
|
||||
session.stop();
|
||||
session.stop();
|
||||
let outcome = session.join().unwrap();
|
||||
server.join();
|
||||
|
||||
assert_eq!(outcome.end_reason, SessionEndReason::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_managed_run_emits_session_probe_and_close_events() {
|
||||
let duration = Duration::from_millis(35);
|
||||
let server = start_echo_server(test_params(Some(duration), Duration::from_millis(10)));
|
||||
let (session, sub) = ManagedClient::start_with_subscription(
|
||||
config(server.addr, Some(duration)),
|
||||
SubscriberConfig {
|
||||
capacity: 16,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let events = collect_until_closed(&sub);
|
||||
let outcome = session.join().unwrap();
|
||||
server.join();
|
||||
|
||||
assert_eq!(outcome.end_reason, SessionEndReason::TestComplete);
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ClientEvent::SessionStarted { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ClientEvent::EchoReply { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_managed_run_drains_late_reply_after_timeout_before_close() {
|
||||
let duration = Duration::from_millis(1);
|
||||
let params = test_params(Some(duration), Duration::from_millis(10));
|
||||
let server = start_delayed_reply_server(params, Duration::from_millis(60));
|
||||
let mut cfg = config(server.addr, Some(duration));
|
||||
cfg.probe_timeout = Duration::from_millis(20);
|
||||
|
||||
let (session, sub) = ManagedClient::start_with_subscription(
|
||||
cfg,
|
||||
SubscriberConfig {
|
||||
capacity: 16,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let events = collect_until_closed(&sub);
|
||||
let outcome = session.join().unwrap();
|
||||
server.join();
|
||||
|
||||
assert_eq!(outcome.end_reason, SessionEndReason::TestComplete);
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ClientEvent::EchoLoss { seq: 0, .. })));
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
ClientEvent::LateReply {
|
||||
seq: 0,
|
||||
sent_at: Some(_),
|
||||
rtt: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
let late_before_close = events
|
||||
.iter()
|
||||
.position(|event| matches!(event, ClientEvent::LateReply { rtt: Some(_), .. }));
|
||||
let close = events
|
||||
.iter()
|
||||
.position(|event| matches!(event, ClientEvent::SessionClosed { .. }));
|
||||
let late_before_close = late_before_close.expect("missing stats-eligible LateReply");
|
||||
let close = close.expect("missing SessionClosed");
|
||||
assert!(late_before_close < close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_managed_run_can_be_stopped_cleanly() {
|
||||
let server = start_echo_server(test_params(None, Duration::from_millis(10)));
|
||||
let session = ManagedClient::start(config(server.addr, None)).unwrap();
|
||||
let sub = session
|
||||
.subscribe(SubscriberConfig {
|
||||
capacity: 16,
|
||||
overflow: SubscriberOverflow::DropNewest,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
loop {
|
||||
if matches!(recv_event_with_timeout(&sub), ClientEvent::EchoReply { .. }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
session.stop();
|
||||
let events = collect_until_closed(&sub);
|
||||
let outcome = session.join().unwrap();
|
||||
server.join();
|
||||
|
||||
assert_eq!(outcome.end_reason, SessionEndReason::Cancelled);
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ClientEvent::SessionClosed { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_reports_worker_panic() {
|
||||
let hub = EventHub::new();
|
||||
let cancellation = CancellationToken::new();
|
||||
let worker = thread::spawn(|| -> Result<SessionOutcome, ClientError> {
|
||||
panic!("intentional managed worker panic")
|
||||
});
|
||||
let session = ManagedClientSession {
|
||||
hub,
|
||||
cancellation,
|
||||
worker: Some(worker),
|
||||
};
|
||||
|
||||
assert!(matches!(session.join(), Err(ClientError::WorkerPanicked)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_test_managed_run_returns_no_test_outcome() {
|
||||
use crate::RunMode;
|
||||
|
||||
let params = test_params(Some(Duration::from_millis(10)), Duration::from_millis(10));
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = socket.local_addr().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let done = thread::spawn(move || {
|
||||
let (request, peer) = recv_request(&socket);
|
||||
tx.send(request[3] & flags::FLAG_CLOSE != 0).unwrap();
|
||||
socket
|
||||
.send_to(
|
||||
&open_reply(FLAG_OPEN | FLAG_REPLY | flags::FLAG_CLOSE, 0, ¶ms),
|
||||
peer,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut cfg = config(addr, Some(Duration::from_millis(10)));
|
||||
cfg.run_mode = RunMode::NoTest;
|
||||
let session = ManagedClient::start(cfg).unwrap();
|
||||
assert!(rx.recv_timeout(Duration::from_secs(2)).unwrap());
|
||||
let outcome = session.join().unwrap();
|
||||
done.join().unwrap();
|
||||
|
||||
assert_eq!(outcome.end_reason, SessionEndReason::NoTestComplete);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{error::ClientError, session::AcceptedSessionParameters};
|
||||
|
||||
#[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,
|
||||
accepted: &AcceptedSessionParameters,
|
||||
) -> Result<Self, ClientError> {
|
||||
let interval = accepted.interval;
|
||||
let end_at = accepted
|
||||
.duration
|
||||
.map(|duration| {
|
||||
start_at
|
||||
.checked_add(duration)
|
||||
.ok_or_else(|| ClientError::NegotiationRejected {
|
||||
reason: "duration is too large to schedule".to_owned(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
end_at,
|
||||
interval,
|
||||
next_send_at: Some(start_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn next_send_deadline(&self) -> Option<Instant> {
|
||||
self.next_send_at
|
||||
}
|
||||
|
||||
/// Wake for finite expiry independently of send readiness until finished.
|
||||
pub(crate) fn end_deadline(&self) -> Option<Instant> {
|
||||
self.end_at.filter(|_| !self.is_finished())
|
||||
}
|
||||
|
||||
pub(crate) fn interval(&self) -> Duration {
|
||||
self.interval
|
||||
}
|
||||
|
||||
pub(crate) fn permit_probe_at(&mut self, now: Instant) -> bool {
|
||||
if self.next_send_at.is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.end_at.is_some_and(|end| now >= end) {
|
||||
self.next_send_at = None;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_managed_commit(
|
||||
&self,
|
||||
scheduled_at: Instant,
|
||||
permission_at: Instant,
|
||||
) -> Result<ScheduleCommit, ClientError> {
|
||||
let (scheduled_at, next_send_at) =
|
||||
advance_cadence(scheduled_at, self.interval, permission_at)?;
|
||||
Ok(ScheduleCommit {
|
||||
scheduled_at,
|
||||
next_send_at: if self.end_at.is_some_and(|end| next_send_at >= end) {
|
||||
None
|
||||
} else {
|
||||
Some(next_send_at)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn commit(&mut self, commit: ScheduleCommit) {
|
||||
self.next_send_at = commit.next_send_at;
|
||||
}
|
||||
|
||||
/// Reclaim slots crossed while a successful socket submission was in flight.
|
||||
/// This follows the send commit; it never advances a failed submission.
|
||||
pub(crate) fn skip_missed_slots_at(&mut self, now: Instant) -> Result<(), ClientError> {
|
||||
if let Some(deadline) = self.next_send_at.filter(|deadline| *deadline <= now) {
|
||||
let commit = self.preflight_managed_commit(deadline, now)?;
|
||||
self.commit(commit);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
self.next_send_at.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn advance_cadence(
|
||||
deadline: Instant,
|
||||
interval: Duration,
|
||||
now: Instant,
|
||||
) -> Result<(Instant, Instant), ClientError> {
|
||||
if interval.is_zero() {
|
||||
return Err(ClientError::InvalidConfig {
|
||||
reason: "probe interval must be greater than zero".to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let elapsed_slots = now
|
||||
.checked_duration_since(deadline)
|
||||
.map_or(0, |elapsed| elapsed.as_nanos() / interval.as_nanos());
|
||||
let scheduled_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(elapsed_slots)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let next_offset = duration_from_nanos(
|
||||
interval
|
||||
.as_nanos()
|
||||
.checked_mul(
|
||||
elapsed_slots
|
||||
.checked_add(1)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)
|
||||
.ok_or(ClientError::DurationOverflow)?,
|
||||
)?;
|
||||
let scheduled_at = deadline
|
||||
.checked_add(scheduled_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
let next_at = deadline
|
||||
.checked_add(next_offset)
|
||||
.ok_or(ClientError::DurationOverflow)?;
|
||||
Ok((scheduled_at, next_at))
|
||||
}
|
||||
|
||||
fn duration_from_nanos(nanos: u128) -> Result<Duration, ClientError> {
|
||||
const NANOS_PER_SECOND: u128 = 1_000_000_000;
|
||||
let seconds =
|
||||
u64::try_from(nanos / NANOS_PER_SECOND).map_err(|_| ClientError::DurationOverflow)?;
|
||||
let subsec_nanos = u32::try_from(nanos % NANOS_PER_SECOND)
|
||||
.expect("nanosecond remainder is always less than one second");
|
||||
Ok(Duration::new(seconds, subsec_nanos))
|
||||
}
|
||||
|
||||
pub(crate) fn instant_abs_diff(left: Instant, right: Instant) -> Duration {
|
||||
left.checked_duration_since(right)
|
||||
.or_else(|| right.checked_duration_since(left))
|
||||
.unwrap_or(Duration::ZERO)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lifetime_deadline_exists_only_for_an_unfinished_finite_schedule() {
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_millis(500);
|
||||
let mut accepted = AcceptedSessionParameters {
|
||||
duration: Some(Duration::from_millis(500)),
|
||||
interval: Duration::from_millis(1),
|
||||
length: 0,
|
||||
received_stats: irtt_proto::ReceivedStats::None,
|
||||
stamp_at: irtt_proto::StampAt::None,
|
||||
clock: irtt_proto::Clock::Both,
|
||||
dscp: 0,
|
||||
server_fill: None,
|
||||
};
|
||||
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
|
||||
assert_eq!(finite.end_deadline(), Some(end));
|
||||
assert!(finite.permit_probe_at(end - Duration::from_nanos(1)));
|
||||
assert_eq!(finite.end_deadline(), Some(end));
|
||||
assert!(!finite.permit_probe_at(end));
|
||||
assert!(finite.is_finished());
|
||||
assert_eq!(finite.end_deadline(), None);
|
||||
|
||||
let mut finite = ProbeSchedule::new(start, &accepted).unwrap();
|
||||
let commit = finite
|
||||
.preflight_managed_commit(start, end - Duration::from_nanos(1))
|
||||
.unwrap();
|
||||
finite.commit(commit);
|
||||
assert!(finite.is_finished());
|
||||
assert_eq!(finite.end_deadline(), None);
|
||||
|
||||
accepted.duration = None;
|
||||
let mut continuous = ProbeSchedule::new(start, &accepted).unwrap();
|
||||
assert!(continuous.permit_probe_at(end));
|
||||
assert_eq!(continuous.end_deadline(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
/// Caller-owned stable target identity.
|
||||
#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct TargetId(Arc<str>);
|
||||
|
||||
impl TargetId {
|
||||
/// Construct a target identifier.
|
||||
pub fn new(value: impl Into<Arc<str>>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// Borrow the identifier as a string slice.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TargetId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_tuple("TargetId").field(&self.0).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TargetId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for TargetId {
|
||||
fn from(value: &str) -> Self {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for TargetId {
|
||||
fn from(value: String) -> Self {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TargetId {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
//! Task ownership, durable status/history, and top-level polling and sealing.
|
||||
//!
|
||||
//! Private children implement control, target lifecycle, updates, pacing, and
|
||||
//! bounded timeout work on this same task; they do not introduce other owners.
|
||||
|
||||
mod control;
|
||||
mod pacing;
|
||||
mod target;
|
||||
mod timeouts;
|
||||
mod update;
|
||||
|
||||
pub use control::{ManagedClient, ManagedClientHandle, ManagedCommandReceipt, ManagedStopReceipt};
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use tokio::{
|
||||
sync::{broadcast, mpsc, watch},
|
||||
time::{self, Sleep},
|
||||
};
|
||||
|
||||
use super::{
|
||||
ManagedClientConfig, ManagedCommandApplyError, ManagedCompletionPolicy, ManagedDriverFailure,
|
||||
ManagedEndReason, ManagedEvent, ManagedLifecycle, ManagedOutcome, ManagedPacing, ManagedStatus,
|
||||
ManagedTargetEndReason, ManagedTargetLifecycle, ManagedTargetOutcome, ManagedTargetStatus,
|
||||
};
|
||||
use control::{ManagedCommand, StopSignal};
|
||||
use pacing::gated_send_deadline;
|
||||
use target::{TargetPhase, TargetRuntime};
|
||||
use timeouts::TimeoutBacklogEntry;
|
||||
|
||||
const TARGET_WORK_BUDGET: usize = 128;
|
||||
|
||||
type WakeFuture = Pin<Box<dyn Future<Output = Option<watch::Receiver<()>>> + Send + 'static>>;
|
||||
|
||||
fn arm_wake(mut receiver: watch::Receiver<()>) -> WakeFuture {
|
||||
Box::pin(async move {
|
||||
receiver.changed().await.ok()?;
|
||||
Some(receiver)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutcomeHistory {
|
||||
limit: usize,
|
||||
recent: VecDeque<ManagedTargetOutcome>,
|
||||
total: u64,
|
||||
successful: u64,
|
||||
failed: u64,
|
||||
peer_closed: u64,
|
||||
discarded: u64,
|
||||
}
|
||||
|
||||
impl OutcomeHistory {
|
||||
fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
limit,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&mut self, outcome: ManagedTargetOutcome) {
|
||||
self.total = self.total.saturating_add(1);
|
||||
match &outcome.end_reason {
|
||||
ManagedTargetEndReason::Failed(_) => self.failed = self.failed.saturating_add(1),
|
||||
ManagedTargetEndReason::PeerClosed => {
|
||||
self.successful = self.successful.saturating_add(1);
|
||||
self.peer_closed = self.peer_closed.saturating_add(1);
|
||||
}
|
||||
ManagedTargetEndReason::TestComplete
|
||||
| ManagedTargetEndReason::NoTestComplete
|
||||
| ManagedTargetEndReason::Removed
|
||||
| ManagedTargetEndReason::Replaced
|
||||
| ManagedTargetEndReason::Stopped => {
|
||||
self.successful = self.successful.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if self.limit == 0 {
|
||||
self.discarded = self.discarded.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
if self.recent.len() == self.limit {
|
||||
self.recent.pop_front();
|
||||
self.discarded = self.discarded.saturating_add(1);
|
||||
}
|
||||
self.recent.push_back(outcome);
|
||||
}
|
||||
|
||||
fn recent(&self) -> Arc<[ManagedTargetOutcome]> {
|
||||
Arc::from(
|
||||
self.recent
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.into_boxed_slice(),
|
||||
)
|
||||
}
|
||||
|
||||
fn outcome(
|
||||
&self,
|
||||
end_reason: ManagedEndReason,
|
||||
applied_command_sequence: u64,
|
||||
) -> ManagedOutcome {
|
||||
ManagedOutcome {
|
||||
end_reason,
|
||||
applied_command_sequence,
|
||||
total_target_outcomes: self.total,
|
||||
successful_target_outcomes: self.successful,
|
||||
failed_target_outcomes: self.failed,
|
||||
peer_closed_target_outcomes: self.peer_closed,
|
||||
discarded_target_outcomes: self.discarded,
|
||||
recent_target_outcomes: self.recent(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskResources {
|
||||
status: watch::Sender<Arc<ManagedStatus>>,
|
||||
events: Option<broadcast::Sender<ManagedEvent>>,
|
||||
stop: Arc<StopSignal>,
|
||||
}
|
||||
|
||||
enum TaskPhase {
|
||||
NotStarted,
|
||||
Running,
|
||||
Stopping,
|
||||
Completed { outcome: Arc<ManagedOutcome> },
|
||||
Failed { outcome: Arc<ManagedOutcome> },
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
impl TaskPhase {
|
||||
fn lifecycle(&self) -> ManagedLifecycle {
|
||||
match self {
|
||||
Self::NotStarted => ManagedLifecycle::NotStarted,
|
||||
Self::Running => ManagedLifecycle::Running,
|
||||
Self::Stopping => ManagedLifecycle::Stopping,
|
||||
Self::Completed { .. } => ManagedLifecycle::Completed,
|
||||
Self::Failed { .. } => ManagedLifecycle::Failed,
|
||||
Self::Abandoned => ManagedLifecycle::Abandoned,
|
||||
}
|
||||
}
|
||||
|
||||
fn final_outcome(&self) -> Option<Arc<ManagedOutcome>> {
|
||||
match self {
|
||||
Self::Completed { outcome } | Self::Failed { outcome } => Some(Arc::clone(outcome)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_terminal(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Completed { .. } | Self::Failed { .. } | Self::Abandoned
|
||||
)
|
||||
}
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
matches!(self, Self::Running)
|
||||
}
|
||||
|
||||
fn is_stopping(&self) -> bool {
|
||||
matches!(self, Self::Stopping)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sole authoritative zero-or-many target driver.
|
||||
#[must_use = "ManagedClientTask must be awaited or deliberately dropped"]
|
||||
pub struct ManagedClientTask {
|
||||
#[cfg(test)]
|
||||
timeout_inspections: std::cell::RefCell<Vec<usize>>,
|
||||
phase: TaskPhase,
|
||||
config: ManagedClientConfig,
|
||||
targets: Vec<TargetRuntime>,
|
||||
commands: mpsc::Receiver<ManagedCommand>,
|
||||
history: OutcomeHistory,
|
||||
resources: Option<TaskResources>,
|
||||
wake: Option<WakeFuture>,
|
||||
timer: Option<Pin<Box<Sleep>>>,
|
||||
cursor: usize,
|
||||
timeout_cursor: usize,
|
||||
timeout_backlog: VecDeque<TimeoutBacklogEntry>,
|
||||
scan_remaining: usize,
|
||||
send_cursor: usize,
|
||||
burst_remaining: usize,
|
||||
stagger_remaining: usize,
|
||||
send_gate: Option<Instant>,
|
||||
last_stagger_send: Option<Instant>,
|
||||
// Explicit stop observation is independent of natural stopping or terminality.
|
||||
stop_observed: bool,
|
||||
next_generation: u64,
|
||||
applied_command_sequence: u64,
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
fn resources(&self) -> &TaskResources {
|
||||
self.resources
|
||||
.as_ref()
|
||||
.expect("managed task resources exist before terminal sealing")
|
||||
}
|
||||
|
||||
fn publish_event(&self, event: ManagedEvent) {
|
||||
if let Some(events) = &self.resources().events {
|
||||
let _ = events.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Arc<ManagedStatus> {
|
||||
let mut connecting = 0;
|
||||
let mut opening = 0;
|
||||
let mut active = 0;
|
||||
let mut draining = 0;
|
||||
let mut closing = 0;
|
||||
let mut terminal = 0;
|
||||
let targets = self
|
||||
.targets
|
||||
.iter()
|
||||
.map(|target| {
|
||||
let lifecycle = target.phase().lifecycle();
|
||||
match lifecycle {
|
||||
ManagedTargetLifecycle::Pending => {}
|
||||
ManagedTargetLifecycle::Connecting => connecting += 1,
|
||||
ManagedTargetLifecycle::Opening => opening += 1,
|
||||
ManagedTargetLifecycle::Active => active += 1,
|
||||
ManagedTargetLifecycle::Draining => draining += 1,
|
||||
ManagedTargetLifecycle::Closing => closing += 1,
|
||||
ManagedTargetLifecycle::Terminal => terminal += 1,
|
||||
}
|
||||
ManagedTargetStatus {
|
||||
target: target.instance.clone(),
|
||||
desired: target.membership.is_desired(),
|
||||
lifecycle,
|
||||
outcome: match target.phase() {
|
||||
TargetPhase::Terminal { outcome } => Some(Arc::clone(outcome)),
|
||||
_ => None,
|
||||
},
|
||||
server_addr: Arc::clone(&target.server_addr),
|
||||
remote: target.remote,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Arc::new(ManagedStatus {
|
||||
lifecycle: self.phase.lifecycle(),
|
||||
stop_requested: self.stop_observed,
|
||||
applied_command_sequence: self.applied_command_sequence,
|
||||
desired_target_count: self
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|target| target.membership.is_desired())
|
||||
.count(),
|
||||
connecting_target_count: connecting,
|
||||
opening_target_count: opening,
|
||||
active_target_count: active,
|
||||
draining_target_count: draining,
|
||||
closing_target_count: closing,
|
||||
terminal_target_count: terminal,
|
||||
total_target_outcomes: self.history.total,
|
||||
successful_target_outcomes: self.history.successful,
|
||||
failed_target_outcomes: self.history.failed,
|
||||
peer_closed_target_outcomes: self.history.peer_closed,
|
||||
discarded_target_outcomes: self.history.discarded,
|
||||
targets: Arc::from(targets.into_boxed_slice()),
|
||||
recent_target_outcomes: self.history.recent(),
|
||||
final_outcome: self.phase.final_outcome(),
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_status(&self) {
|
||||
self.resources().status.send_replace(self.snapshot());
|
||||
}
|
||||
|
||||
fn begin_running(&mut self) -> Result<(), ManagedDriverFailure> {
|
||||
tokio::runtime::Handle::try_current().map_err(|_| ManagedDriverFailure::NoTokioRuntime)?;
|
||||
self.phase = TaskPhase::Running;
|
||||
self.replace_status();
|
||||
self.publish_event(ManagedEvent::Started);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn observe_stop(&mut self) {
|
||||
if self.stop_observed {
|
||||
return;
|
||||
}
|
||||
self.stop_observed = true;
|
||||
self.resources().stop.begin_stopping();
|
||||
let lifecycle_transition = !self.phase.is_stopping();
|
||||
self.phase = TaskPhase::Stopping;
|
||||
self.replace_status();
|
||||
if lifecycle_transition {
|
||||
self.publish_event(ManagedEvent::Stopping);
|
||||
}
|
||||
self.scan_remaining = self.targets.len();
|
||||
self.burst_remaining = 0;
|
||||
}
|
||||
|
||||
fn poll_target_pass(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||
if self.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if self.scan_remaining == 0 {
|
||||
self.scan_remaining = self.targets.len();
|
||||
}
|
||||
let work = self.scan_remaining.min(TARGET_WORK_BUDGET);
|
||||
let mut immediate = false;
|
||||
for _ in 0..work {
|
||||
let index = self.cursor % self.targets.len();
|
||||
self.cursor = (self.cursor + 1) % self.targets.len();
|
||||
self.scan_remaining -= 1;
|
||||
immediate |= self.poll_target(index, cx, now);
|
||||
}
|
||||
immediate || self.scan_remaining > 0
|
||||
}
|
||||
|
||||
fn all_targets_terminal(&self) -> bool {
|
||||
self.targets
|
||||
.iter()
|
||||
.all(|target| matches!(target.phase(), TargetPhase::Terminal { .. }))
|
||||
}
|
||||
|
||||
fn next_deadline(&self) -> Option<Instant> {
|
||||
let mut non_send_deadline = None;
|
||||
let mut send_deadline = None;
|
||||
for target in &self.targets {
|
||||
match target.phase() {
|
||||
TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
} => {
|
||||
non_send_deadline = non_send_deadline
|
||||
.into_iter()
|
||||
.chain(client.next_probe_timeout_deadline())
|
||||
.chain(schedule.end_deadline())
|
||||
.min();
|
||||
if target.membership.is_desired() && !send_waiting {
|
||||
send_deadline = send_deadline
|
||||
.into_iter()
|
||||
.chain(schedule.next_send_deadline())
|
||||
.min();
|
||||
}
|
||||
}
|
||||
TargetPhase::Draining {
|
||||
client, deadline, ..
|
||||
} => {
|
||||
non_send_deadline = non_send_deadline
|
||||
.into_iter()
|
||||
.chain(client.next_probe_timeout_deadline())
|
||||
.chain(Some(*deadline))
|
||||
.min();
|
||||
}
|
||||
TargetPhase::Closing { deadline, .. } => {
|
||||
non_send_deadline = non_send_deadline.into_iter().chain(Some(*deadline)).min();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.phase.is_running()
|
||||
&& self.config.pacing == ManagedPacing::Staggered
|
||||
&& self.active_count() > 0
|
||||
{
|
||||
send_deadline = gated_send_deadline(send_deadline, self.send_gate);
|
||||
}
|
||||
non_send_deadline.into_iter().chain(send_deadline).min()
|
||||
}
|
||||
|
||||
fn register_timer(&mut self, cx: &mut Context<'_>) -> bool {
|
||||
let Some(deadline) = self.next_deadline() else {
|
||||
self.timer = None;
|
||||
return false;
|
||||
};
|
||||
let timer = self
|
||||
.timer
|
||||
.get_or_insert_with(|| Box::pin(time::sleep_until(deadline.into())));
|
||||
timer.as_mut().reset(deadline.into());
|
||||
timer.as_mut().poll(cx).is_ready()
|
||||
}
|
||||
|
||||
fn register_stop_wake(&mut self, cx: &mut Context<'_>) -> bool {
|
||||
let Some(mut wake) = self.wake.take() else {
|
||||
return false;
|
||||
};
|
||||
match wake.as_mut().poll(cx) {
|
||||
Poll::Pending => {
|
||||
self.wake = Some(wake);
|
||||
false
|
||||
}
|
||||
Poll::Ready(Some(receiver)) => {
|
||||
self.wake = Some(arm_wake(receiver));
|
||||
self.resources().stop.is_requested()
|
||||
}
|
||||
Poll::Ready(None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn seal(&mut self, mut end_reason: ManagedEndReason, failed: bool) -> Poll<ManagedOutcome> {
|
||||
// Sealing is the final stop-observation point. A stop that reaches the
|
||||
// latch before this terminal linearization becomes durable even when
|
||||
// quiescence had already entered `Stopping`.
|
||||
if self.resources().stop.is_requested() {
|
||||
self.observe_stop();
|
||||
}
|
||||
if self.stop_observed && matches!(end_reason, ManagedEndReason::TargetsComplete) {
|
||||
end_reason = ManagedEndReason::StopRequested;
|
||||
}
|
||||
|
||||
// Closing admission before closing and draining the receiver makes every
|
||||
// accepted command task-owned. Tokio still permits buffered commands
|
||||
// to be drained after `Receiver::close`, but rejects later sends.
|
||||
self.resources().stop.close_updates();
|
||||
self.commands.close();
|
||||
while let Ok(ManagedCommand::UpdateTargets {
|
||||
acknowledgement, ..
|
||||
}) = self.commands.try_recv()
|
||||
{
|
||||
let error = match &end_reason {
|
||||
ManagedEndReason::DriverFailed(failure) => {
|
||||
ManagedCommandApplyError::DriverFailed(failure.clone())
|
||||
}
|
||||
_ => ManagedCommandApplyError::Stopping,
|
||||
};
|
||||
let _ = acknowledgement.send(Err(error));
|
||||
}
|
||||
let outcome = Arc::new(
|
||||
self.history
|
||||
.outcome(end_reason, self.applied_command_sequence),
|
||||
);
|
||||
self.phase = if failed {
|
||||
TaskPhase::Failed {
|
||||
outcome: Arc::clone(&outcome),
|
||||
}
|
||||
} else {
|
||||
TaskPhase::Completed {
|
||||
outcome: Arc::clone(&outcome),
|
||||
}
|
||||
};
|
||||
self.replace_status();
|
||||
self.publish_event(if failed {
|
||||
ManagedEvent::Failed {
|
||||
outcome: Arc::clone(&outcome),
|
||||
}
|
||||
} else {
|
||||
ManagedEvent::Completed {
|
||||
outcome: Arc::clone(&outcome),
|
||||
}
|
||||
});
|
||||
if let Some(resources) = self.resources.as_mut() {
|
||||
resources.events.take();
|
||||
}
|
||||
self.wake = None;
|
||||
self.timer = None;
|
||||
self.resources.take();
|
||||
Poll::Ready((*outcome).clone())
|
||||
}
|
||||
|
||||
fn fail_driver(&mut self, failure: ManagedDriverFailure) -> Poll<ManagedOutcome> {
|
||||
self.seal(ManagedEndReason::DriverFailed(failure), true)
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for ManagedClientTask {
|
||||
type Output = ManagedOutcome;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = self.as_mut().get_mut();
|
||||
if this.phase.is_terminal() {
|
||||
panic!("ManagedClientTask polled after completion");
|
||||
}
|
||||
|
||||
if matches!(this.phase, TaskPhase::NotStarted) {
|
||||
if this.resources().stop.is_requested() {
|
||||
this.observe_stop();
|
||||
} else if let Err(failure) = this.begin_running() {
|
||||
return this.fail_driver(failure);
|
||||
}
|
||||
}
|
||||
if this.resources().stop.is_requested() {
|
||||
this.observe_stop();
|
||||
}
|
||||
|
||||
let mut immediate = this.process_commands(cx);
|
||||
if this.resources().stop.is_requested() {
|
||||
this.observe_stop();
|
||||
}
|
||||
let now = Instant::now();
|
||||
immediate |= this.poll_timeout_pass(now);
|
||||
immediate |= this.poll_target_pass(cx, now);
|
||||
this.prune_undesired_terminal();
|
||||
if this.phase.is_running() {
|
||||
immediate |= match this.config.pacing {
|
||||
ManagedPacing::Staggered => this.poll_staggered_send(cx, now),
|
||||
ManagedPacing::Burst => this.poll_burst_sends(cx, now),
|
||||
};
|
||||
}
|
||||
|
||||
if this.resources().stop.is_requested() {
|
||||
this.observe_stop();
|
||||
}
|
||||
|
||||
if this.all_targets_terminal() {
|
||||
match this.phase {
|
||||
TaskPhase::Stopping => {
|
||||
return this.seal(
|
||||
if this.stop_observed {
|
||||
ManagedEndReason::StopRequested
|
||||
} else {
|
||||
ManagedEndReason::TargetsComplete
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
TaskPhase::Running
|
||||
if this.config.completion == ManagedCompletionPolicy::FinishWhenQuiescent =>
|
||||
{
|
||||
this.phase = TaskPhase::Stopping;
|
||||
this.resources().stop.begin_stopping();
|
||||
this.replace_status();
|
||||
this.publish_event(ManagedEvent::Stopping);
|
||||
return this.seal(ManagedEndReason::TargetsComplete, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
immediate |= this.register_stop_wake(cx);
|
||||
if !immediate {
|
||||
immediate |= this.register_timer(cx);
|
||||
}
|
||||
if immediate {
|
||||
cx.waker().wake_by_ref();
|
||||
}
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedClientTask {
|
||||
fn drop(&mut self) {
|
||||
if self.phase.is_terminal() || self.resources.is_none() {
|
||||
return;
|
||||
}
|
||||
self.phase = TaskPhase::Abandoned;
|
||||
self.resources().stop.close_updates();
|
||||
self.commands.close();
|
||||
self.replace_status();
|
||||
self.publish_event(ManagedEvent::Abandoned);
|
||||
if let Some(resources) = self.resources.as_mut() {
|
||||
resources.events.take();
|
||||
}
|
||||
self.resources.take();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
async_client::AsyncClient,
|
||||
managed::{schedule::ProbeSchedule, ManagedTargetConfig},
|
||||
ClientConfig,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
// Real EAGAIN needs privileged queue shaping. Inspect only the owning
|
||||
// deadline selection here, using a production socket without sending.
|
||||
#[test]
|
||||
fn finite_lifetime_deadline_survives_send_waiting_and_stagger_gating() {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
runtime.block_on(async {
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_millis(500);
|
||||
let accepted = crate::AcceptedSessionParameters {
|
||||
duration: Some(Duration::from_millis(500)),
|
||||
interval: Duration::from_millis(1),
|
||||
length: 0,
|
||||
received_stats: irtt_proto::ReceivedStats::None,
|
||||
stamp_at: irtt_proto::StampAt::None,
|
||||
clock: irtt_proto::Clock::Both,
|
||||
dscp: 0,
|
||||
server_fill: None,
|
||||
};
|
||||
for pacing in [ManagedPacing::Burst, ManagedPacing::Staggered] {
|
||||
let (mut task, _handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
pacing,
|
||||
..ManagedClientConfig::default()
|
||||
},
|
||||
vec![ManagedTargetConfig::new("target", "127.0.0.1:9")],
|
||||
)
|
||||
.unwrap();
|
||||
let client = AsyncClient::connect("127.0.0.1:9", ClientConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
task.phase = TaskPhase::Running;
|
||||
task.install_target_phase(
|
||||
0,
|
||||
TargetPhase::Active {
|
||||
client,
|
||||
schedule: ProbeSchedule::new(start, &accepted).unwrap(),
|
||||
send_waiting: false,
|
||||
},
|
||||
);
|
||||
task.send_gate = Some(end + Duration::from_secs(1));
|
||||
|
||||
for send_waiting in [false, true] {
|
||||
let TargetPhase::Active {
|
||||
schedule,
|
||||
send_waiting: waiting,
|
||||
..
|
||||
} = task.targets[0].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*waiting = send_waiting;
|
||||
*schedule = ProbeSchedule::new(start, &accepted).unwrap();
|
||||
let expected = if pacing == ManagedPacing::Burst && !send_waiting {
|
||||
start
|
||||
} else {
|
||||
end
|
||||
};
|
||||
assert_eq!(task.next_deadline(), Some(expected));
|
||||
|
||||
let TargetPhase::Active { schedule, .. } = task.targets[0].phase_mut() else {
|
||||
unreachable!()
|
||||
};
|
||||
schedule.permit_probe_at(end);
|
||||
assert_eq!(task.next_deadline(), None);
|
||||
}
|
||||
|
||||
let mut continuous = accepted.clone();
|
||||
continuous.duration = None;
|
||||
let TargetPhase::Active { schedule, .. } = task.targets[0].phase_mut() else {
|
||||
unreachable!()
|
||||
};
|
||||
*schedule = ProbeSchedule::new(start, &continuous).unwrap();
|
||||
assert_eq!(task.next_deadline(), None);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Task construction, public control/observation handles, and stop/update admission.
|
||||
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
fmt,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||
Arc,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, watch};
|
||||
|
||||
use crate::{
|
||||
managed::{
|
||||
ManagedClientConfig, ManagedCommandAcknowledgement, ManagedCommandApplyError,
|
||||
ManagedCommandError, ManagedCompletionPolicy, ManagedConfigError, ManagedEvent,
|
||||
ManagedEventSubscription, ManagedLifecycle, ManagedStatus, ManagedStatusSubscription,
|
||||
ManagedSubscribeError, ManagedTargetConfig, ManagedTargetLifecycle, ManagedTargetStatus,
|
||||
TargetInstance,
|
||||
},
|
||||
session::machine::SessionMachine,
|
||||
socket_options::validate_ttl,
|
||||
ClientConfig, ClientError,
|
||||
};
|
||||
|
||||
use super::{
|
||||
arm_wake,
|
||||
target::{TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
|
||||
timeouts::TIMEOUT_WORK_BUDGET,
|
||||
ManagedClientTask, OutcomeHistory, TaskPhase, TaskResources,
|
||||
};
|
||||
|
||||
// Tokio 1.53 broadcast asserts this exact bound before rounding capacity to a
|
||||
// power of two; Tokio does not expose it as a public constant.
|
||||
const MAX_BROADCAST_CHANNEL_CAPACITY: usize = usize::MAX >> 1;
|
||||
|
||||
/// Entry point for constructing a unified Tokio managed task.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Construct the task and its separate control handle, subscribe before
|
||||
/// starting the task, then run it under the caller's Tokio runtime. A reachable
|
||||
/// server is required at runtime, so this example only type-checks.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use irtt_client::{ClientConfig, ClientEvent};
|
||||
/// use irtt_client::managed::{
|
||||
/// ManagedClient, ManagedClientConfig, ManagedEvent, ManagedTargetConfig,
|
||||
/// };
|
||||
///
|
||||
/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let config = ManagedClientConfig {
|
||||
/// client: ClientConfig {
|
||||
/// request: irtt_client::SessionRequest {
|
||||
/// duration: Some(std::time::Duration::from_secs(10)),
|
||||
/// ..irtt_client::SessionRequest::default()
|
||||
/// },
|
||||
/// ..ClientConfig::default()
|
||||
/// },
|
||||
/// ..ManagedClientConfig::default()
|
||||
/// };
|
||||
/// let targets = vec![ManagedTargetConfig::new("edge", "127.0.0.1:2112")];
|
||||
/// let (task, handle) = ManagedClient::task(config, targets)?;
|
||||
/// let mut events = handle.subscribe()?;
|
||||
/// let driver = tokio::spawn(task);
|
||||
///
|
||||
/// while let Ok(event) = events.recv().await {
|
||||
/// if let ManagedEvent::Client { event: ClientEvent::EchoReply { .. }, .. } = event {
|
||||
/// println!("received a probe reply");
|
||||
/// }
|
||||
/// }
|
||||
/// let outcome = driver.await?;
|
||||
/// println!("run ended: {:?}", outcome.end_reason);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ManagedClient;
|
||||
|
||||
impl ManagedClient {
|
||||
/// Validate configuration and construct a runtime-independent task/handle pair.
|
||||
pub fn task(
|
||||
config: ManagedClientConfig,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
|
||||
build_task(config, targets)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct StopSignal {
|
||||
requested: AtomicBool,
|
||||
update_admission: AtomicU8,
|
||||
wake: watch::Sender<()>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub(super) enum UpdateAdmission {
|
||||
Open,
|
||||
Stopping,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl UpdateAdmission {
|
||||
fn from_raw(value: u8) -> Self {
|
||||
match value {
|
||||
value if value == Self::Open as u8 => Self::Open,
|
||||
value if value == Self::Stopping as u8 => Self::Stopping,
|
||||
value if value == Self::Closed as u8 => Self::Closed,
|
||||
_ => unreachable!("managed update admission stores a known state"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StopSignal {
|
||||
fn request(&self) {
|
||||
self.begin_stopping();
|
||||
if !self.requested.swap(true, Ordering::AcqRel) {
|
||||
self.wake.send_replace(());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_admission(&self) -> UpdateAdmission {
|
||||
UpdateAdmission::from_raw(self.update_admission.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
pub(super) fn begin_stopping(&self) {
|
||||
let mut current = self.update_admission.load(Ordering::Acquire);
|
||||
loop {
|
||||
match UpdateAdmission::from_raw(current) {
|
||||
UpdateAdmission::Open => match self.update_admission.compare_exchange_weak(
|
||||
UpdateAdmission::Open as u8,
|
||||
UpdateAdmission::Stopping as u8,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
},
|
||||
UpdateAdmission::Stopping | UpdateAdmission::Closed => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn close_updates(&self) {
|
||||
self.update_admission
|
||||
.store(UpdateAdmission::Closed as u8, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(super) fn is_requested(&self) -> bool {
|
||||
self.requested.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable control and observation capability for a managed task.
|
||||
#[derive(Clone)]
|
||||
pub struct ManagedClientHandle {
|
||||
stop: Arc<StopSignal>,
|
||||
commands: mpsc::Sender<ManagedCommand>,
|
||||
max_live_target_generations: usize,
|
||||
status: watch::Receiver<Arc<ManagedStatus>>,
|
||||
events: broadcast::WeakSender<ManagedEvent>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ManagedClientHandle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ManagedClientHandle")
|
||||
.field("status", &self.status())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl ManagedClientHandle {
|
||||
/// Return the latest durable immutable status snapshot.
|
||||
pub fn status(&self) -> Arc<ManagedStatus> {
|
||||
Arc::clone(&self.status.borrow())
|
||||
}
|
||||
|
||||
/// Subscribe to authoritative durable latest-state observation.
|
||||
///
|
||||
/// The current snapshot is marked as seen when subscribing.
|
||||
/// The current snapshot is immediately available through `borrow()`. Updates
|
||||
/// may coalesce; this is not a lossless event log. After the task terminates,
|
||||
/// the sender closes but the final snapshot remains readable. Use
|
||||
/// [`Self::subscribe`] for the lossy presentation-event stream.
|
||||
pub fn subscribe_status(&self) -> ManagedStatusSubscription {
|
||||
let mut status = self.status.clone();
|
||||
drop(status.borrow_and_update());
|
||||
status
|
||||
}
|
||||
|
||||
/// Subscribe to future lossy presentation events.
|
||||
pub fn subscribe(&self) -> Result<ManagedEventSubscription, ManagedSubscribeError> {
|
||||
self.events
|
||||
.upgrade()
|
||||
.map(|sender| sender.subscribe())
|
||||
.ok_or(ManagedSubscribeError::Closed)
|
||||
}
|
||||
|
||||
/// Request idempotent graceful stop and return a durable-status receipt.
|
||||
pub fn stop(&self) -> ManagedStopReceipt {
|
||||
self.stop.request();
|
||||
ManagedStopReceipt::new(self.subscribe_status())
|
||||
}
|
||||
|
||||
/// Submit the complete desired target set, replacing any previous one.
|
||||
///
|
||||
/// `targets` is not a delta: any live target whose id is missing from
|
||||
/// `targets` is stopped and removed. A target that is still present with an
|
||||
/// unchanged configuration continues undisturbed — it is not restarted and
|
||||
/// does not get a new generation. A target present with a *changed*
|
||||
/// configuration, or whose current generation already reached a terminal
|
||||
/// state (e.g. failed, completed, or was closed by the peer), is treated as
|
||||
/// a fresh start: it is retired and a new generation is created for it, even
|
||||
/// if the configuration is byte-for-byte identical to the one that just
|
||||
/// finished.
|
||||
///
|
||||
/// This call enqueues the update without waiting for queue capacity; await
|
||||
/// the returned [`ManagedCommandReceipt`] to observe whether — and how — it
|
||||
/// was applied.
|
||||
pub fn update_targets(
|
||||
&self,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<ManagedCommandReceipt, ManagedCommandError> {
|
||||
match self.stop.update_admission() {
|
||||
UpdateAdmission::Open => {}
|
||||
UpdateAdmission::Stopping => return Err(ManagedCommandError::Stopping),
|
||||
UpdateAdmission::Closed => return Err(ManagedCommandError::DriverClosed),
|
||||
}
|
||||
if targets.len() > self.max_live_target_generations {
|
||||
return Err(ManagedCommandError::TooManyTargets {
|
||||
configured: targets.len(),
|
||||
limit: self.max_live_target_generations,
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
// command to enqueue; the driver-side check immediately before
|
||||
// `apply_targets` determines whether it applies or resolves as
|
||||
// `Stopping`. Terminal sealing closes the receiver before draining
|
||||
// accepted commands.
|
||||
let (acknowledgement, receiver) = oneshot::channel();
|
||||
match self.commands.try_send(ManagedCommand::UpdateTargets {
|
||||
targets,
|
||||
acknowledgement,
|
||||
}) {
|
||||
Ok(()) => Ok(ManagedCommandReceipt { receiver }),
|
||||
Err(mpsc::error::TrySendError::Full(_)) => match self.stop.update_admission() {
|
||||
UpdateAdmission::Open => Err(ManagedCommandError::QueueFull),
|
||||
UpdateAdmission::Stopping => Err(ManagedCommandError::Stopping),
|
||||
UpdateAdmission::Closed => Err(ManagedCommandError::DriverClosed),
|
||||
},
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => Err(ManagedCommandError::DriverClosed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Receipt for a target-set transaction accepted by the driver queue.
|
||||
#[must_use = "await the receipt to observe target-update application"]
|
||||
pub struct ManagedCommandReceipt {
|
||||
receiver: oneshot::Receiver<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
||||
}
|
||||
|
||||
impl ManagedCommandReceipt {
|
||||
/// Block until the target-set transaction is applied or rejected.
|
||||
///
|
||||
/// This is for synchronous callers such as [`crate::managed::BlockingManagedClient`]. It
|
||||
/// must not be called from an asynchronous runtime; use the [`Future`]
|
||||
/// implementation there instead.
|
||||
pub fn blocking_wait(self) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
|
||||
self.receiver
|
||||
.blocking_recv()
|
||||
.unwrap_or(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for ManagedCommandReceipt {
|
||||
type Output = Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>;
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
match Pin::new(&mut self.receiver).poll(cx) {
|
||||
Poll::Ready(Ok(result)) => Poll::Ready(result),
|
||||
Poll::Ready(Err(_)) => {
|
||||
Poll::Ready(Err(ManagedCommandApplyError::AcknowledgementDisconnected))
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum ManagedCommand {
|
||||
UpdateTargets {
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
acknowledgement:
|
||||
oneshot::Sender<Result<ManagedCommandAcknowledgement, ManagedCommandApplyError>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Receipt resolving once stop is durably observed or the task is terminal.
|
||||
///
|
||||
/// Resolves from authoritative status as soon as `stop_requested` is true or
|
||||
/// the lifecycle is Completed, Failed, or Abandoned. It need not wait for
|
||||
/// graceful cleanup after stop observation.
|
||||
#[must_use = "await the receipt to observe durable stop acknowledgement"]
|
||||
pub struct ManagedStopReceipt {
|
||||
future: Pin<Box<dyn Future<Output = ()> + Send>>,
|
||||
}
|
||||
|
||||
impl ManagedStopReceipt {
|
||||
fn new(mut status: ManagedStatusSubscription) -> Self {
|
||||
let future = Box::pin(async move {
|
||||
while !{
|
||||
let snapshot = status.borrow();
|
||||
snapshot.stop_requested
|
||||
|| matches!(
|
||||
snapshot.lifecycle,
|
||||
ManagedLifecycle::Completed
|
||||
| ManagedLifecycle::Failed
|
||||
| ManagedLifecycle::Abandoned
|
||||
)
|
||||
} {
|
||||
// A closed sender retains the terminal snapshot. Never wait
|
||||
// indefinitely if the channel closes.
|
||||
if status.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { future }
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for ManagedStopReceipt {
|
||||
type Output = ();
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
self.future.as_mut().poll(cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_task(
|
||||
config: ManagedClientConfig,
|
||||
targets: Vec<ManagedTargetConfig>,
|
||||
) -> Result<(ManagedClientTask, ManagedClientHandle), ManagedConfigError> {
|
||||
if config.event_capacity == 0 {
|
||||
return Err(ManagedConfigError::ZeroEventCapacity);
|
||||
}
|
||||
if config.event_capacity > MAX_BROADCAST_CHANNEL_CAPACITY {
|
||||
return Err(ManagedConfigError::EventCapacityTooLarge {
|
||||
configured: config.event_capacity,
|
||||
maximum: MAX_BROADCAST_CHANNEL_CAPACITY,
|
||||
});
|
||||
}
|
||||
if config.command_capacity == 0 {
|
||||
return Err(ManagedConfigError::ZeroCommandCapacity);
|
||||
}
|
||||
if config.command_capacity > tokio::sync::Semaphore::MAX_PERMITS {
|
||||
return Err(ManagedConfigError::CommandCapacityTooLarge {
|
||||
configured: config.command_capacity,
|
||||
maximum: tokio::sync::Semaphore::MAX_PERMITS,
|
||||
});
|
||||
}
|
||||
if config.max_live_target_generations == 0 {
|
||||
return Err(ManagedConfigError::ZeroLiveGenerationLimit);
|
||||
}
|
||||
if Instant::now().checked_add(config.final_drain).is_none() {
|
||||
return Err(ManagedConfigError::UnschedulableFinalDrain {
|
||||
duration: config.final_drain,
|
||||
});
|
||||
}
|
||||
if targets.len() > config.max_live_target_generations {
|
||||
return Err(ManagedConfigError::TooManyTargets {
|
||||
configured: targets.len(),
|
||||
limit: config.max_live_target_generations,
|
||||
});
|
||||
}
|
||||
if targets.is_empty() && config.completion == ManagedCompletionPolicy::FinishWhenQuiescent {
|
||||
return Err(ManagedConfigError::EmptyInitialTargets);
|
||||
}
|
||||
|
||||
let mut ids = HashSet::with_capacity(targets.len());
|
||||
let mut runtimes = Vec::with_capacity(targets.len());
|
||||
let mut next_generation = 1_u64;
|
||||
for target in targets {
|
||||
if !ids.insert(target.id.clone()) {
|
||||
return Err(ManagedConfigError::DuplicateTargetId { id: target.id });
|
||||
}
|
||||
let generation = next_generation;
|
||||
next_generation = next_generation
|
||||
.checked_add(1)
|
||||
.ok_or(ManagedConfigError::GenerationExhausted)?;
|
||||
let mut client_config = config.client.clone();
|
||||
client_config.auth = target.auth.resolve(&config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedConfigError::InvalidTarget {
|
||||
id: target.id.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
runtimes.push(TargetRuntime {
|
||||
instance: TargetInstance {
|
||||
id: target.id.clone(),
|
||||
generation,
|
||||
},
|
||||
config: target.clone(),
|
||||
membership: TargetMembership::Desired,
|
||||
server_addr: Arc::from(target.server_addr),
|
||||
remote: None,
|
||||
counters: TargetCounters::default(),
|
||||
phase: Some(TargetPhase::Pending { client_config }),
|
||||
});
|
||||
}
|
||||
|
||||
let (event_sender, _) = broadcast::channel(config.event_capacity);
|
||||
let weak_events = event_sender.downgrade();
|
||||
let (wake_sender, wake_receiver) = watch::channel(());
|
||||
let stop = Arc::new(StopSignal {
|
||||
requested: AtomicBool::new(false),
|
||||
update_admission: AtomicU8::new(UpdateAdmission::Open as u8),
|
||||
wake: wake_sender,
|
||||
});
|
||||
let history = OutcomeHistory::new(config.outcome_history_limit);
|
||||
let initial_targets = runtimes
|
||||
.iter()
|
||||
.map(|target| ManagedTargetStatus {
|
||||
target: target.instance.clone(),
|
||||
desired: target.membership.is_desired(),
|
||||
lifecycle: ManagedTargetLifecycle::Pending,
|
||||
outcome: None,
|
||||
server_addr: Arc::clone(&target.server_addr),
|
||||
remote: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let phase = TaskPhase::NotStarted;
|
||||
let initial = Arc::new(ManagedStatus {
|
||||
lifecycle: phase.lifecycle(),
|
||||
stop_requested: false,
|
||||
applied_command_sequence: 0,
|
||||
desired_target_count: runtimes.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(initial_targets.into_boxed_slice()),
|
||||
recent_target_outcomes: history.recent(),
|
||||
final_outcome: phase.final_outcome(),
|
||||
});
|
||||
let (status_sender, status_receiver) = watch::channel(initial);
|
||||
let (command_sender, command_receiver) = mpsc::channel(config.command_capacity);
|
||||
let max_live_target_generations = config.max_live_target_generations;
|
||||
let resources = TaskResources {
|
||||
status: status_sender,
|
||||
events: Some(event_sender),
|
||||
stop: Arc::clone(&stop),
|
||||
};
|
||||
let task = ManagedClientTask {
|
||||
#[cfg(test)]
|
||||
timeout_inspections: std::cell::RefCell::new(Vec::new()),
|
||||
phase,
|
||||
config,
|
||||
targets: runtimes,
|
||||
commands: command_receiver,
|
||||
history,
|
||||
resources: Some(resources),
|
||||
wake: Some(arm_wake(wake_receiver)),
|
||||
timer: None,
|
||||
cursor: 0,
|
||||
timeout_cursor: 0,
|
||||
timeout_backlog: VecDeque::with_capacity(TIMEOUT_WORK_BUDGET),
|
||||
scan_remaining: 0,
|
||||
send_cursor: 0,
|
||||
burst_remaining: 0,
|
||||
stagger_remaining: 0,
|
||||
send_gate: None,
|
||||
last_stagger_send: None,
|
||||
stop_observed: false,
|
||||
next_generation,
|
||||
applied_command_sequence: 0,
|
||||
};
|
||||
let handle = ManagedClientHandle {
|
||||
stop,
|
||||
commands: command_sender,
|
||||
max_live_target_generations,
|
||||
status: status_receiver,
|
||||
events: weak_events,
|
||||
};
|
||||
Ok((task, handle))
|
||||
}
|
||||
|
||||
pub(super) fn validate_target_config(config: &ClientConfig) -> Result<(), ClientError> {
|
||||
config.open.validate()?;
|
||||
SessionMachine::validate_config(config)?;
|
||||
if let Some(ttl) = config.socket.ttl {
|
||||
validate_ttl(ttl)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Burst/stagger send selection and committed-send cadence.
|
||||
|
||||
use std::{
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
managed::{schedule::instant_abs_diff, ManagedTargetEndReason, ManagedTargetFailurePhase},
|
||||
ClientEvent, SendProbeError,
|
||||
};
|
||||
|
||||
use super::{
|
||||
target::{OpenSessionFailureCleanup, TargetPhase},
|
||||
ManagedClientTask, TARGET_WORK_BUDGET,
|
||||
};
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn active_count(&self) -> usize {
|
||||
self.targets
|
||||
.iter()
|
||||
.filter(|target| target.is_paced_active())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn active_stagger_spacing(&self) -> Option<Duration> {
|
||||
let mut active = 0;
|
||||
let mut minimum: Option<Duration> = None;
|
||||
for target in &self.targets {
|
||||
let TargetPhase::Active { schedule, .. } = target.phase() else {
|
||||
continue;
|
||||
};
|
||||
if !target.membership.is_desired() {
|
||||
continue;
|
||||
}
|
||||
active += 1;
|
||||
minimum = minimum.into_iter().chain(Some(schedule.interval())).min();
|
||||
}
|
||||
minimum.map(|interval| stagger_spacing(interval, active))
|
||||
}
|
||||
|
||||
pub(super) fn stagger_target_added(&mut self, now: Instant) {
|
||||
let Some(existing) = self.send_gate.filter(|gate| *gate > now) else {
|
||||
self.send_gate = None;
|
||||
return;
|
||||
};
|
||||
let candidate = self
|
||||
.last_stagger_send
|
||||
.zip(self.active_stagger_spacing())
|
||||
.and_then(|(last, spacing)| last.checked_add(spacing))
|
||||
.filter(|gate| *gate > now);
|
||||
self.send_gate = candidate.map(|candidate| existing.min(candidate));
|
||||
}
|
||||
|
||||
pub(super) fn stagger_target_removed(&mut self, now: Instant) {
|
||||
self.send_gate = self.send_gate.filter(|gate| *gate > now);
|
||||
}
|
||||
|
||||
/// The next stagger gate after a send was accepted at `accepted_at`.
|
||||
///
|
||||
/// The gate advances on its **own** cadence rather than being re-anchored
|
||||
/// on every observed accept time. Anchoring on `accepted_at` folds each
|
||||
/// send's wakeup latency into the next slot and then into every slot after
|
||||
/// it, so the gate — and with it the whole group's send cadence — drifts
|
||||
/// later without bound while the per-target schedules stay on their
|
||||
/// absolute grid. The drift is not hypothetical: it shows up as a
|
||||
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
|
||||
/// interval, as probe slots the schedule then skips.
|
||||
///
|
||||
/// `accepted_at` is still the anchor in the two cases where continuing the
|
||||
/// old cadence would be wrong: there is no previous gate, or the pacer has
|
||||
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
|
||||
/// stranded in the past from releasing a burst of catch-up sends once the
|
||||
/// group starts moving again.
|
||||
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
|
||||
let base = match self.send_gate {
|
||||
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
|
||||
_ => accepted_at,
|
||||
};
|
||||
base.checked_add(spacing)
|
||||
}
|
||||
|
||||
fn record_stagger_acceptance(
|
||||
&mut self,
|
||||
result: SendResult,
|
||||
stagger_spacing: Option<Duration>,
|
||||
accepted_at: Instant,
|
||||
) {
|
||||
if !result.accepted() {
|
||||
return;
|
||||
}
|
||||
let Some(spacing) = stagger_spacing else {
|
||||
return;
|
||||
};
|
||||
self.last_stagger_send = Some(accepted_at);
|
||||
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
|
||||
}
|
||||
|
||||
fn poll_one_send(
|
||||
&mut self,
|
||||
index: usize,
|
||||
cx: &mut Context<'_>,
|
||||
now: Instant,
|
||||
stagger_spacing: Option<Duration>,
|
||||
) -> SendResult {
|
||||
if !self.targets[index].membership.is_desired() {
|
||||
if let TargetPhase::Active { send_waiting, .. } = self.targets[index].phase_mut() {
|
||||
*send_waiting = false;
|
||||
}
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
let phase = self.targets[index].take_phase();
|
||||
let TargetPhase::Active {
|
||||
mut client,
|
||||
mut schedule,
|
||||
mut send_waiting,
|
||||
} = phase
|
||||
else {
|
||||
self.targets[index].restore_phase(phase);
|
||||
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)
|
||||
{
|
||||
send_waiting = false;
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
if !schedule.permit_probe_at(now) {
|
||||
send_waiting = false;
|
||||
let pending = client.has_pending_probes();
|
||||
if !pending {
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
}
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
if pending {
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
|
||||
return SendResult::Ready { accepted: false };
|
||||
}
|
||||
if schedule
|
||||
.next_send_deadline()
|
||||
.is_none_or(|deadline| deadline > now)
|
||||
{
|
||||
send_waiting = false;
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
return SendResult::NotAttempted;
|
||||
}
|
||||
let commit =
|
||||
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
|
||||
Ok(commit) => commit,
|
||||
Err(error) => {
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Drain,
|
||||
);
|
||||
return SendResult::Failed { accepted: false };
|
||||
}
|
||||
};
|
||||
let scheduled_at = commit.scheduled_at;
|
||||
let result = client.poll_send_probe(cx);
|
||||
let receipt = match &result {
|
||||
Poll::Ready(Ok(receipt))
|
||||
| Poll::Ready(Err(SendProbeError::AfterCommit { receipt, .. })) => Some(*receipt),
|
||||
Poll::Pending | Poll::Ready(Err(SendProbeError::NotCommitted(_))) => None,
|
||||
};
|
||||
let accepted = receipt.is_some();
|
||||
let send_result = match &result {
|
||||
Poll::Pending => SendResult::Pending,
|
||||
Poll::Ready(Ok(_)) => SendResult::Ready { accepted },
|
||||
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
|
||||
};
|
||||
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
|
||||
if accepted {
|
||||
schedule.commit(commit);
|
||||
}
|
||||
let schedule_error = if accepted {
|
||||
schedule.skip_missed_slots_at(Instant::now()).err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.targets[index].sync_packets_sent(&client);
|
||||
send_waiting = result.is_pending();
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client,
|
||||
schedule,
|
||||
send_waiting,
|
||||
});
|
||||
// A committed send must reach the lifecycle/accounting stream even
|
||||
// when post-send processing fails. Publish before failure cleanup.
|
||||
let sent_event = receipt.map(|receipt| {
|
||||
let mut event = ClientEvent::from(receipt);
|
||||
if let ClientEvent::EchoSent {
|
||||
scheduled_at: intended,
|
||||
timer_error,
|
||||
..
|
||||
} = &mut event
|
||||
{
|
||||
*intended = Some(scheduled_at);
|
||||
*timer_error = Some(instant_abs_diff(receipt.sent_at.mono, scheduled_at));
|
||||
}
|
||||
event
|
||||
});
|
||||
match result {
|
||||
Poll::Pending => SendResult::Pending,
|
||||
Poll::Ready(Ok(_)) => {
|
||||
self.publish_client_events(index, vec![sent_event.unwrap()]);
|
||||
if let Some(error) = schedule_error {
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Drain,
|
||||
);
|
||||
SendResult::Failed { accepted }
|
||||
} else {
|
||||
SendResult::Ready { accepted }
|
||||
}
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
if let Some(event) = sent_event {
|
||||
self.publish_client_events(index, vec![event]);
|
||||
}
|
||||
let error = match error {
|
||||
SendProbeError::NotCommitted(source) => source,
|
||||
SendProbeError::AfterCommit { source, .. } => *source,
|
||||
};
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Sending,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Drain,
|
||||
);
|
||||
SendResult::Failed { accepted }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll_staggered_send(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||
if self.active_count() == 0 || self.send_gate.is_some_and(|gate| gate > now) {
|
||||
return false;
|
||||
}
|
||||
if self.stagger_remaining == 0 {
|
||||
self.stagger_remaining = self.targets.len();
|
||||
}
|
||||
let work = self.stagger_remaining.min(TARGET_WORK_BUDGET);
|
||||
for _ in 0..work {
|
||||
let index = self.send_cursor % self.targets.len();
|
||||
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
||||
self.stagger_remaining -= 1;
|
||||
if !self.targets[index].is_paced_active() {
|
||||
continue;
|
||||
}
|
||||
let stagger_spacing = self.active_stagger_spacing();
|
||||
let result = self.poll_one_send(index, cx, now, stagger_spacing);
|
||||
if result == SendResult::NotAttempted {
|
||||
continue;
|
||||
}
|
||||
self.stagger_remaining = 0;
|
||||
return matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
||||
}
|
||||
self.stagger_remaining > 0
|
||||
}
|
||||
|
||||
pub(super) fn poll_burst_sends(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||
if self.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if self.burst_remaining == 0 {
|
||||
self.burst_remaining = self.targets.len();
|
||||
}
|
||||
let work = self.burst_remaining.min(TARGET_WORK_BUDGET);
|
||||
let mut immediate = false;
|
||||
for _ in 0..work {
|
||||
let index = self.send_cursor % self.targets.len();
|
||||
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
||||
self.burst_remaining -= 1;
|
||||
let result = self.poll_one_send(index, cx, now, None);
|
||||
immediate |= matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
||||
}
|
||||
immediate || self.burst_remaining > 0
|
||||
}
|
||||
}
|
||||
|
||||
// A release gate delays a send opportunity; it cannot create one.
|
||||
pub(super) fn gated_send_deadline(
|
||||
cadence: Option<Instant>,
|
||||
gate: Option<Instant>,
|
||||
) -> Option<Instant> {
|
||||
cadence.map(|deadline| gate.map_or(deadline, |gate| deadline.max(gate)))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
enum SendResult {
|
||||
NotAttempted,
|
||||
Pending,
|
||||
Ready { accepted: bool },
|
||||
Failed { accepted: bool },
|
||||
}
|
||||
|
||||
impl SendResult {
|
||||
fn accepted(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Ready { accepted: true } | Self::Failed { accepted: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn stagger_spacing(interval: Duration, active_targets: usize) -> Duration {
|
||||
let divisor = u128::try_from(active_targets.max(1)).unwrap_or(u128::MAX);
|
||||
let nanos = (interval.as_nanos() / divisor).max(1);
|
||||
let seconds = u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX);
|
||||
let subsec = u32::try_from(nanos % 1_000_000_000).unwrap_or(999_999_999);
|
||||
Duration::new(seconds, subsec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_gate_only_delays_an_existing_send_deadline() {
|
||||
let earlier = Instant::now();
|
||||
let later = earlier + Duration::from_secs(1);
|
||||
for (cadence, gate, expected) in [
|
||||
(None, None, None),
|
||||
(None, Some(earlier), None),
|
||||
(None, Some(later), None),
|
||||
(Some(earlier), None, Some(earlier)),
|
||||
(Some(later), Some(earlier), Some(later)),
|
||||
(Some(earlier), Some(earlier), Some(earlier)),
|
||||
(Some(earlier), Some(later), Some(later)),
|
||||
] {
|
||||
assert_eq!(gated_send_deadline(cadence, gate), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
//! Target-generation state, phase transitions, accounting, and completion.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
async_client::{AsyncClient, AsyncOpenState},
|
||||
managed::schedule::ProbeSchedule,
|
||||
managed::{
|
||||
classify_client_error, ManagedEvent, ManagedTargetConfig, ManagedTargetEndReason,
|
||||
ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase,
|
||||
ManagedTargetLifecycle, ManagedTargetOutcome, TargetInstance,
|
||||
},
|
||||
ClientConfig, ClientError, ClientEvent, OpenOutcome,
|
||||
};
|
||||
|
||||
use super::{ManagedClientTask, TARGET_WORK_BUDGET};
|
||||
|
||||
const POST_DEADLINE_RECEIVE_BUDGET: usize = TARGET_WORK_BUDGET;
|
||||
|
||||
type ConnectFuture =
|
||||
Pin<Box<dyn Future<Output = Result<AsyncClient, ClientError>> + Send + 'static>>;
|
||||
|
||||
pub(super) enum TargetPhase {
|
||||
Pending {
|
||||
client_config: ClientConfig,
|
||||
},
|
||||
Connecting {
|
||||
future: ConnectFuture,
|
||||
},
|
||||
Opening {
|
||||
client: AsyncClient,
|
||||
open: Box<AsyncOpenState>,
|
||||
},
|
||||
Active {
|
||||
client: AsyncClient,
|
||||
schedule: ProbeSchedule,
|
||||
send_waiting: bool,
|
||||
},
|
||||
Draining {
|
||||
client: AsyncClient,
|
||||
drain_started_at: Instant,
|
||||
deadline: Instant,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
post_deadline_receives_remaining: usize,
|
||||
},
|
||||
Closing {
|
||||
client: AsyncClient,
|
||||
deadline: Instant,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
},
|
||||
/// Terminal state always owns its finalized authoritative outcome.
|
||||
Terminal {
|
||||
outcome: Arc<ManagedTargetOutcome>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TargetPhase {
|
||||
pub(super) fn lifecycle(&self) -> ManagedTargetLifecycle {
|
||||
match self {
|
||||
Self::Pending { .. } => ManagedTargetLifecycle::Pending,
|
||||
Self::Connecting { .. } => ManagedTargetLifecycle::Connecting,
|
||||
Self::Opening { .. } => ManagedTargetLifecycle::Opening,
|
||||
Self::Active { .. } => ManagedTargetLifecycle::Active,
|
||||
Self::Draining { .. } => ManagedTargetLifecycle::Draining,
|
||||
Self::Closing { .. } => ManagedTargetLifecycle::Closing,
|
||||
Self::Terminal { .. } => ManagedTargetLifecycle::Terminal,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn client(&self) -> &AsyncClient {
|
||||
match self {
|
||||
Self::Opening { client, .. }
|
||||
| Self::Active { client, .. }
|
||||
| Self::Draining { client, .. }
|
||||
| Self::Closing { client, .. } => client,
|
||||
_ => panic!("managed target has no client in this phase"),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_client(self) -> AsyncClient {
|
||||
match self {
|
||||
Self::Opening { client, .. }
|
||||
| Self::Active { client, .. }
|
||||
| Self::Draining { client, .. }
|
||||
| Self::Closing { client, .. } => client,
|
||||
_ => panic!("managed target has no client in this phase"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct TargetCounters {
|
||||
packets_sent: u64,
|
||||
/// Unique echo replies that arrived in order and before their probe timed
|
||||
/// out.
|
||||
///
|
||||
/// A reply that arrives behind an already-received sequence, or after its
|
||||
/// own probe timed out, is counted by `late` and deliberately not here.
|
||||
/// These counters are durable, unlike the lossy managed presentation
|
||||
/// events.
|
||||
replies_received: u64,
|
||||
duplicates: u64,
|
||||
late: u64,
|
||||
warning_events: u64,
|
||||
}
|
||||
|
||||
impl TargetCounters {
|
||||
/// Account for one client event.
|
||||
///
|
||||
/// Events that carry no durable target-local count are ignored. Probe
|
||||
/// sends are not counted here: `packets_sent` is authoritative in the
|
||||
/// underlying client and is synchronized through
|
||||
/// [`TargetRuntime::sync_packets_sent`].
|
||||
fn observe(&mut self, event: &ClientEvent) {
|
||||
match event {
|
||||
ClientEvent::EchoReply { .. } => {
|
||||
self.replies_received = self.replies_received.saturating_add(1);
|
||||
}
|
||||
ClientEvent::DuplicateReply { .. } => {
|
||||
self.duplicates = self.duplicates.saturating_add(1);
|
||||
}
|
||||
ClientEvent::LateReply { .. } => {
|
||||
self.late = self.late.saturating_add(1);
|
||||
}
|
||||
ClientEvent::Warning { .. } => {
|
||||
self.warning_events = self.warning_events.saturating_add(1);
|
||||
}
|
||||
ClientEvent::EchoSent { .. }
|
||||
| ClientEvent::EchoLoss { .. }
|
||||
| ClientEvent::SessionStarted(_)
|
||||
| ClientEvent::NoTestCompleted(_)
|
||||
| ClientEvent::SessionClosed { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum RetirementReason {
|
||||
Removed,
|
||||
Replaced,
|
||||
}
|
||||
|
||||
impl From<RetirementReason> for ManagedTargetEndReason {
|
||||
fn from(reason: RetirementReason) -> Self {
|
||||
match reason {
|
||||
RetirementReason::Removed => Self::Removed,
|
||||
RetirementReason::Replaced => Self::Replaced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum TargetMembership {
|
||||
Desired,
|
||||
Withdrawn(RetirementReason),
|
||||
}
|
||||
|
||||
impl TargetMembership {
|
||||
pub(super) fn is_desired(&self) -> bool {
|
||||
matches!(self, Self::Desired)
|
||||
}
|
||||
|
||||
fn retirement_reason(&self) -> Option<RetirementReason> {
|
||||
match self {
|
||||
Self::Desired => None,
|
||||
Self::Withdrawn(reason) => Some(*reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct TargetRuntime {
|
||||
pub(super) instance: TargetInstance,
|
||||
pub(super) config: ManagedTargetConfig,
|
||||
pub(super) membership: TargetMembership,
|
||||
pub(super) server_addr: Arc<str>,
|
||||
pub(super) remote: Option<std::net::SocketAddr>,
|
||||
pub(super) counters: TargetCounters,
|
||||
// None only while a synchronous operation owns the phase locally.
|
||||
pub(super) phase: Option<TargetPhase>,
|
||||
}
|
||||
|
||||
impl TargetRuntime {
|
||||
pub(super) fn phase(&self) -> &TargetPhase {
|
||||
self.phase
|
||||
.as_ref()
|
||||
.expect("managed target phase is extracted")
|
||||
}
|
||||
|
||||
pub(super) fn phase_mut(&mut self) -> &mut TargetPhase {
|
||||
self.phase
|
||||
.as_mut()
|
||||
.expect("managed target phase is extracted")
|
||||
}
|
||||
|
||||
pub(super) fn take_phase(&mut self) -> TargetPhase {
|
||||
self.phase
|
||||
.take()
|
||||
.expect("managed target phase is extracted")
|
||||
}
|
||||
|
||||
pub(super) fn restore_phase(&mut self, phase: TargetPhase) {
|
||||
assert!(
|
||||
self.phase.is_none(),
|
||||
"managed target phase is already installed"
|
||||
);
|
||||
self.phase = Some(phase);
|
||||
}
|
||||
|
||||
pub(super) fn is_paced_active(&self) -> bool {
|
||||
let phase = self.phase();
|
||||
self.membership.is_desired() && matches!(phase, TargetPhase::Active { .. })
|
||||
}
|
||||
|
||||
/// Adopt the underlying client's authoritative sent count.
|
||||
///
|
||||
/// The client owns `packets_sent`; this runtime only mirrors it so a
|
||||
/// terminal outcome can report it after the client is gone.
|
||||
pub(super) fn sync_packets_sent(&mut self, client: &AsyncClient) {
|
||||
self.counters.packets_sent = client.packets_sent();
|
||||
}
|
||||
|
||||
pub(super) fn sync_live_packets_sent(&mut self) {
|
||||
self.counters.packets_sent = self.phase().client().packets_sent();
|
||||
}
|
||||
|
||||
/// Build this target's durable outcome.
|
||||
pub(super) fn outcome(
|
||||
&self,
|
||||
end_reason: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
) -> ManagedTargetOutcome {
|
||||
ManagedTargetOutcome {
|
||||
target: self.instance.clone(),
|
||||
server_addr: Arc::clone(&self.server_addr),
|
||||
remote: self.remote,
|
||||
end_reason,
|
||||
packets_sent: self.counters.packets_sent,
|
||||
replies_received: self.counters.replies_received,
|
||||
duplicates: self.counters.duplicates,
|
||||
late: self.counters.late,
|
||||
warning_events: self.counters.warning_events,
|
||||
cleanup_failure,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum OpenSessionFailureCleanup {
|
||||
Drain,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn install_target_phase(&mut self, index: usize, phase: TargetPhase) {
|
||||
let previous = self.targets[index].phase().lifecycle();
|
||||
self.targets[index].phase = Some(phase);
|
||||
self.publish_target_phase(index, previous);
|
||||
}
|
||||
|
||||
fn publish_target_phase(&mut self, index: usize, previous: ManagedTargetLifecycle) {
|
||||
let lifecycle = self.targets[index].phase().lifecycle();
|
||||
let desired = self.targets[index].membership.is_desired();
|
||||
let was_paced_active = desired && previous == ManagedTargetLifecycle::Active;
|
||||
let paced_active = self.targets[index].is_paced_active();
|
||||
let now = Instant::now();
|
||||
match (was_paced_active, paced_active) {
|
||||
(false, true) => self.stagger_target_added(now),
|
||||
(true, false) => self.stagger_target_removed(now),
|
||||
_ => {}
|
||||
}
|
||||
self.replace_status();
|
||||
self.publish_event(ManagedEvent::TargetStateChanged {
|
||||
target: self.targets[index].instance.clone(),
|
||||
lifecycle,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn publish_client_events(&mut self, index: usize, events: Vec<ClientEvent>) {
|
||||
for event in events {
|
||||
self.targets[index].counters.observe(&event);
|
||||
self.publish_event(ManagedEvent::Client {
|
||||
target: self.targets[index].instance.clone(),
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_target(
|
||||
&mut self,
|
||||
index: usize,
|
||||
end_reason: ManagedTargetEndReason,
|
||||
cleanup_failure: Option<ManagedTargetFailure>,
|
||||
) {
|
||||
let outcome = Arc::new(self.targets[index].outcome(end_reason, cleanup_failure));
|
||||
// Finalize history before the phase publisher installs Terminal, updates
|
||||
// pacing and publishes one complete status followed by the lifecycle event.
|
||||
self.history.record(outcome.as_ref().clone());
|
||||
self.install_target_phase(
|
||||
index,
|
||||
TargetPhase::Terminal {
|
||||
outcome: Arc::clone(&outcome),
|
||||
},
|
||||
);
|
||||
self.publish_event(ManagedEvent::TargetFinished { outcome });
|
||||
}
|
||||
|
||||
fn fail_target(&mut self, index: usize, phase: ManagedTargetFailurePhase, error: ClientError) {
|
||||
let failure = classify_client_error(phase, &error);
|
||||
self.finish_target(index, ManagedTargetEndReason::Failed(failure), None);
|
||||
}
|
||||
|
||||
pub(super) fn begin_open_session_failure(
|
||||
&mut self,
|
||||
index: usize,
|
||||
phase: ManagedTargetFailurePhase,
|
||||
error: ClientError,
|
||||
now: Instant,
|
||||
cleanup: OpenSessionFailureCleanup,
|
||||
) -> bool {
|
||||
let primary_end = ManagedTargetEndReason::Failed(classify_client_error(phase, &error));
|
||||
let (TargetPhase::Opening { client, .. } | TargetPhase::Active { client, .. }) =
|
||||
self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!("open-session failure requires an opening or active target")
|
||||
};
|
||||
client.discard_prepared_probe();
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
match cleanup {
|
||||
OpenSessionFailureCleanup::Drain => self.begin_drain(index, primary_end, now),
|
||||
OpenSessionFailureCleanup::Close => self.begin_close(index, primary_end, None, now),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn effective_retirement(&self, index: usize) -> Option<ManagedTargetEndReason> {
|
||||
self.targets[index]
|
||||
.membership
|
||||
.retirement_reason()
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn start_connecting(&mut self, index: usize) {
|
||||
let TargetPhase::Pending { client_config } = self.targets[index].take_phase() else {
|
||||
unreachable!("connecting starts from a pending target")
|
||||
};
|
||||
let endpoint = Arc::clone(&self.targets[index].server_addr);
|
||||
let future = Box::pin(async move { AsyncClient::connect(endpoint, client_config).await });
|
||||
self.targets[index].restore_phase(TargetPhase::Connecting { future });
|
||||
self.publish_target_phase(index, ManagedTargetLifecycle::Pending);
|
||||
}
|
||||
|
||||
pub(super) fn poll_target(&mut self, index: usize, cx: &mut Context<'_>, now: Instant) -> bool {
|
||||
let retirement = self.effective_retirement(index);
|
||||
let stopping = self.phase.is_stopping() || retirement.is_some();
|
||||
match self.targets[index].phase_mut() {
|
||||
TargetPhase::Pending { .. } => {
|
||||
if stopping {
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
None,
|
||||
);
|
||||
false
|
||||
} else {
|
||||
self.start_connecting(index);
|
||||
true
|
||||
}
|
||||
}
|
||||
TargetPhase::Connecting { future } => {
|
||||
if stopping {
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
None,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
match future.as_mut().poll(cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(client)) => {
|
||||
self.targets[index].remote = Some(client.remote_addr());
|
||||
self.install_target_phase(
|
||||
index,
|
||||
TargetPhase::Opening {
|
||||
client,
|
||||
open: Box::new(AsyncOpenState::new()),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.fail_target(index, ManagedTargetFailurePhase::Connecting, error);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
TargetPhase::Opening { client, open } => {
|
||||
if stopping {
|
||||
let retirement = retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped);
|
||||
if !open.has_in_flight_work() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(index, retirement, None);
|
||||
return false;
|
||||
}
|
||||
open.request_stop_after_current_attempt();
|
||||
}
|
||||
match client.poll_open(open, cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(OpenOutcome::Started(started))) => {
|
||||
let schedule = match ProbeSchedule::new(
|
||||
started.at.mono,
|
||||
&started.negotiation.accepted,
|
||||
) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(error) => {
|
||||
return self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Close,
|
||||
)
|
||||
}
|
||||
};
|
||||
self.publish_client_events(
|
||||
index,
|
||||
vec![ClientEvent::SessionStarted(started)],
|
||||
);
|
||||
if stopping {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.begin_drain(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
now,
|
||||
)
|
||||
} else {
|
||||
let previous = self.targets[index].take_phase();
|
||||
self.targets[index].restore_phase(TargetPhase::Active {
|
||||
client: previous.into_client(),
|
||||
schedule,
|
||||
send_waiting: false,
|
||||
});
|
||||
self.publish_target_phase(index, ManagedTargetLifecycle::Opening);
|
||||
true
|
||||
}
|
||||
}
|
||||
Poll::Ready(Ok(OpenOutcome::NoTestCompleted(completed))) => {
|
||||
self.publish_client_events(
|
||||
index,
|
||||
vec![ClientEvent::NoTestCompleted(completed)],
|
||||
);
|
||||
let end_reason = if stopping {
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped)
|
||||
} else {
|
||||
ManagedTargetEndReason::NoTestComplete
|
||||
};
|
||||
self.finish_target(index, end_reason, None);
|
||||
false
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
if stopping {
|
||||
let TargetPhase::Opening { open, .. } = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let cleanup_failure =
|
||||
(!open.stopped_after_current_attempt()).then(|| {
|
||||
classify_client_error(
|
||||
ManagedTargetFailurePhase::Opening,
|
||||
&error,
|
||||
)
|
||||
});
|
||||
self.finish_target(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
cleanup_failure,
|
||||
);
|
||||
} else {
|
||||
self.fail_target(index, ManagedTargetFailurePhase::Opening, error);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
TargetPhase::Active { client, .. } => {
|
||||
if stopping {
|
||||
client.discard_prepared_probe();
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
return self.begin_drain(
|
||||
index,
|
||||
retirement
|
||||
.clone()
|
||||
.unwrap_or(ManagedTargetEndReason::Stopped),
|
||||
now,
|
||||
);
|
||||
}
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let received = match client.poll_recv(cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(events)) => {
|
||||
self.publish_client_events(index, events);
|
||||
true
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
return self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Receiving,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Close,
|
||||
);
|
||||
}
|
||||
};
|
||||
if self.targets[index].phase().client().is_peer_closed() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(index, ManagedTargetEndReason::PeerClosed, None);
|
||||
return false;
|
||||
}
|
||||
let TargetPhase::Active {
|
||||
client, schedule, ..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
schedule.permit_probe_at(now);
|
||||
if schedule.is_finished() && !client.has_pending_probes() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
return self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
|
||||
}
|
||||
received
|
||||
}
|
||||
TargetPhase::Draining { client, .. } => {
|
||||
if client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let mut retained_state_changed = false;
|
||||
let received = match client.poll_recv(cx) {
|
||||
Poll::Pending => false,
|
||||
Poll::Ready(Ok(events)) => {
|
||||
self.publish_client_events(index, events);
|
||||
retained_state_changed = true;
|
||||
true
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let TargetPhase::Draining {
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
cleanup_failure.get_or_insert_with(|| {
|
||||
classify_client_error(ManagedTargetFailurePhase::Receiving, &error)
|
||||
});
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
return self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
}
|
||||
};
|
||||
if self.targets[index].phase().client().is_peer_closed() {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let TargetPhase::Draining {
|
||||
cleanup_failure, ..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
self.finish_target(
|
||||
index,
|
||||
ManagedTargetEndReason::PeerClosed,
|
||||
cleanup_failure.clone(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if retained_state_changed {
|
||||
let TargetPhase::Draining {
|
||||
client,
|
||||
drain_started_at,
|
||||
..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let candidate = self.drain_deadline(client, *drain_started_at);
|
||||
let TargetPhase::Draining {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
match candidate {
|
||||
Some(candidate) => *deadline = (*deadline).min(candidate),
|
||||
None => {
|
||||
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
return self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
let TargetPhase::Draining {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
post_deadline_receives_remaining,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
if now >= *deadline {
|
||||
if received && *post_deadline_receives_remaining > 1 {
|
||||
*post_deadline_receives_remaining -= 1;
|
||||
true
|
||||
} else {
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
self.begin_close(index, primary_end, cleanup_failure, now)
|
||||
}
|
||||
} else {
|
||||
received
|
||||
}
|
||||
}
|
||||
TargetPhase::Closing { client, .. } => {
|
||||
let result = client.poll_close(cx);
|
||||
let TargetPhase::Closing {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let deadline = *deadline;
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
match result {
|
||||
Poll::Pending => {
|
||||
if now >= deadline {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.finish_target(
|
||||
index,
|
||||
primary_end,
|
||||
cleanup_failure.or_else(|| Some(close_timeout_failure())),
|
||||
);
|
||||
}
|
||||
false
|
||||
}
|
||||
Poll::Ready(Ok(events)) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
self.publish_client_events(index, events);
|
||||
self.finish_target(index, primary_end, cleanup_failure);
|
||||
false
|
||||
}
|
||||
Poll::Ready(Err(error)) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let cleanup =
|
||||
classify_client_error(ManagedTargetFailurePhase::Closing, &error);
|
||||
self.finish_target(index, primary_end, cleanup_failure.or(Some(cleanup)));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
TargetPhase::Terminal { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_drain(
|
||||
&mut self,
|
||||
index: usize,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
_now: Instant,
|
||||
) -> bool {
|
||||
let drain_started_at = Instant::now();
|
||||
let Some(deadline) =
|
||||
self.drain_deadline(self.targets[index].phase().client(), drain_started_at)
|
||||
else {
|
||||
return self.begin_close(
|
||||
index,
|
||||
primary_end,
|
||||
Some(duration_overflow_failure()),
|
||||
drain_started_at,
|
||||
);
|
||||
};
|
||||
let previous = self.targets[index].take_phase();
|
||||
let lifecycle = previous.lifecycle();
|
||||
self.targets[index].restore_phase(TargetPhase::Draining {
|
||||
client: previous.into_client(),
|
||||
drain_started_at,
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure: None,
|
||||
post_deadline_receives_remaining: POST_DEADLINE_RECEIVE_BUDGET,
|
||||
});
|
||||
self.publish_target_phase(index, lifecycle);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn drain_deadline(
|
||||
&self,
|
||||
client: &AsyncClient,
|
||||
drain_started_at: Instant,
|
||||
) -> Option<Instant> {
|
||||
client
|
||||
.latest_probe_timeout_deadline()
|
||||
.unwrap_or(drain_started_at)
|
||||
.max(drain_started_at)
|
||||
.checked_add(self.config.final_drain)
|
||||
}
|
||||
|
||||
pub(super) fn begin_close(
|
||||
&mut self,
|
||||
index: usize,
|
||||
primary_end: ManagedTargetEndReason,
|
||||
mut cleanup_failure: Option<ManagedTargetFailure>,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
let deadline = now.checked_add(self.config.final_drain).unwrap_or_else(|| {
|
||||
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||
now
|
||||
});
|
||||
let previous = self.targets[index].take_phase();
|
||||
let lifecycle = previous.lifecycle();
|
||||
self.targets[index].restore_phase(TargetPhase::Closing {
|
||||
client: previous.into_client(),
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
});
|
||||
self.publish_target_phase(index, lifecycle);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn duration_overflow_failure() -> ManagedTargetFailure {
|
||||
classify_client_error(
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
&ClientError::DurationOverflow,
|
||||
)
|
||||
}
|
||||
|
||||
fn close_timeout_failure() -> ManagedTargetFailure {
|
||||
ManagedTargetFailure {
|
||||
phase: ManagedTargetFailurePhase::Closing,
|
||||
kind: ManagedTargetFailureKind::Timeout,
|
||||
message: Arc::from("best-effort close did not become writable before its deadline"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Bounded timeout discovery and processing across target generations.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::managed::{classify_client_error, ManagedTargetFailurePhase};
|
||||
|
||||
use super::{
|
||||
target::{duration_overflow_failure, OpenSessionFailureCleanup, TargetPhase},
|
||||
ManagedClientTask,
|
||||
};
|
||||
|
||||
pub(super) const TIMEOUT_WORK_BUDGET: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) struct TimeoutBacklogEntry {
|
||||
index: usize,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TimeoutStep {
|
||||
more_due: bool,
|
||||
}
|
||||
|
||||
impl TimeoutStep {
|
||||
const NONE: Self = Self { more_due: false };
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn poll_timeout_pass(&mut self, now: Instant) -> bool {
|
||||
let target_len = self.targets.len();
|
||||
if target_len == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut transitions_remaining = TIMEOUT_WORK_BUDGET;
|
||||
for _ in 0..target_len.min(TIMEOUT_WORK_BUDGET) {
|
||||
if transitions_remaining == 0 {
|
||||
break;
|
||||
}
|
||||
let index = self.timeout_cursor;
|
||||
self.timeout_cursor = (self.timeout_cursor + 1) % target_len;
|
||||
if self.target_has_due_timeout(index, now) {
|
||||
let generation = self.targets[index].instance.generation;
|
||||
let step = self.poll_target_timeout(index, now);
|
||||
transitions_remaining -= 1;
|
||||
if step.more_due {
|
||||
self.enqueue_timeout_backlog(index, generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while transitions_remaining > 0 {
|
||||
let Some(entry) = self.timeout_backlog.pop_front() else {
|
||||
break;
|
||||
};
|
||||
let valid = self
|
||||
.targets
|
||||
.get(entry.index)
|
||||
.is_some_and(|target| target.instance.generation == entry.generation)
|
||||
&& self.target_has_due_timeout(entry.index, now);
|
||||
if !valid {
|
||||
continue;
|
||||
}
|
||||
|
||||
let step = self.poll_target_timeout(entry.index, now);
|
||||
transitions_remaining -= 1;
|
||||
if step.more_due {
|
||||
self.enqueue_timeout_backlog(entry.index, entry.generation);
|
||||
}
|
||||
}
|
||||
|
||||
!self.timeout_backlog.is_empty()
|
||||
}
|
||||
|
||||
fn enqueue_timeout_backlog(&mut self, index: usize, generation: u64) {
|
||||
if self.timeout_backlog.len() == TIMEOUT_WORK_BUDGET
|
||||
|| self
|
||||
.timeout_backlog
|
||||
.iter()
|
||||
.any(|entry| entry.index == index && entry.generation == generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.timeout_backlog
|
||||
.push_back(TimeoutBacklogEntry { index, generation });
|
||||
}
|
||||
|
||||
fn target_has_due_timeout(&self, index: usize, now: Instant) -> bool {
|
||||
#[cfg(test)]
|
||||
self.timeout_inspections.borrow_mut().push(index);
|
||||
match self.targets[index].phase() {
|
||||
TargetPhase::Active { client, .. } => {
|
||||
!self.phase.is_stopping()
|
||||
&& self.effective_retirement(index).is_none()
|
||||
&& client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|deadline| deadline <= now)
|
||||
}
|
||||
TargetPhase::Draining { client, .. } => client
|
||||
.next_probe_timeout_deadline()
|
||||
.is_some_and(|timeout| timeout <= now),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_target_timeout(&mut self, index: usize, now: Instant) -> TimeoutStep {
|
||||
match self.targets[index].phase_mut() {
|
||||
TargetPhase::Active { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
|
||||
Ok(batch) => {
|
||||
self.publish_client_events(index, batch.events);
|
||||
TimeoutStep {
|
||||
more_due: batch.more_due,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.begin_open_session_failure(
|
||||
index,
|
||||
ManagedTargetFailurePhase::Timing,
|
||||
error,
|
||||
now,
|
||||
OpenSessionFailureCleanup::Close,
|
||||
);
|
||||
TimeoutStep::NONE
|
||||
}
|
||||
},
|
||||
TargetPhase::Draining { client, .. } => match client.poll_timeouts_bounded_at(now, 1) {
|
||||
Ok(batch) => {
|
||||
self.publish_client_events(index, batch.events);
|
||||
let TargetPhase::Draining {
|
||||
client,
|
||||
drain_started_at,
|
||||
..
|
||||
} = self.targets[index].phase()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let candidate = self.drain_deadline(client, *drain_started_at);
|
||||
let TargetPhase::Draining {
|
||||
deadline,
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
match candidate {
|
||||
Some(candidate) => *deadline = (*deadline).min(candidate),
|
||||
None => {
|
||||
cleanup_failure.get_or_insert_with(duration_overflow_failure);
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
return TimeoutStep::NONE;
|
||||
}
|
||||
}
|
||||
TimeoutStep {
|
||||
more_due: batch.more_due,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.targets[index].sync_live_packets_sent();
|
||||
let TargetPhase::Draining {
|
||||
primary_end,
|
||||
cleanup_failure,
|
||||
..
|
||||
} = self.targets[index].phase_mut()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
cleanup_failure.get_or_insert_with(|| {
|
||||
classify_client_error(ManagedTargetFailurePhase::Timing, &error)
|
||||
});
|
||||
let primary_end = primary_end.clone();
|
||||
let cleanup_failure = cleanup_failure.clone();
|
||||
self.begin_close(index, primary_end, cleanup_failure, now);
|
||||
TimeoutStep::NONE
|
||||
}
|
||||
},
|
||||
_ => TimeoutStep::NONE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::managed::{ManagedClientConfig, ManagedTargetConfig};
|
||||
|
||||
use super::{super::ManagedClient, Instant, TIMEOUT_WORK_BUDGET};
|
||||
|
||||
// Poll latency through public events cannot distinguish an O(n) discovery
|
||||
// scan from a bounded scan reliably. This tiny inspection trace measures
|
||||
// work directly, including empty targets, and checks eventual coverage
|
||||
// without asserting a cursor value or a particular visitation order.
|
||||
#[test]
|
||||
fn timeout_discovery_has_bounded_work_and_eventually_visits_every_target() {
|
||||
let count = TIMEOUT_WORK_BUDGET * 2 + 1;
|
||||
let targets = (0..count)
|
||||
.map(|i| ManagedTargetConfig::new(i.to_string(), "127.0.0.1:2112"))
|
||||
.collect();
|
||||
let (mut task, _handle) = ManagedClient::task(
|
||||
ManagedClientConfig {
|
||||
max_live_target_generations: count,
|
||||
..ManagedClientConfig::default()
|
||||
},
|
||||
targets,
|
||||
)
|
||||
.unwrap();
|
||||
let mut visited = HashSet::new();
|
||||
for _ in 0..count.div_ceil(TIMEOUT_WORK_BUDGET) {
|
||||
task.timeout_inspections.borrow_mut().clear();
|
||||
task.poll_timeout_pass(Instant::now());
|
||||
let inspected = task.timeout_inspections.borrow();
|
||||
assert!(inspected.len() <= TIMEOUT_WORK_BUDGET);
|
||||
visited.extend(inspected.iter().copied());
|
||||
}
|
||||
assert_eq!(
|
||||
visited.len(),
|
||||
count,
|
||||
"timeout discovery must reach all targets"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Atomic desired-target transactions, generation retirement, and pruning.
|
||||
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
managed::{
|
||||
ManagedCommandAcknowledgement, ManagedCommandApplyError, ManagedCompletionPolicy,
|
||||
ManagedEvent, ManagedTargetConfig, ManagedTargetLifecycle, TargetInstance,
|
||||
},
|
||||
ClientConfig,
|
||||
};
|
||||
|
||||
use super::{
|
||||
control::{validate_target_config, ManagedCommand, UpdateAdmission},
|
||||
target::{RetirementReason, TargetCounters, TargetMembership, TargetPhase, TargetRuntime},
|
||||
ManagedClientTask, TaskPhase,
|
||||
};
|
||||
|
||||
const COMMAND_WORK_BUDGET: usize = 32;
|
||||
|
||||
struct PlannedTarget {
|
||||
target: ManagedTargetConfig,
|
||||
client_config: ClientConfig,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
struct PlannedRetirement {
|
||||
index: usize,
|
||||
reason: RetirementReason,
|
||||
synchronous: bool,
|
||||
}
|
||||
|
||||
struct UpdatePlan {
|
||||
retirements: Vec<PlannedRetirement>,
|
||||
created: Vec<PlannedTarget>,
|
||||
next_generation: u64,
|
||||
next_command_sequence: u64,
|
||||
prospective_live_count: usize,
|
||||
}
|
||||
|
||||
fn synchronously_retireable(phase: &TargetPhase) -> bool {
|
||||
match phase {
|
||||
TargetPhase::Pending { .. }
|
||||
| TargetPhase::Connecting { .. }
|
||||
| TargetPhase::Terminal { .. } => true,
|
||||
TargetPhase::Opening { open, .. } => !open.has_in_flight_work(),
|
||||
TargetPhase::Active { .. } | TargetPhase::Draining { .. } | TargetPhase::Closing { .. } => {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_satisfies_target(runtime: &TargetRuntime, target: &ManagedTargetConfig) -> bool {
|
||||
runtime.membership.is_desired()
|
||||
&& !matches!(runtime.phase(), TargetPhase::Terminal { .. })
|
||||
&& runtime.config == *target
|
||||
}
|
||||
|
||||
impl ManagedClientTask {
|
||||
pub(super) fn process_commands(&mut self, cx: &mut Context<'_>) -> bool {
|
||||
let mut immediate = false;
|
||||
for _ in 0..COMMAND_WORK_BUDGET {
|
||||
match self.commands.poll_recv(cx) {
|
||||
Poll::Ready(Some(ManagedCommand::UpdateTargets {
|
||||
targets,
|
||||
acknowledgement,
|
||||
})) => {
|
||||
// This check is the driver-side application linearization point.
|
||||
// `apply_targets` cannot suspend, so a stop observed here wins over
|
||||
// this command; once it passes, this command may complete atomically.
|
||||
let result = if !self.phase.is_running()
|
||||
|| self.resources().stop.update_admission() != UpdateAdmission::Open
|
||||
{
|
||||
Err(ManagedCommandApplyError::Stopping)
|
||||
} else {
|
||||
self.apply_targets(targets)
|
||||
};
|
||||
let _ = acknowledgement.send(result);
|
||||
immediate = true;
|
||||
}
|
||||
Poll::Ready(None) | Poll::Pending => break,
|
||||
}
|
||||
}
|
||||
immediate
|
||||
}
|
||||
|
||||
fn apply_targets(
|
||||
&mut self,
|
||||
incoming: Vec<ManagedTargetConfig>,
|
||||
) -> Result<ManagedCommandAcknowledgement, ManagedCommandApplyError> {
|
||||
let plan = self.plan_targets(incoming)?;
|
||||
debug_assert!(plan.prospective_live_count <= self.config.max_live_target_generations);
|
||||
|
||||
let now = Instant::now();
|
||||
let mut synchronous = HashSet::with_capacity(plan.retirements.len());
|
||||
let mut finished = Vec::new();
|
||||
for retirement in &plan.retirements {
|
||||
let target = &mut self.targets[retirement.index];
|
||||
let was_paced_active = target.is_paced_active();
|
||||
target.membership = TargetMembership::Withdrawn(retirement.reason);
|
||||
if retirement.synchronous {
|
||||
synchronous.insert(retirement.index);
|
||||
if !matches!(target.phase(), TargetPhase::Terminal { .. }) {
|
||||
let outcome = Arc::new(target.outcome(retirement.reason.into(), None));
|
||||
self.history.record(outcome.as_ref().clone());
|
||||
finished.push(ManagedEvent::TargetFinished { outcome });
|
||||
}
|
||||
} else if was_paced_active {
|
||||
// Removal is directional: it may discard an elapsed gate but never
|
||||
// lengthens an existing future stagger gate.
|
||||
self.stagger_target_removed(now);
|
||||
}
|
||||
}
|
||||
if !synchronous.is_empty() {
|
||||
let mut index = 0;
|
||||
self.targets.retain(|_| {
|
||||
let keep = !synchronous.contains(&index);
|
||||
index += 1;
|
||||
keep
|
||||
});
|
||||
self.rebase_target_cursors();
|
||||
}
|
||||
|
||||
let mut created_instances = Vec::with_capacity(plan.created.len());
|
||||
for planned in plan.created {
|
||||
let instance = TargetInstance {
|
||||
id: planned.target.id.clone(),
|
||||
generation: planned.generation,
|
||||
};
|
||||
created_instances.push(instance.clone());
|
||||
self.targets.push(TargetRuntime {
|
||||
instance,
|
||||
server_addr: Arc::from(planned.target.server_addr.clone()),
|
||||
config: planned.target,
|
||||
membership: TargetMembership::Desired,
|
||||
remote: None,
|
||||
counters: TargetCounters::default(),
|
||||
phase: Some(TargetPhase::Pending {
|
||||
client_config: planned.client_config,
|
||||
}),
|
||||
});
|
||||
}
|
||||
self.next_generation = plan.next_generation;
|
||||
self.applied_command_sequence = plan.next_command_sequence;
|
||||
let stopping = self
|
||||
.targets
|
||||
.iter()
|
||||
.all(|runtime| !runtime.membership.is_desired())
|
||||
&& self.config.completion == ManagedCompletionPolicy::FinishWhenQuiescent;
|
||||
if stopping {
|
||||
self.resources().stop.begin_stopping();
|
||||
self.phase = TaskPhase::Stopping;
|
||||
}
|
||||
|
||||
// A transaction has one externally visible durable point: all runtime and
|
||||
// history changes above, then this exact status snapshot, then its events and
|
||||
// acknowledgement. Do not use per-transition publishing helpers here.
|
||||
let status = self.snapshot();
|
||||
self.resources().status.send_replace(Arc::clone(&status));
|
||||
if stopping {
|
||||
self.publish_event(ManagedEvent::Stopping);
|
||||
}
|
||||
for event in finished {
|
||||
self.publish_event(event);
|
||||
}
|
||||
for target in created_instances {
|
||||
self.publish_event(ManagedEvent::TargetStateChanged {
|
||||
target,
|
||||
lifecycle: ManagedTargetLifecycle::Pending,
|
||||
});
|
||||
}
|
||||
Ok(ManagedCommandAcknowledgement {
|
||||
sequence: self.applied_command_sequence,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_targets(
|
||||
&self,
|
||||
incoming: Vec<ManagedTargetConfig>,
|
||||
) -> Result<UpdatePlan, ManagedCommandApplyError> {
|
||||
let mut ids = HashSet::with_capacity(incoming.len());
|
||||
let mut prepared = Vec::with_capacity(incoming.len());
|
||||
for target in incoming {
|
||||
if !ids.insert(target.id.clone()) {
|
||||
return Err(ManagedCommandApplyError::DuplicateTargetId { id: target.id });
|
||||
}
|
||||
let mut client_config = self.config.client.clone();
|
||||
client_config.auth = target.auth.resolve(&self.config.client.auth);
|
||||
validate_target_config(&client_config).map_err(|source| {
|
||||
ManagedCommandApplyError::InvalidTarget {
|
||||
id: target.id.clone(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
prepared.push((target, client_config));
|
||||
}
|
||||
let mut retirements = Vec::new();
|
||||
for (index, runtime) in self.targets.iter().enumerate() {
|
||||
if !runtime.membership.is_desired()
|
||||
|| prepared
|
||||
.iter()
|
||||
.any(|(target, _)| runtime_satisfies_target(runtime, target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let reason = if prepared
|
||||
.iter()
|
||||
.any(|(target, _)| target.id == runtime.instance.id)
|
||||
{
|
||||
RetirementReason::Replaced
|
||||
} else {
|
||||
RetirementReason::Removed
|
||||
};
|
||||
retirements.push(PlannedRetirement {
|
||||
index,
|
||||
reason,
|
||||
synchronous: synchronously_retireable(runtime.phase()),
|
||||
});
|
||||
}
|
||||
|
||||
let mut next_generation = self.next_generation;
|
||||
let mut created = Vec::new();
|
||||
for (target, client_config) in prepared {
|
||||
if self
|
||||
.targets
|
||||
.iter()
|
||||
.any(|runtime| runtime_satisfies_target(runtime, &target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let generation = next_generation;
|
||||
next_generation = next_generation
|
||||
.checked_add(1)
|
||||
.ok_or(ManagedCommandApplyError::GenerationExhausted)?;
|
||||
created.push(PlannedTarget {
|
||||
target,
|
||||
client_config,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
let next_command_sequence = self
|
||||
.applied_command_sequence
|
||||
.checked_add(1)
|
||||
.ok_or(ManagedCommandApplyError::CommandSequenceExhausted)?;
|
||||
let synchronous = retirements
|
||||
.iter()
|
||||
.filter(|retirement| retirement.synchronous)
|
||||
.map(|retirement| retirement.index)
|
||||
.collect::<HashSet<_>>();
|
||||
let prospective_live_count = self
|
||||
.targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| !synchronous.contains(index))
|
||||
.count()
|
||||
.saturating_add(created.len());
|
||||
if prospective_live_count > self.config.max_live_target_generations {
|
||||
return Err(ManagedCommandApplyError::LiveGenerationLimitExceeded {
|
||||
required: prospective_live_count,
|
||||
limit: self.config.max_live_target_generations,
|
||||
});
|
||||
}
|
||||
Ok(UpdatePlan {
|
||||
retirements,
|
||||
created,
|
||||
next_generation,
|
||||
next_command_sequence,
|
||||
prospective_live_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prune_undesired_terminal(&mut self) {
|
||||
if !self.targets.iter().any(|target| {
|
||||
!target.membership.is_desired()
|
||||
&& matches!(target.phase(), TargetPhase::Terminal { .. })
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
self.targets.retain(|target| {
|
||||
target.membership.is_desired()
|
||||
|| !matches!(target.phase(), TargetPhase::Terminal { .. })
|
||||
});
|
||||
self.rebase_target_cursors();
|
||||
self.replace_status();
|
||||
}
|
||||
|
||||
fn rebase_target_cursors(&mut self) {
|
||||
let len = self.targets.len();
|
||||
if len == 0 {
|
||||
self.cursor = 0;
|
||||
self.timeout_cursor = 0;
|
||||
self.send_cursor = 0;
|
||||
} else {
|
||||
self.cursor %= len;
|
||||
self.timeout_cursor %= len;
|
||||
self.send_cursor %= len;
|
||||
}
|
||||
self.scan_remaining = self.scan_remaining.min(len);
|
||||
self.burst_remaining = self.burst_remaining.min(len);
|
||||
self.stagger_remaining = self.stagger_remaining.min(len);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,51 @@
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::event::PacketMeta;
|
||||
|
||||
/// Largest lag behind the paired userspace receive sample that a kernel
|
||||
/// receive timestamp may show and still be used as a measurement endpoint.
|
||||
///
|
||||
/// This is a sanity guard, not an expected kernel-to-userspace wakeup
|
||||
/// latency: ordinary wakeup latency is orders of magnitude smaller. A kernel
|
||||
/// timestamp this far behind the userspace sample most likely reflects a
|
||||
/// realtime clock discontinuity or otherwise unusable metadata, and falling
|
||||
/// back is preferable to injecting a bad cross-host timing sample.
|
||||
const MAX_KERNEL_RX_LAG: Duration = Duration::from_secs(1);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) struct ReceiveMeta {
|
||||
pub(crate) traffic_class: Option<u8>,
|
||||
pub(crate) kernel_rx_timestamp: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl ReceiveMeta {
|
||||
/// Receive wall-clock endpoint to use for downstream one-way delay.
|
||||
///
|
||||
/// Prefers an observed kernel receive timestamp, which is sampled earlier
|
||||
/// than the userspace receive instant and therefore excludes socket wakeup
|
||||
/// latency from the measured server-to-client delay. The kernel value is
|
||||
/// only used when it is plausible for the datagram that produced
|
||||
/// `userspace_wall`: it cannot be later than the userspace sample that
|
||||
/// observed the datagram, and it may not lag it by more than
|
||||
/// [`MAX_KERNEL_RX_LAG`]. Anything else falls back to `userspace_wall`.
|
||||
///
|
||||
/// Rejecting a kernel timestamp here never discards it as observed
|
||||
/// metadata: [`PacketMeta::kernel_rx_timestamp`] still reports the raw
|
||||
/// value.
|
||||
pub(crate) fn preferred_receive_wall(&self, userspace_wall: SystemTime) -> SystemTime {
|
||||
let Some(kernel_wall) = self.kernel_rx_timestamp else {
|
||||
return userspace_wall;
|
||||
};
|
||||
// `duration_since` fails exactly when the kernel timestamp is later
|
||||
// than the userspace sample, which cannot happen for a datagram that
|
||||
// sample observed.
|
||||
match userspace_wall.duration_since(kernel_wall) {
|
||||
Ok(lag) if lag <= MAX_KERNEL_RX_LAG => kernel_wall,
|
||||
_ => userspace_wall,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ReceiveMeta> for PacketMeta {
|
||||
fn from(meta: ReceiveMeta) -> Self {
|
||||
Self {
|
||||
@@ -18,73 +56,3 @@ impl From<ReceiveMeta> for PacketMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ReceiveMeta;
|
||||
use crate::event::PacketMeta;
|
||||
|
||||
#[test]
|
||||
fn metadata_default_is_unavailable() {
|
||||
let meta = ReceiveMeta::default();
|
||||
let packet_meta = PacketMeta::from(meta);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
+368
-118
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
time::Instant,
|
||||
time::{Instant, SystemTime},
|
||||
};
|
||||
|
||||
use crate::{error::ClientError, timing::ClientTimestamp};
|
||||
@@ -8,13 +8,60 @@ use crate::{error::ClientError, timing::ClientTimestamp};
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PendingProbe {
|
||||
pub wire_seq: u32,
|
||||
/// Paired client wall/monotonic timestamp captured immediately after the
|
||||
/// successful UDP send completed. This is the RTT endpoint (via
|
||||
/// `compute_rtt`), the userspace fallback endpoint for upstream one-way
|
||||
/// delay, `timer_error`'s basis, and the value surfaced on the public
|
||||
/// `EchoSent`/`EchoReply`/`EchoLoss`/`LateReply` events. It is *not* the
|
||||
/// input to `timeout_at` below — see that field's doc.
|
||||
pub sent_at: ClientTimestamp,
|
||||
/// Operational timeout deadline, computed from the pre-send send
|
||||
/// anchor's monotonic timestamp plus the configured probe timeout
|
||||
/// (`SessionMachine::finalize_probe_commit`), not from `sent_at.mono`
|
||||
/// above. Timeout semantics deliberately stay anchored to before the
|
||||
/// socket send so that fallible deadline arithmetic (and its overflow
|
||||
/// check) still runs, and a `PendingProbe` is only ever created, before
|
||||
/// the datagram is transmitted.
|
||||
pub timeout_at: Instant,
|
||||
/// Local wall-clock lower bound for validating an asynchronous Linux
|
||||
/// kernel `TX_SOFTWARE` timestamp, sampled at the same pre-send send
|
||||
/// anchor as `timeout_at` — i.e. before the socket send, not from
|
||||
/// `sent_at.wall` above. A legitimate `TX_SOFTWARE` timestamp can be
|
||||
/// generated during the send path and therefore observed earlier than
|
||||
/// the post-send `sent_at.wall` sample; using this earlier pre-send
|
||||
/// bound instead of `sent_at.wall` avoids rejecting such a timestamp
|
||||
/// merely because userspace samples `sent_at` after `send()` returns.
|
||||
/// See `compute_one_way`'s `preferred_send_wall`.
|
||||
pub tx_not_before_wall: SystemTime,
|
||||
/// Observed Linux kernel TX_SOFTWARE wall timestamp for this probe's
|
||||
/// send, when the socket has TX timestamping enabled and a matching
|
||||
/// `MSG_ERRQUEUE` record has been drained. Optional observed metadata,
|
||||
/// eligible only for upstream one-way delay after local plausibility
|
||||
/// validation against `tx_not_before_wall` (see `compute_one_way`'s
|
||||
/// `preferred_send_wall`). `sent_at` remains the userspace fallback for
|
||||
/// upstream one-way delay when this is absent, implausible, or kernel-ID
|
||||
/// correlation was invalidated by a failed probe submission.
|
||||
pub kernel_tx_timestamp: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingEntry {
|
||||
probe: PendingProbe,
|
||||
previous: Option<u32>,
|
||||
next: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExpiredBatch {
|
||||
pub probes: Vec<PendingProbe>,
|
||||
pub more_due: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PendingMap {
|
||||
map: HashMap<u32, PendingProbe>,
|
||||
map: HashMap<u32, PendingEntry>,
|
||||
first: Option<u32>,
|
||||
last: Option<u32>,
|
||||
max_capacity: usize,
|
||||
}
|
||||
|
||||
@@ -22,112 +69,234 @@ impl PendingMap {
|
||||
pub fn new(max_capacity: usize) -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
first: None,
|
||||
last: None,
|
||||
max_capacity,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_capacity(&self) -> Result<(), ClientError> {
|
||||
pub fn preflight_insert(&mut self, wire_seq: u32) -> Result<(), ClientError> {
|
||||
if self.map.contains_key(&wire_seq) {
|
||||
return Err(ClientError::PendingSequenceCollision { seq: wire_seq });
|
||||
}
|
||||
if self.map.len() >= self.max_capacity {
|
||||
return Err(ClientError::PendingLimitExceeded {
|
||||
limit: self.max_capacity,
|
||||
});
|
||||
}
|
||||
if self.map.len() == self.map.capacity() {
|
||||
self.map
|
||||
.try_reserve(1)
|
||||
.map_err(|source| ClientError::AllocationFailed {
|
||||
operation: "pending probe storage",
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, probe: PendingProbe) -> Result<(), ClientError> {
|
||||
self.check_capacity()?;
|
||||
self.map.insert(probe.wire_seq, probe);
|
||||
Ok(())
|
||||
pub fn commit_insert(&mut self, probe: PendingProbe) {
|
||||
let wire_seq = probe.wire_seq;
|
||||
let previous = self.last;
|
||||
if let Some(previous) = previous {
|
||||
let previous = self
|
||||
.map
|
||||
.get_mut(&previous)
|
||||
.expect("pending list tail remains present");
|
||||
// probe_timeout is fixed for an open session and committed sends
|
||||
// have nondecreasing monotonic timestamps, so deadlines append.
|
||||
debug_assert!(previous.probe.timeout_at <= probe.timeout_at);
|
||||
debug_assert!(
|
||||
previous.next.is_none(),
|
||||
"pending list tail has no successor"
|
||||
);
|
||||
previous.next = Some(wire_seq);
|
||||
} else {
|
||||
debug_assert!(self.first.is_none(), "empty pending list has no head");
|
||||
self.first = Some(wire_seq);
|
||||
}
|
||||
|
||||
let replaced = self.map.insert(
|
||||
wire_seq,
|
||||
PendingEntry {
|
||||
probe,
|
||||
previous,
|
||||
next: None,
|
||||
},
|
||||
);
|
||||
debug_assert!(replaced.is_none(), "preflight rejected pending collision");
|
||||
self.last = Some(wire_seq);
|
||||
self.assert_links_consistent();
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, wire_seq: u32) -> Option<PendingProbe> {
|
||||
self.map.remove(&wire_seq)
|
||||
}
|
||||
let PendingEntry {
|
||||
probe,
|
||||
previous,
|
||||
next,
|
||||
} = self.map.remove(&wire_seq)?;
|
||||
|
||||
pub fn drain_expired(&mut self, now: Instant) -> Vec<PendingProbe> {
|
||||
let expired_keys: Vec<u32> = self
|
||||
.map
|
||||
.iter()
|
||||
.filter(|(_, probe)| probe.timeout_at <= now)
|
||||
.map(|(key, _)| *key)
|
||||
.collect();
|
||||
let mut expired = Vec::with_capacity(expired_keys.len());
|
||||
for key in expired_keys {
|
||||
if let Some(probe) = self.map.remove(&key) {
|
||||
expired.push(probe);
|
||||
}
|
||||
if let Some(previous) = previous {
|
||||
let previous = self
|
||||
.map
|
||||
.get_mut(&previous)
|
||||
.expect("pending list predecessor remains present");
|
||||
debug_assert_eq!(previous.next, Some(wire_seq));
|
||||
previous.next = next;
|
||||
} else {
|
||||
debug_assert_eq!(self.first, Some(wire_seq));
|
||||
self.first = next;
|
||||
}
|
||||
expired.sort_by_key(|p| p.sent_at.mono);
|
||||
expired
|
||||
if let Some(next) = next {
|
||||
let next = self
|
||||
.map
|
||||
.get_mut(&next)
|
||||
.expect("pending list successor remains present");
|
||||
debug_assert_eq!(next.previous, Some(wire_seq));
|
||||
next.previous = previous;
|
||||
} else {
|
||||
debug_assert_eq!(self.last, Some(wire_seq));
|
||||
self.last = previous;
|
||||
}
|
||||
|
||||
self.assert_links_consistent();
|
||||
Some(probe)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
pub(crate) fn drain_expired_bounded(&mut self, now: Instant, limit: usize) -> ExpiredBatch {
|
||||
let mut probes = Vec::with_capacity(limit.min(self.map.len()));
|
||||
while probes.len() < limit {
|
||||
let Some(wire_seq) = self.first else {
|
||||
break;
|
||||
};
|
||||
let timeout_at = self
|
||||
.map
|
||||
.get(&wire_seq)
|
||||
.expect("pending list head remains present")
|
||||
.probe
|
||||
.timeout_at;
|
||||
if timeout_at > now {
|
||||
break;
|
||||
}
|
||||
|
||||
let probe = self
|
||||
.remove(wire_seq)
|
||||
.expect("pending list head remains present");
|
||||
probes.push(probe);
|
||||
}
|
||||
let more_due = self
|
||||
.first
|
||||
.and_then(|wire_seq| self.map.get(&wire_seq))
|
||||
.is_some_and(|entry| entry.probe.timeout_at <= now);
|
||||
ExpiredBatch { probes, more_due }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.map.is_empty()
|
||||
}
|
||||
|
||||
/// Mutable access to a still-pending probe by wire sequence, without
|
||||
/// disturbing its position in the timeout order. Used to attach an
|
||||
/// observed kernel TX timestamp to a probe that has not yet completed or
|
||||
/// timed out.
|
||||
pub fn get_mut(&mut self, wire_seq: u32) -> Option<&mut PendingProbe> {
|
||||
self.map.get_mut(&wire_seq).map(|entry| &mut entry.probe)
|
||||
}
|
||||
|
||||
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(feature = "tokio")]
|
||||
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.last
|
||||
.and_then(|wire_seq| self.map.get(&wire_seq))
|
||||
.map(|entry| entry.probe.timeout_at)
|
||||
}
|
||||
|
||||
fn assert_links_consistent(&self) {
|
||||
debug_assert_eq!(self.first.is_none(), self.map.is_empty());
|
||||
debug_assert_eq!(self.last.is_none(), self.map.is_empty());
|
||||
if let Some(first) = self.first {
|
||||
debug_assert_eq!(
|
||||
self.map
|
||||
.get(&first)
|
||||
.expect("pending list head remains present")
|
||||
.previous,
|
||||
None
|
||||
);
|
||||
}
|
||||
if let Some(last) = self.last {
|
||||
debug_assert_eq!(
|
||||
self.map
|
||||
.get(&last)
|
||||
.expect("pending list tail remains present")
|
||||
.next,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TimedOutMap {
|
||||
map: HashMap<u32, PendingProbe>,
|
||||
insertion_order: VecDeque<u32>,
|
||||
max_capacity: usize,
|
||||
// Timeout transitions consume PendingMap's head in deadline order. Reuse
|
||||
// its linked storage in a separate map: the head owns FIFO eviction and
|
||||
// the tail owns the latest retained timeout, even after arbitrary replies.
|
||||
probes: PendingMap,
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
deadline_inspections: std::cell::Cell<usize>,
|
||||
}
|
||||
|
||||
impl TimedOutMap {
|
||||
pub fn new(max_capacity: usize) -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
insertion_order: VecDeque::new(),
|
||||
max_capacity,
|
||||
probes: PendingMap::new(max_capacity),
|
||||
#[cfg(all(test, feature = "tokio"))]
|
||||
deadline_inspections: std::cell::Cell::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, probe: PendingProbe) {
|
||||
if self.max_capacity == 0 {
|
||||
if self.probes.max_capacity == 0 {
|
||||
return;
|
||||
}
|
||||
if let std::collections::hash_map::Entry::Occupied(mut entry) =
|
||||
self.map.entry(probe.wire_seq)
|
||||
{
|
||||
entry.insert(probe);
|
||||
return;
|
||||
// A sequence cannot be both pending and retained: committing its next
|
||||
// generation removes the old retention before adding the pending probe.
|
||||
debug_assert!(!self.probes.map.contains_key(&probe.wire_seq));
|
||||
if self.probes.map.len() == self.probes.max_capacity {
|
||||
self.probes
|
||||
.remove(self.probes.first.expect("full retention has a head"));
|
||||
}
|
||||
while self.map.len() >= self.max_capacity {
|
||||
self.evict_oldest();
|
||||
}
|
||||
self.insertion_order.push_back(probe.wire_seq);
|
||||
self.map.insert(probe.wire_seq, probe);
|
||||
// Unlike pending admission, retention always makes room by eviction.
|
||||
// Allocation here remains infallible as it was for the previous map.
|
||||
self.probes.commit_insert(probe);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, wire_seq: u32) -> Option<PendingProbe> {
|
||||
let removed = self.map.remove(&wire_seq);
|
||||
if removed.is_some() {
|
||||
self.insertion_order.retain(|seq| *seq != wire_seq);
|
||||
}
|
||||
removed
|
||||
self.probes.remove(wire_seq)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.insertion_order.clear();
|
||||
/// Mutable access to a still-retained timed-out probe by wire sequence,
|
||||
/// without disturbing eviction order. Used to attach an observed kernel
|
||||
/// TX timestamp that arrives after the probe has already timed out.
|
||||
pub fn get_mut(&mut self, wire_seq: u32) -> Option<&mut PendingProbe> {
|
||||
self.probes.get_mut(wire_seq)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.map.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn insertion_order_len(&self) -> usize {
|
||||
self.insertion_order.len()
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
while let Some(oldest_key) = self.insertion_order.pop_front() {
|
||||
if self.map.remove(&oldest_key).is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "tokio")]
|
||||
pub fn latest_timeout_deadline(&self) -> Option<Instant> {
|
||||
self.probes
|
||||
.last
|
||||
.and_then(|wire_seq| self.probes.map.get(&wire_seq))
|
||||
.map(|entry| {
|
||||
#[cfg(test)]
|
||||
self.deadline_inspections
|
||||
.set(self.deadline_inspections.get() + 1);
|
||||
entry.probe.timeout_at
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +333,14 @@ impl CompletedSet {
|
||||
self.set.contains(&seq)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, seq: u32) -> bool {
|
||||
let removed = self.set.remove(&seq);
|
||||
if removed {
|
||||
self.insertion_order.retain(|entry| *entry != seq);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn evict_oldest(&mut self) {
|
||||
while let Some(oldest_seq) = self.insertion_order.pop_front() {
|
||||
if self.set.remove(&oldest_seq) {
|
||||
@@ -186,78 +363,151 @@ mod tests {
|
||||
}
|
||||
|
||||
fn pending(seq: u32, timeout_at: Instant) -> PendingProbe {
|
||||
let sent_at = ts(timeout_at - Duration::from_secs(1));
|
||||
PendingProbe {
|
||||
wire_seq: seq,
|
||||
sent_at: ts(timeout_at - Duration::from_secs(1)),
|
||||
sent_at,
|
||||
timeout_at,
|
||||
tx_not_before_wall: sent_at.wall,
|
||||
kernel_tx_timestamp: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_map_insert_and_remove() {
|
||||
let mut map = PendingMap::new(10);
|
||||
let now = Instant::now();
|
||||
let probe = pending(0, now + Duration::from_secs(4));
|
||||
map.insert(probe).unwrap();
|
||||
assert_eq!(map.len(), 1);
|
||||
assert!(map.remove(0).is_some());
|
||||
assert_eq!(map.len(), 0);
|
||||
assert!(map.remove(0).is_none());
|
||||
fn pending_removal_and_sequence_reuse_preserve_expiry_and_capacity() {
|
||||
let start = Instant::now();
|
||||
// Remove the head, middle or tail, then reuse that sequence at a later
|
||||
// deadline. Observe expiry and capacity, not private list links.
|
||||
for removed in 0..3 {
|
||||
let mut pending_map = PendingMap::new(3);
|
||||
for seq in 0..3 {
|
||||
pending_map.preflight_insert(seq).unwrap();
|
||||
pending_map
|
||||
.commit_insert(pending(seq, start + Duration::from_secs(u64::from(seq))));
|
||||
}
|
||||
assert!(matches!(
|
||||
pending_map.preflight_insert(3),
|
||||
Err(ClientError::PendingLimitExceeded { .. })
|
||||
));
|
||||
assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed);
|
||||
pending_map.preflight_insert(removed).unwrap();
|
||||
let reused_deadline = start + Duration::from_secs(4);
|
||||
pending_map.commit_insert(pending(removed, reused_deadline));
|
||||
|
||||
let remaining = (0..3).filter(|seq| *seq != removed).collect::<Vec<_>>();
|
||||
for (index, seq) in remaining.iter().enumerate() {
|
||||
assert_eq!(
|
||||
pending_map.next_timeout_deadline(),
|
||||
Some(start + Duration::from_secs(u64::from(*seq)))
|
||||
);
|
||||
let expired = pending_map.drain_expired_bounded(start + Duration::from_secs(3), 1);
|
||||
assert_eq!(
|
||||
expired
|
||||
.probes
|
||||
.iter()
|
||||
.map(|probe| probe.wire_seq)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![*seq]
|
||||
);
|
||||
assert_eq!(expired.more_due, index == 0);
|
||||
}
|
||||
assert_eq!(pending_map.next_timeout_deadline(), Some(reused_deadline));
|
||||
let expired = pending_map.drain_expired_bounded(reused_deadline, 3);
|
||||
assert_eq!(expired.probes.len(), 1);
|
||||
assert_eq!(expired.probes[0].wire_seq, removed);
|
||||
assert!(!expired.more_due);
|
||||
assert!(pending_map.is_empty());
|
||||
assert_eq!(pending_map.next_timeout_deadline(), None);
|
||||
pending_map.preflight_insert(removed).unwrap();
|
||||
pending_map.commit_insert(pending(removed, reused_deadline));
|
||||
assert_eq!(pending_map.remove(removed).unwrap().wire_seq, removed);
|
||||
assert!(pending_map.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// Measure deadline inspection work directly: wall-clock thresholds cannot
|
||||
// reliably distinguish a bounded lookup from a retention scan.
|
||||
#[cfg(feature = "tokio")]
|
||||
#[test]
|
||||
fn pending_map_rejects_over_capacity() {
|
||||
let mut map = PendingMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.insert(pending(0, now + Duration::from_secs(4)))
|
||||
.unwrap();
|
||||
map.insert(pending(1, now + Duration::from_secs(4)))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
map.insert(pending(2, now + Duration::from_secs(4))),
|
||||
Err(ClientError::PendingLimitExceeded { limit: 2 })
|
||||
));
|
||||
fn timeout_retention_deadlines_have_bounded_lookup_work() {
|
||||
const COUNT: u32 = 4096;
|
||||
let start = Instant::now();
|
||||
let deadline = |seq| start + Duration::from_micros(u64::from(seq / 2));
|
||||
let mut pending_map = PendingMap::new(COUNT as usize);
|
||||
let mut timed_out = TimedOutMap::new(COUNT as usize);
|
||||
for seq in 0..COUNT {
|
||||
pending_map.preflight_insert(seq).unwrap();
|
||||
pending_map.commit_insert(pending(seq, deadline(seq)));
|
||||
}
|
||||
let last_deadline = deadline(COUNT - 1);
|
||||
for seq in 0..COUNT {
|
||||
let batch = pending_map.drain_expired_bounded(last_deadline, 1);
|
||||
assert_eq!(batch.probes.len(), 1);
|
||||
assert_eq!(batch.probes[0].wire_seq, seq);
|
||||
assert_eq!(batch.more_due, seq + 1 < COUNT);
|
||||
timed_out.insert(batch.probes.into_iter().next().unwrap());
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
|
||||
assert_eq!(
|
||||
pending_map
|
||||
.latest_timeout_deadline()
|
||||
.into_iter()
|
||||
.chain(timed_out.latest_timeout_deadline())
|
||||
.max(),
|
||||
Some(last_deadline)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
timed_out.deadline_inspections.replace(0) <= 2 * COUNT as usize,
|
||||
"draining must not scan growing timeout retention after each transition"
|
||||
);
|
||||
// A middle/head removal leaves the latest deadline intact. Removing
|
||||
// the newest survivors must walk back through equal deadlines too.
|
||||
assert!(timed_out.remove(COUNT / 2).is_some());
|
||||
assert!(timed_out.remove(0).is_some());
|
||||
for seq in (1..COUNT).rev().filter(|seq| *seq != COUNT / 2) {
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), Some(deadline(seq)));
|
||||
assert!(timed_out.remove(seq).is_some());
|
||||
}
|
||||
assert_eq!(timed_out.latest_timeout_deadline(), None);
|
||||
assert!(timed_out.deadline_inspections.get() <= COUNT as usize);
|
||||
}
|
||||
|
||||
// Public loss/reply events cannot reveal leaked retention bookkeeping.
|
||||
#[test]
|
||||
fn completed_set_tracks_and_evicts() {
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_map_tracks_and_evicts() {
|
||||
let mut map = TimedOutMap::new(2);
|
||||
let now = Instant::now();
|
||||
map.insert(pending(0, now));
|
||||
map.insert(pending(1, now));
|
||||
map.insert(pending(2, now));
|
||||
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.remove(0).is_none());
|
||||
assert!(map.remove(1).is_some());
|
||||
assert!(map.remove(2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_map_remove_prunes_insertion_order() {
|
||||
fn timed_out_retention_preserves_fifo_eviction_and_stays_bounded() {
|
||||
let mut map = TimedOutMap::new(4);
|
||||
let now = Instant::now();
|
||||
|
||||
for i in 0..20 {
|
||||
map.insert(pending(i, now));
|
||||
assert!(map.remove(i).is_some());
|
||||
assert_eq!(map.len(), 0);
|
||||
assert_eq!(map.insertion_order_len(), 0);
|
||||
assert!(map.probes.map.len() <= 4);
|
||||
}
|
||||
|
||||
// Equal deadlines across wire-sequence wrap must still evict by
|
||||
// insertion order. Removal at any position frees capacity immediately.
|
||||
let sequences = [u32::MAX - 1, u32::MAX, 0, 1];
|
||||
for removed in sequences {
|
||||
let mut map = TimedOutMap::new(4);
|
||||
for seq in sequences {
|
||||
map.insert(pending(seq, now));
|
||||
}
|
||||
assert!(map.remove(removed).is_some());
|
||||
map.insert(pending(2, now));
|
||||
let oldest = sequences.into_iter().find(|seq| *seq != removed).unwrap();
|
||||
map.insert(pending(3, now));
|
||||
assert!(map.remove(oldest).is_none(), "oldest survivor is evicted");
|
||||
for seq in sequences
|
||||
.into_iter()
|
||||
.filter(|seq| *seq != removed && *seq != oldest)
|
||||
{
|
||||
assert!(map.remove(seq).is_some());
|
||||
}
|
||||
assert!(map.remove(2).is_some());
|
||||
assert!(map.remove(3).is_some());
|
||||
assert!(map.probes.map.is_empty());
|
||||
#[cfg(feature = "tokio")]
|
||||
assert_eq!(map.latest_timeout_deadline(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
io,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use crate::{metadata::ReceiveMeta, timing::ClientTimestamp};
|
||||
@@ -38,6 +39,32 @@ pub(crate) fn recv_datagram(
|
||||
linux::recv_datagram(socket, buf)
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
feature = "tokio",
|
||||
not(all(target_os = "linux", feature = "ancillary"))
|
||||
))]
|
||||
pub(crate) fn try_recv_tokio_datagram(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
buf: &mut [u8],
|
||||
) -> Result<ReceivedDatagram, io::Error> {
|
||||
let len = socket.try_recv(buf)?;
|
||||
let received_at = ClientTimestamp::now();
|
||||
|
||||
Ok(ReceivedDatagram {
|
||||
len,
|
||||
received_at,
|
||||
meta: ReceiveMeta::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tokio", target_os = "linux", feature = "ancillary"))]
|
||||
pub(crate) fn try_recv_tokio_datagram(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
buf: &mut [u8],
|
||||
) -> Result<ReceivedDatagram, io::Error> {
|
||||
linux::try_recv_tokio_datagram(socket, buf)
|
||||
}
|
||||
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
pub(crate) fn configure_receive_metadata(
|
||||
_socket: &UdpSocket,
|
||||
@@ -51,54 +78,36 @@ pub(crate) fn configure_receive_metadata(socket: &UdpSocket, remote: SocketAddr)
|
||||
linux::configure_receive_metadata(socket, remote)
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
/// Best-effort upgrade a socket from RX-only to RX+TX kernel software
|
||||
/// timestamping. Returns whether the upgrade succeeded; a caller should
|
||||
/// track this to skip later drains entirely rather than perform harmless
|
||||
/// no-op ones. Always returns `false` off Linux or without the `ancillary`
|
||||
/// feature, so callers never need their own platform `cfg`.
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
pub(crate) fn try_enable_tx_timestamping<S>(_socket: &S) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
pub(crate) fn try_enable_tx_timestamping<S: std::os::fd::AsFd>(socket: &S) -> bool {
|
||||
linux::try_enable_tx_timestamping(socket)
|
||||
}
|
||||
|
||||
/// Bounded, nonblocking drain of `socket`'s `MSG_ERRQUEUE`, reporting each
|
||||
/// usable TX timestamp completion to `on_timestamp`. Always a no-op off
|
||||
/// Linux or without the `ancillary` feature.
|
||||
#[cfg(not(all(target_os = "linux", feature = "ancillary")))]
|
||||
pub(crate) fn drain_tx_timestamps<S>(
|
||||
_socket: &S,
|
||||
_on_timestamp: impl FnMut(u32, SystemTime),
|
||||
) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", feature = "ancillary"))]
|
||||
pub(crate) fn drain_tx_timestamps<S: std::os::fd::AsRawFd>(
|
||||
socket: &S,
|
||||
on_timestamp: impl FnMut(u32, SystemTime),
|
||||
) -> io::Result<()> {
|
||||
linux::drain_tx_timestamps(socket, on_timestamp)
|
||||
}
|
||||
@@ -1,18 +1,136 @@
|
||||
#![allow(unsafe_code)]
|
||||
//! Linux ancillary receive and transmit metadata.
|
||||
//!
|
||||
//! Receive kernel timing uses `SO_TIMESTAMPING` with `RX_SOFTWARE` +
|
||||
//! `SOFTWARE` (reported via `SCM_TIMESTAMPING`), not the older
|
||||
//! `SO_TIMESTAMPNS`/`SCM_TIMESTAMPNS`. Linux documents fallback quirks when
|
||||
//! `SO_TIMESTAMP[NS]` and `SO_TIMESTAMPING` are both enabled on one socket,
|
||||
//! so this establishes one unambiguous software-timestamp facility. `ReceiveMeta`'s
|
||||
//! semantics — an optional observed kernel receive wall time, used for
|
||||
//! downstream one-way delay only when plausible relative to the userspace
|
||||
//! receive sample — are unchanged by any of this module's TX-side additions.
|
||||
//!
|
||||
//! After a successful Open, the client adapter best-effort upgrades the
|
||||
//! socket from [`RX_TIMESTAMPING_FLAGS`] to [`error_queue::TX_TIMESTAMPING_FLAGS`]
|
||||
//! via [`try_enable_tx_timestamping`]. When that succeeds, the automatic
|
||||
//! `SOF_TIMESTAMPING_OPT_ID` counter normally tracks the probe wire sequence
|
||||
//! after successful sends, so it is used as a best-effort correlation ID.
|
||||
//! A kernel ID can theoretically be consumed by a send that later fails,
|
||||
//! desynchronizing that correlation. [`drain_tx_timestamps`] discards
|
||||
//! unmatched or implausible records; the userspace `sent_at` timestamp remains
|
||||
//! the fallback. After a probe send failure, correlation is disabled for that
|
||||
//! 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. A plausible kernel TX wall time is used only
|
||||
//! for upstream one-way delay; RTT retains the userspace send instant.
|
||||
|
||||
use std::{
|
||||
io, mem,
|
||||
io::{self, IoSliceMut},
|
||||
net::{SocketAddr, UdpSocket},
|
||||
os::fd::AsRawFd,
|
||||
ptr,
|
||||
os::fd::{AsFd, AsRawFd, RawFd},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nix::sys::{
|
||||
socket::{
|
||||
cmsg_space, recvmsg, setsockopt, sockopt, ControlMessageOwned, MsgFlags, RecvMsg,
|
||||
TimestampingFlag, Timestamps,
|
||||
},
|
||||
time::TimeSpec,
|
||||
};
|
||||
|
||||
use crate::{metadata::ReceiveMeta, receive::ReceivedDatagram, timing::ClientTimestamp};
|
||||
|
||||
const CONTROL_LEN: usize = 128;
|
||||
pub(crate) mod error_queue;
|
||||
|
||||
use error_queue::ErrorQueueRecord;
|
||||
|
||||
/// Starvation guard on one opportunistic `MSG_ERRQUEUE` drain.
|
||||
///
|
||||
/// Each successfully submitted, TX-timestamped datagram generates at most
|
||||
/// one record, and drains happen at several natural choke points (after a
|
||||
/// probe send, after a normal receive, before timeout expiry), so this is
|
||||
/// not a queue-capacity promise: it just bounds the work one drain call can
|
||||
/// do. A handful of in-flight probes' worth of backlog is the realistic
|
||||
/// case between choke points, so a small constant comfortably covers it
|
||||
/// without any unbounded loop.
|
||||
pub(crate) const MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN: usize = 32;
|
||||
|
||||
/// Best-effort upgrade from RX-only to RX+TX `SO_TIMESTAMPING` flags.
|
||||
///
|
||||
/// Returns `true` when the upgrade succeeded and the caller may now expect
|
||||
/// `MSG_ERRQUEUE` TX timestamp records for subsequent sends. On failure, the
|
||||
/// original [`RX_TIMESTAMPING_FLAGS`] are best-effort restored so a failed
|
||||
/// upgrade cannot leave the socket without the RX timestamping this client
|
||||
/// already depends on; that restore's own failure is not observable here
|
||||
/// (both leave the caller without TX capability, which this return value
|
||||
/// already reports).
|
||||
pub(crate) fn try_enable_tx_timestamping<S: AsFd>(socket: &S) -> bool {
|
||||
if setsockopt(
|
||||
socket,
|
||||
sockopt::Timestamping,
|
||||
&error_queue::TX_TIMESTAMPING_FLAGS,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let _ = setsockopt(socket, sockopt::Timestamping, &RX_TIMESTAMPING_FLAGS);
|
||||
false
|
||||
}
|
||||
|
||||
/// Bounded, nonblocking drain of `fd`'s `MSG_ERRQUEUE`.
|
||||
///
|
||||
/// Reads and classifies up to [`MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN`] records,
|
||||
/// one at a time with no intermediate allocation. Each usable TX timestamp
|
||||
/// completion is reported to `on_timestamp` immediately. Malformed or
|
||||
/// unsupported timestamp notifications and unrelated records are silently
|
||||
/// dropped: timestamp metadata is optional and never fails a probe. A
|
||||
/// genuine non-timestamp socket/network error stops the drain and is
|
||||
/// returned to the caller, which decides how to surface it.
|
||||
pub(crate) fn drain_tx_timestamps<S: AsRawFd>(
|
||||
socket: &S,
|
||||
mut on_timestamp: impl FnMut(u32, SystemTime),
|
||||
) -> io::Result<()> {
|
||||
let fd = socket.as_raw_fd();
|
||||
for _ in 0..MAX_TX_TIMESTAMP_RECORDS_PER_DRAIN {
|
||||
match error_queue::try_recv_error_queue_record(fd)? {
|
||||
None => return Ok(()),
|
||||
Some(ErrorQueueRecord::TxTimestamp { id, timestamp }) => on_timestamp(id, timestamp),
|
||||
Some(ErrorQueueRecord::MalformedOrUnsupportedTimestamp | ErrorQueueRecord::Ignored) => {
|
||||
}
|
||||
Some(ErrorQueueRecord::SocketError { errno, .. }) => {
|
||||
return Err(io::Error::from_raw_os_error(errno));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `SO_TIMESTAMPING` flags requested on every production socket at
|
||||
/// connect time, before any TX upgrade is attempted.
|
||||
///
|
||||
/// `SOF_TIMESTAMPING_RX_SOFTWARE` asks the kernel to generate a software
|
||||
/// receive timestamp for each datagram; `SOF_TIMESTAMPING_SOFTWARE` asks it
|
||||
/// to report that timestamp back via `SCM_TIMESTAMPING`. Mixing this API with
|
||||
/// the older `SO_TIMESTAMP`/`SO_TIMESTAMPNS` on the same socket has
|
||||
/// documented fallback quirks, so the client uses `SO_TIMESTAMPING`
|
||||
/// exclusively for receive timing. Hardware and TX-side flags are
|
||||
/// deliberately excluded here: a fresh socket is receive-only until (and
|
||||
/// unless) [`try_enable_tx_timestamping`] later upgrades it.
|
||||
const RX_TIMESTAMPING_FLAGS: TimestampingFlag = TimestampingFlag::SOF_TIMESTAMPING_RX_SOFTWARE
|
||||
.union(TimestampingFlag::SOF_TIMESTAMPING_SOFTWARE);
|
||||
|
||||
/// Control buffer capacity for a normal receive: one `SCM_TIMESTAMPING`
|
||||
/// record (three `TimeSpec`s) plus one traffic-class record. Only one of
|
||||
/// `IP_TOS` (`u8`) or `IPV6_TCLASS` (`i32`) is ever requested on a given
|
||||
/// socket, but `cmsg_space` already rounds both up to the same aligned
|
||||
/// space, so sizing on the larger of the two costs nothing and covers
|
||||
/// either family.
|
||||
const CONTROL_LEN: usize = cmsg_space::<Timestamps>() + cmsg_space::<i32>();
|
||||
|
||||
pub(crate) fn configure_receive_metadata(socket: &UdpSocket, remote: SocketAddr) -> io::Result<()> {
|
||||
enable_kernel_rx_timestamps(socket)?;
|
||||
setsockopt(socket, sockopt::Timestamping, &RX_TIMESTAMPING_FLAGS)?;
|
||||
let socket = socket2::SockRef::from(socket);
|
||||
if remote.is_ipv4() {
|
||||
socket.set_recv_tos_v4(true)
|
||||
@@ -21,374 +139,79 @@ pub(crate) fn configure_receive_metadata(socket: &UdpSocket, remote: SocketAddr)
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_kernel_rx_timestamps(socket: &UdpSocket) -> io::Result<()> {
|
||||
let enabled: libc::c_int = 1;
|
||||
let result = unsafe {
|
||||
// SAFETY: The file descriptor is borrowed from a valid `UdpSocket`.
|
||||
// `enabled` is a properly aligned `c_int`, and the length matches the
|
||||
// pointed-to value for the `SO_TIMESTAMPNS` boolean socket option.
|
||||
libc::setsockopt(
|
||||
socket.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_TIMESTAMPNS,
|
||||
(&enabled as *const libc::c_int).cast(),
|
||||
mem::size_of_val(&enabled) as libc::socklen_t,
|
||||
)
|
||||
};
|
||||
if result == -1 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn recv_datagram(
|
||||
socket: &UdpSocket,
|
||||
buf: &mut [u8],
|
||||
) -> Result<ReceivedDatagram, io::Error> {
|
||||
let mut control = ControlBuffer::new();
|
||||
let mut iov = libc::iovec {
|
||||
iov_base: buf.as_mut_ptr().cast(),
|
||||
iov_len: buf.len(),
|
||||
};
|
||||
let mut msg: libc::msghdr = unsafe { mem::zeroed() };
|
||||
msg.msg_iov = &mut iov;
|
||||
msg.msg_iovlen = 1;
|
||||
msg.msg_control = control.as_mut_ptr().cast();
|
||||
msg.msg_controllen = control.len();
|
||||
recv_datagram_fd(socket.as_raw_fd(), buf, MsgFlags::empty())
|
||||
}
|
||||
|
||||
let len = unsafe {
|
||||
// SAFETY: `msg` points to one writable iovec backed by `buf`, and the
|
||||
// control buffer is writable and lives until `recvmsg` returns. The
|
||||
// socket file descriptor is borrowed from a valid `UdpSocket`.
|
||||
libc::recvmsg(socket.as_raw_fd(), &mut msg, 0)
|
||||
};
|
||||
if len < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let len = usize::try_from(len).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"recvmsg returned an unrepresentable datagram length",
|
||||
)
|
||||
})?;
|
||||
let received_at = ClientTimestamp::now();
|
||||
let meta = unsafe {
|
||||
// SAFETY: `msg` was initialized by a successful `recvmsg` call. The
|
||||
// parser only reads cmsghdr entries within `msg_controllen` and ignores
|
||||
// short or unrelated control messages.
|
||||
parse_receive_meta(&msg)
|
||||
};
|
||||
|
||||
Ok(ReceivedDatagram {
|
||||
len,
|
||||
received_at,
|
||||
meta,
|
||||
#[cfg(feature = "tokio")]
|
||||
pub(crate) fn try_recv_tokio_datagram(
|
||||
socket: &tokio::net::UdpSocket,
|
||||
buf: &mut [u8],
|
||||
) -> Result<ReceivedDatagram, io::Error> {
|
||||
socket.try_io(tokio::io::Interest::READABLE, || {
|
||||
recv_datagram_fd(socket.as_raw_fd(), buf, MsgFlags::MSG_DONTWAIT)
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn parse_receive_meta(msg: &libc::msghdr) -> ReceiveMeta {
|
||||
fn recv_datagram_fd(
|
||||
socket_fd: RawFd,
|
||||
buf: &mut [u8],
|
||||
flags: MsgFlags,
|
||||
) -> Result<ReceivedDatagram, io::Error> {
|
||||
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(&mut control.0), flags)?;
|
||||
let received_at = ClientTimestamp::now();
|
||||
|
||||
Ok(ReceivedDatagram {
|
||||
len: msg.bytes,
|
||||
received_at,
|
||||
meta: receive_meta(&msg),
|
||||
})
|
||||
}
|
||||
|
||||
fn receive_meta<S>(msg: &RecvMsg<'_, '_, S>) -> ReceiveMeta {
|
||||
let mut meta = ReceiveMeta::default();
|
||||
let mut cmsg = libc::CMSG_FIRSTHDR(msg);
|
||||
while !cmsg.is_null() {
|
||||
let cmsg_ref = &*cmsg;
|
||||
let header_len = libc::CMSG_LEN(0) as usize;
|
||||
if cmsg_ref.cmsg_len < header_len {
|
||||
cmsg = libc::CMSG_NXTHDR(msg, cmsg);
|
||||
continue;
|
||||
// A control buffer the kernel had to truncate (`MSG_CTRUNC`) cannot be
|
||||
// walked reliably, so `cmsgs` refuses to parse it. The datagram itself is
|
||||
// still valid; report it without ancillary metadata.
|
||||
let Ok(cmsgs) = msg.cmsgs() else {
|
||||
return meta;
|
||||
};
|
||||
|
||||
for cmsg in cmsgs {
|
||||
match cmsg {
|
||||
ControlMessageOwned::ScmTimestampsns(timestamps) => {
|
||||
meta.kernel_rx_timestamp = system_time_from_timespec(timestamps.system);
|
||||
}
|
||||
ControlMessageOwned::Ipv4Tos(tos) => {
|
||||
meta.traffic_class = Some(tos);
|
||||
}
|
||||
ControlMessageOwned::Ipv6TClass(traffic_class) => {
|
||||
meta.traffic_class = u8::try_from(traffic_class).ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let data_len = cmsg_ref.cmsg_len - header_len;
|
||||
if is_traffic_class_cmsg(cmsg_ref) {
|
||||
meta.traffic_class = read_int_cmsg_low_byte(cmsg, data_len);
|
||||
} else if is_kernel_rx_timestamp_cmsg(cmsg_ref) {
|
||||
meta.kernel_rx_timestamp = read_timespec_cmsg(cmsg, data_len);
|
||||
}
|
||||
cmsg = libc::CMSG_NXTHDR(msg, cmsg);
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
fn is_traffic_class_cmsg(cmsg: &libc::cmsghdr) -> bool {
|
||||
(cmsg.cmsg_level == libc::IPPROTO_IP && cmsg.cmsg_type == libc::IP_TOS)
|
||||
|| (cmsg.cmsg_level == libc::IPPROTO_IPV6 && cmsg.cmsg_type == libc::IPV6_TCLASS)
|
||||
}
|
||||
|
||||
fn is_kernel_rx_timestamp_cmsg(cmsg: &libc::cmsghdr) -> bool {
|
||||
cmsg.cmsg_level == libc::SOL_SOCKET && cmsg.cmsg_type == libc::SCM_TIMESTAMPNS
|
||||
}
|
||||
|
||||
unsafe fn read_u8_cmsg(cmsg: *mut libc::cmsghdr, data_len: usize) -> Option<u8> {
|
||||
if data_len >= mem::size_of::<u8>() {
|
||||
Some(*libc::CMSG_DATA(cmsg).cast::<u8>())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn read_int_cmsg_low_byte(cmsg: *mut libc::cmsghdr, data_len: usize) -> Option<u8> {
|
||||
if data_len >= mem::size_of::<libc::c_int>() {
|
||||
let value = ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast::<libc::c_int>());
|
||||
Some((value & 0xff) as u8)
|
||||
} else {
|
||||
read_u8_cmsg(cmsg, data_len)
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn read_timespec_cmsg(cmsg: *mut libc::cmsghdr, data_len: usize) -> Option<SystemTime> {
|
||||
if data_len < mem::size_of::<libc::timespec>() {
|
||||
fn system_time_from_timespec(timespec: TimeSpec) -> Option<SystemTime> {
|
||||
let seconds = u64::try_from(timespec.tv_sec()).ok()?;
|
||||
let nanos = u32::try_from(timespec.tv_nsec()).ok()?;
|
||||
if nanos >= 1_000_000_000 {
|
||||
return None;
|
||||
}
|
||||
let timespec = ptr::read_unaligned(libc::CMSG_DATA(cmsg).cast::<libc::timespec>());
|
||||
system_time_from_timespec(timespec)
|
||||
}
|
||||
|
||||
fn system_time_from_timespec(timespec: libc::timespec) -> Option<SystemTime> {
|
||||
if timespec.tv_sec < 0 || timespec.tv_nsec < 0 || timespec.tv_nsec >= 1_000_000_000 {
|
||||
return None;
|
||||
}
|
||||
let seconds = u64::try_from(timespec.tv_sec).ok()?;
|
||||
let nanos = u32::try_from(timespec.tv_nsec).ok()?;
|
||||
UNIX_EPOCH.checked_add(Duration::new(seconds, nanos))
|
||||
}
|
||||
|
||||
/// Reusable per-receive control buffer. `recvmsg` control data must satisfy
|
||||
/// `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_ptr(&mut self) -> *mut u8 {
|
||||
self.0.as_mut_ptr()
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io,
|
||||
net::{SocketAddr, UdpSocket},
|
||||
time::{Duration, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
event::PacketMeta,
|
||||
receive::{configure_receive_metadata, recv_datagram},
|
||||
socket_options::apply_dscp_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);
|
||||
}
|
||||
|
||||
#[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_TIMESTAMPNS"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let duration = timestamp.duration_since(UNIX_EPOCH).unwrap();
|
||||
assert!(duration.as_nanos() > 0);
|
||||
}
|
||||
|
||||
#[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_dscp_to_socket(&sender, receiver.local_addr().unwrap(), 46).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_dscp_to_socket(&sender, receiver.local_addr().unwrap(), 46).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(libc::timespec {
|
||||
tv_sec: 1,
|
||||
tv_nsec: 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(libc::timespec {
|
||||
tv_sec: -1,
|
||||
tv_nsec: 0,
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
super::system_time_from_timespec(libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: -1,
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
super::system_time_from_timespec(libc::timespec {
|
||||
tv_sec: 0,
|
||||
tv_nsec: 1_000_000_000,
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn is_unavailable_ipv6_loopback(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Safe `MSG_ERRQUEUE` primitives backing the client's TX timestamp capture.
|
||||
//!
|
||||
//! A production socket that successfully upgraded to
|
||||
//! [`TX_TIMESTAMPING_FLAGS`] receives one extended-error record per
|
||||
//! successfully submitted timestamped datagram on `MSG_ERRQUEUE`.
|
||||
//! [`try_recv_error_queue_record`] performs one nonblocking read of that
|
||||
//! queue and [`classify`] turns its ancillary data into an
|
||||
//! [`ErrorQueueRecord`] without ever panicking or requiring the caller to
|
||||
//! understand cmsg layout.
|
||||
|
||||
use std::{
|
||||
io::{self, IoSliceMut},
|
||||
os::fd::RawFd,
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use nix::{
|
||||
errno::Errno,
|
||||
sys::socket::{
|
||||
cmsg_space, recvmsg, ControlMessageOwned, MsgFlags, TimestampingFlag, Timestamps,
|
||||
},
|
||||
};
|
||||
|
||||
use super::system_time_from_timespec;
|
||||
|
||||
/// `SO_TIMESTAMPING` configuration for a TX-timestamped socket. Combines the
|
||||
/// module's RX flags with the TX-side flags TX capture needs: `TX_SOFTWARE`
|
||||
/// to generate a send timestamp, `OPT_ID` for automatic per-datagram
|
||||
/// correlation, and `OPT_TSONLY` so the notification does not need to
|
||||
/// retain or copy the original payload.
|
||||
pub(crate) const TX_TIMESTAMPING_FLAGS: TimestampingFlag =
|
||||
TimestampingFlag::SOF_TIMESTAMPING_RX_SOFTWARE
|
||||
.union(TimestampingFlag::SOF_TIMESTAMPING_SOFTWARE)
|
||||
.union(TimestampingFlag::SOF_TIMESTAMPING_TX_SOFTWARE)
|
||||
.union(TimestampingFlag::SOF_TIMESTAMPING_OPT_ID)
|
||||
.union(TimestampingFlag::SOF_TIMESTAMPING_OPT_TSONLY);
|
||||
|
||||
/// `SCM_TSTAMP_SND`, the send-timestamp completion kind reported in the
|
||||
/// `ee_info` field of a `SO_EE_ORIGIN_TIMESTAMPING` extended error.
|
||||
///
|
||||
/// Not exposed by `libc` or `nix`. Value verified against the kernel UAPI
|
||||
/// enum in `include/uapi/linux/errqueue.h`:
|
||||
/// `enum { SCM_TSTAMP_SND, SCM_TSTAMP_SCHED, SCM_TSTAMP_ACK };`, so
|
||||
/// `SCM_TSTAMP_SND == 0`. This is a stable, long-documented UAPI value, not
|
||||
/// a guess.
|
||||
const SCM_TSTAMP_SND: u32 = 0;
|
||||
|
||||
/// A large-enough placeholder for the offender address `sock_extended_err`
|
||||
/// is followed by in the kernel's error-queue cmsg payload (the address is
|
||||
/// present for network-originated errors, absent for local/timestamp
|
||||
/// ones). Sized generously so `cmsg_space` reserves enough control-buffer
|
||||
/// capacity for either an `IPv4` or `IPv6` offender address; never read.
|
||||
#[repr(C)]
|
||||
struct ExtendedErrWithAddr {
|
||||
_err: nix::libc::sock_extended_err,
|
||||
_addr: nix::libc::sockaddr_in6,
|
||||
}
|
||||
|
||||
/// Control buffer capacity for one error-queue receive: one extended-error
|
||||
/// record (with room for the largest possible offender address) plus one
|
||||
/// `SCM_TIMESTAMPING` record.
|
||||
const ERROR_QUEUE_CONTROL_LEN: usize =
|
||||
cmsg_space::<ExtendedErrWithAddr>() + cmsg_space::<Timestamps>();
|
||||
|
||||
/// A classified record read from a socket's `MSG_ERRQUEUE`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ErrorQueueRecord {
|
||||
/// A genuine send-timestamp completion: the extended error's origin was
|
||||
/// `SO_EE_ORIGIN_TIMESTAMPING`, its kind was `SCM_TSTAMP_SND`, and a
|
||||
/// usable software timestamp accompanied it.
|
||||
TxTimestamp { id: u32, timestamp: SystemTime },
|
||||
/// The extended error's origin was `SO_EE_ORIGIN_TIMESTAMPING` (i.e. this
|
||||
/// is unambiguously a timestamp facility record, never a real
|
||||
/// socket/network error), but it was not a usable send-timestamp
|
||||
/// completion: the completion kind was not `SCM_TSTAMP_SND` (this client
|
||||
/// requests only `TX_SOFTWARE`, so only `SND` is expected, but an
|
||||
/// unrequested kind must not be mistaken for a socket failure), the
|
||||
/// `SCM_TIMESTAMPING` cmsg was missing, or its value failed to convert
|
||||
/// to a `SystemTime`. An accuracy failure, not a socket failure.
|
||||
MalformedOrUnsupportedTimestamp,
|
||||
/// An extended error whose origin was not `SO_EE_ORIGIN_TIMESTAMPING`: a
|
||||
/// real network, local, or ICMP error surfaced on the error queue. Must
|
||||
/// not be misclassified as malformed timing metadata.
|
||||
SocketError { errno: i32, origin: u8 },
|
||||
/// No extended error was present in this record's control data (e.g.
|
||||
/// only unrelated/unknown cmsgs).
|
||||
Ignored,
|
||||
}
|
||||
|
||||
/// Classify a decoded set of cmsgs from one `MSG_ERRQUEUE` datagram.
|
||||
///
|
||||
/// Collects the extended error and any accompanying timestamp
|
||||
/// independently before classifying, so cmsg order never matters. Origin is
|
||||
/// the sole discriminator between a timestamp facility record and a real
|
||||
/// socket error: any `SO_EE_ORIGIN_TIMESTAMPING` record is timing metadata,
|
||||
/// however unexpected its `errno`/`ee_info`, and only ever becomes
|
||||
/// [`ErrorQueueRecord::MalformedOrUnsupportedTimestamp`] on the way to
|
||||
/// `None` — never [`ErrorQueueRecord::SocketError`]. A timestamp is only
|
||||
/// ever attached to a timestamp-origin record reporting `SCM_TSTAMP_SND`.
|
||||
pub(crate) fn classify(cmsgs: impl Iterator<Item = ControlMessageOwned>) -> ErrorQueueRecord {
|
||||
let mut extended_error: Option<(u32, u8, u32, u32)> = None;
|
||||
let mut timestamp: Option<Timestamps> = None;
|
||||
|
||||
for cmsg in cmsgs {
|
||||
match cmsg {
|
||||
ControlMessageOwned::Ipv4RecvErr(err, _) | ControlMessageOwned::Ipv6RecvErr(err, _) => {
|
||||
extended_error = Some((err.ee_errno, err.ee_origin, err.ee_info, err.ee_data));
|
||||
}
|
||||
ControlMessageOwned::ScmTimestampsns(observed) => {
|
||||
timestamp = Some(observed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((errno, origin, info, id)) = extended_error else {
|
||||
return ErrorQueueRecord::Ignored;
|
||||
};
|
||||
|
||||
if origin != nix::libc::SO_EE_ORIGIN_TIMESTAMPING {
|
||||
return ErrorQueueRecord::SocketError {
|
||||
errno: errno as i32,
|
||||
origin,
|
||||
};
|
||||
}
|
||||
|
||||
let is_send_completion = errno == nix::libc::ENOMSG as u32 && info == SCM_TSTAMP_SND;
|
||||
if !is_send_completion {
|
||||
return ErrorQueueRecord::MalformedOrUnsupportedTimestamp;
|
||||
}
|
||||
|
||||
match timestamp.and_then(|observed| system_time_from_timespec(observed.system)) {
|
||||
Some(timestamp) => ErrorQueueRecord::TxTimestamp { id, timestamp },
|
||||
None => ErrorQueueRecord::MalformedOrUnsupportedTimestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable per-receive control buffer. `recvmsg` control data must satisfy
|
||||
/// `cmsghdr` alignment; a plain `[u8; N]` only guarantees byte alignment, so
|
||||
/// this mirrors the parent module's `ControlBuffer` rather than reading
|
||||
/// `cmsghdr`s out of unaligned storage.
|
||||
#[repr(align(8))]
|
||||
struct ErrorQueueControlBuffer([u8; 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
|
||||
/// caller in [`super::drain_tx_timestamps`] stops its drain there, exactly
|
||||
/// as it always has. An interrupted read (`EINTR`) is different: it says
|
||||
/// nothing about whether the queue is empty, so treating it the same as
|
||||
/// `EWOULDBLOCK` could end a drain early and strand a still-queued record
|
||||
/// past the point its matching probe is looked up and removed (see the
|
||||
/// crate's `AGENTS.md`). TX timestamp capture is optional best-effort
|
||||
/// metadata, not a network operation, so an interruption here must also
|
||||
/// never become a socket/network error; instead it is reported as
|
||||
/// [`ErrorQueueRecord::Ignored`], which consumes one attempt of the bounded
|
||||
/// caller's existing per-drain work budget and lets the loop immediately
|
||||
/// retry the same slot rather than stopping or spinning unboundedly.
|
||||
/// `SOF_TIMESTAMPING_OPT_TSONLY` notifications carry no meaningful payload,
|
||||
/// so a zero-length iovec is enough: correlation is by `id`, never by
|
||||
/// payload content.
|
||||
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([0; ERROR_QUEUE_CONTROL_LEN]);
|
||||
|
||||
match recvmsg::<()>(
|
||||
fd,
|
||||
&mut iov,
|
||||
Some(&mut control.0),
|
||||
MsgFlags::MSG_ERRQUEUE | MsgFlags::MSG_DONTWAIT,
|
||||
) {
|
||||
Ok(msg) => {
|
||||
let cmsgs = msg
|
||||
.cmsgs()
|
||||
.map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?;
|
||||
Ok(Some(classify(cmsgs)))
|
||||
}
|
||||
Err(Errno::EWOULDBLOCK) => Ok(None),
|
||||
Err(Errno::EINTR) => Ok(Some(ErrorQueueRecord::Ignored)),
|
||||
Err(errno) => Err(io::Error::from(errno)),
|
||||
}
|
||||
}
|
||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user