1use std::{
5 collections::HashMap,
6 fmt::{Debug, Display},
7 future::Future,
8 sync::Arc,
9 time::Duration,
10};
11
12use crate::nq_core::{
13 ConnectionTiming, ConnectionType, Network, ScopedHeaders, Time, Timestamp,
14 client::{Direction, ThroughputClient, wait_for_finish},
15};
16use crate::nq_load_generator::{LoadConfig, LoadGenerator, LoadedConnection};
17use crate::nq_stats::{TimeSeries, instant_minus_intervals};
18use humansize::{DECIMAL, format_size};
19use tokio::{select, sync::mpsc};
20use tokio_util::sync::CancellationToken;
21use tracing::{Instrument, debug, error, info, warn};
22use url::Url;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum ConnectionErrorPolicy {
35 #[default]
38 Retire,
39 Abort,
41}
42
43#[derive(Debug, Clone)]
44pub struct ResponsivenessConfig {
45 pub large_download_url: Url,
46 pub small_download_url: Url,
47 pub upload_url: Url,
48 pub moving_average_distance: usize,
49 pub interval_duration: Duration,
50 pub test_duration: Duration,
51 pub trimmed_mean_percent: f64,
52 pub std_tolerance: f64,
53 pub max_loaded_connections: usize,
54 pub conn_type: ConnectionType,
55 pub determine_load_only: bool,
56 pub upload_bytes_per_request: usize,
68 pub on_connection_error: ConnectionErrorPolicy,
70 pub scoped_headers: Option<ScopedHeaders>,
73}
74
75impl ResponsivenessConfig {
76 pub fn load_config(&self) -> LoadConfig {
77 LoadConfig {
78 headers: HashMap::default(),
79 scoped_headers: self.scoped_headers.clone(),
80 download_url: self.large_download_url.clone(),
81 upload_url: self.upload_url.clone(),
82 }
83 }
84}
85
86pub const DEFAULT_UPLOAD_BYTES_PER_REQUEST: usize = 100_000_000;
93
94impl Default for ResponsivenessConfig {
95 fn default() -> Self {
96 Self {
97 large_download_url: "https://h3.speed.cloudflare.com/__down?bytes=10000000000"
98 .parse()
99 .unwrap(),
100 small_download_url: "https://h3.speed.cloudflare.com/__down?bytes=10"
101 .parse()
102 .unwrap(),
103 upload_url: "https://h3.speed.cloudflare.com/__up".parse().unwrap(),
104 moving_average_distance: 4,
105 interval_duration: Duration::from_millis(1000),
106 test_duration: Duration::from_secs(20),
107 trimmed_mean_percent: 0.95,
108 std_tolerance: 0.05,
109 max_loaded_connections: 16,
110 conn_type: ConnectionType::H2,
111 determine_load_only: false,
112 upload_bytes_per_request: DEFAULT_UPLOAD_BYTES_PER_REQUEST,
113 on_connection_error: ConnectionErrorPolicy::default(),
114 scoped_headers: None,
115 }
116 }
117}
118
119pub struct Responsiveness {
120 start: Timestamp,
121 config: ResponsivenessConfig,
122 load_generator: LoadGenerator,
123 foreign_probe_results: ForeignProbeResults,
124 self_probe_results: SelfProbeResults,
125 average_goodput_series: TimeSeries,
126 rpm_series: TimeSeries,
127 goodput_saturated: bool,
128 rpm_saturated: bool,
129 direction: Direction,
130 rpm: Option<f64>,
133 last_rpm: Option<f64>,
141 capacity: f64,
142 failed_connections: usize,
144 starved_intervals: usize,
147}
148
149impl Responsiveness {
150 pub fn new(config: ResponsivenessConfig, download: bool) -> anyhow::Result<Self> {
151 let load_generator = LoadGenerator::new(config.load_config())?;
152
153 let upload_bytes_per_request = config.upload_bytes_per_request;
155
156 Ok(Self {
157 start: Timestamp::now(),
158 config,
159 load_generator,
160 foreign_probe_results: Default::default(),
161 self_probe_results: Default::default(),
162 average_goodput_series: TimeSeries::new(),
163 rpm_series: TimeSeries::new(),
164 failed_connections: 0,
165 starved_intervals: 0,
166 goodput_saturated: false,
167 rpm_saturated: false,
168 direction: if download {
172 Direction::Down
173 } else {
174 Direction::Up(upload_bytes_per_request)
175 },
176 rpm: None,
177 last_rpm: None,
178 capacity: 0.0,
179 })
180 }
181}
182
183impl Responsiveness {
184 pub async fn run_test(
193 mut self,
194 network: Arc<dyn Network>,
195 time: Arc<dyn Time>,
196 shutdown: CancellationToken,
197 ) -> anyhow::Result<ResponsivenessResult> {
198 let env = Env { time, network };
199 self.start = env.time.now();
200
201 info!("running responsiveness test: {:?}", self.config);
202
203 let mut interval = None;
204
205 let mut interval_timer = tokio::time::interval(self.config.interval_duration);
208
209 let (event_tx, mut event_rx) = mpsc::channel(1024);
210
211 self.new_load_generating_connection(event_tx.clone(), &env, shutdown.clone())?;
212
213 if !self.config.determine_load_only {
214 self.send_foreign_probe(event_tx.clone(), &env, shutdown.clone())?;
215 }
216
217 loop {
218 select! {
219 Some(event) = event_rx.recv() => {
220 match event {
221 Event::NewLoadedConnection(connection) => {
222 self.load_generator.push(connection);
223 }
224 Event::ForeignProbe(f) => {
225 self.foreign_probe_results.add(f);
226
227 if !self.send_self_probe(event_tx.clone(), &env, shutdown.clone())? {
231 self.send_foreign_probe(event_tx.clone(), &env, shutdown.clone())?;
232 }
233 }
234 Event::SelfProbe(s) => {
235 self.self_probe_results.add(s);
236
237 self.send_foreign_probe(event_tx.clone(), &env, shutdown.clone())?;
238 }
239 Event::Error(e) => {
240 error!("error: {e}");
241 }
242 }
243 }
244 _ = interval_timer.tick() => {
245 self.load_generator.update();
247
248 if let Some(interval) = interval.as_mut() {
249 if self.on_interval(*interval, event_tx.clone(), &env, shutdown.clone()).await? {
250 break;
251 }
252
253 *interval += 1;
254 } else {
255 interval = Some(0);
256 }
257 }
258 _ = shutdown.cancelled() => {
259 debug!("shutdown requested");
260 break;
261 }
262 };
263
264 if env.time.now().duration_since(self.start) > self.config.test_duration {
265 break;
266 }
267 }
268
269 let now = env.time.now();
270
271 self.rpm = select_reported_rpm(self.rpm, self.last_rpm);
291
292 let mut loads = self.load_generator.into_connections();
294 loads.iter_mut().for_each(|load| load.stop());
295
296 Ok(ResponsivenessResult {
297 capacity: self.capacity,
298 rpm: self.rpm,
299 self_probe_latencies: self.self_probe_results.http,
300 loaded_connections: loads,
301 failed_connections: self.failed_connections,
302 duration: now.duration_since(self.start),
303 average_goodput_series: self.average_goodput_series,
304 })
305 }
306
307 async fn on_interval(
332 &mut self,
333 interval: usize,
334 event_tx: mpsc::Sender<Event>,
335 env: &Env,
336 shutdown: CancellationToken,
337 ) -> anyhow::Result<bool> {
338 let end_data_interval = self.start + self.config.interval_duration * interval as u32;
340 let start_data_interval = instant_minus_intervals(
341 end_data_interval,
342 self.config.moving_average_distance,
343 self.config.interval_duration,
344 );
345
346 self.enforce_connection_error_policy()?;
347
348 if self.load_generator.count_loads() < self.config.max_loaded_connections
351 && interval.is_multiple_of(2)
352 {
353 self.new_load_generating_connection(event_tx, env, shutdown)?;
354 }
355
356 let current_goodput = self.current_average_throughput(end_data_interval);
357 self.average_goodput_series
358 .add(end_data_interval, current_goodput);
359
360 let std_goodput = self
361 .average_goodput_series
362 .interval_std(start_data_interval, end_data_interval)
363 .unwrap_or(f64::MAX);
364
365 let goodput_saturated = std_goodput < current_goodput * self.config.std_tolerance;
368 if goodput_saturated {
369 self.capacity = current_goodput;
372 self.goodput_saturated = true;
373 }
374
375 let current_rpm = compute_responsiveness(
377 &self.foreign_probe_results,
378 &self.self_probe_results,
379 start_data_interval,
380 end_data_interval,
381 self.config.trimmed_mean_percent,
382 );
383
384 let current_rpm_or_zero = current_rpm.unwrap_or(0.0);
402 self.rpm_series.add(end_data_interval, current_rpm_or_zero);
403
404 if let Some(current_rpm) = current_rpm {
407 self.last_rpm = Some(current_rpm);
408 }
409
410 let std_rpm = self
411 .rpm_series
412 .interval_std(start_data_interval, end_data_interval);
413
414 let is_rpm_saturated = if let Some(std_rpm) = std_rpm {
415 if std_rpm < current_rpm_or_zero * self.config.std_tolerance {
421 self.rpm = Some(current_rpm_or_zero);
422 self.rpm_saturated = true;
423 true
424 } else {
425 false
426 }
427 } else {
428 false
429 };
430
431 self.log_interval(
432 interval,
433 current_goodput,
434 std_goodput,
435 goodput_saturated,
436 current_rpm_or_zero,
437 current_rpm.is_some(),
438 std_rpm,
439 is_rpm_saturated,
440 );
441
442 Ok(self.goodput_saturated && self.rpm_saturated)
444 }
445
446 #[allow(clippy::too_many_arguments)]
447 fn log_interval(
448 &mut self,
449 interval: usize,
450 current_goodput: f64,
451 std_goodput: f64,
452 goodput_saturated: bool,
453 current_rpm: f64,
454 rpm_measured: bool,
455 std_rpm: Option<f64>,
456 is_rpm_saturated: bool,
457 ) {
458 let custom_options = humansize::FormatSizeOptions::from(DECIMAL)
460 .base_unit(humansize::BaseUnit::Bit)
461 .long_units(false)
462 .decimal_places(2);
463
464 info!(
468 interval,
469 loads = self.load_generator.count_loads(),
470 throughput = format_size(current_goodput as usize, custom_options),
471 rpm = current_rpm,
472 throughput_saturated = goodput_saturated,
473 rpm_saturated = is_rpm_saturated,
474 "interval finished"
475 );
476
477 if !rpm_measured {
478 warn!(
479 interval,
480 "no probe measurements in this interval's window; recorded 0 RPM, \
481 which inflates the stability std for the next MAD intervals"
482 );
483 }
484
485 info!(
486 interval,
487 throughput_std = format_size(std_goodput as usize, custom_options),
488 throughput_target_std = format_size(
489 (current_goodput * self.config.std_tolerance) as usize,
490 custom_options
491 ),
492 rpm_std = std_rpm.unwrap_or(f64::NAN),
493 rpm_target_std = current_rpm * self.config.std_tolerance,
494 "interval stats"
495 );
496 }
497
498 fn current_average_throughput(&self, end_data_interval: Timestamp) -> f64 {
504 let start_data_interval =
505 instant_minus_intervals(end_data_interval, 4, self.config.interval_duration);
506
507 let mut bytes_seen = 0.0;
508
509 for connection in self.load_generator.connections() {
510 bytes_seen += connection
511 .total_bytes_series()
512 .interval_sum(start_data_interval, end_data_interval);
513 }
514
515 let total_time = end_data_interval
516 .duration_since(start_data_interval)
517 .as_secs_f64();
518
519 8.0 * bytes_seen / total_time
520 }
521
522 fn enforce_connection_error_policy(&mut self) -> anyhow::Result<()> {
530 let failed = self.load_generator.count_failed_loads();
531 let newly_failed = failed.saturating_sub(self.failed_connections);
532 self.failed_connections = failed;
533
534 if newly_failed > 0 {
535 let reason = self
536 .load_generator
537 .connections()
538 .filter_map(|c| c.failure_reason())
539 .last()
540 .unwrap_or("connection terminated early")
541 .to_owned();
542
543 warn!(
544 newly_failed,
545 total_failed = failed,
546 reason = %reason,
547 "load-generating connection(s) terminated with an error"
548 );
549
550 if self.config.on_connection_error == ConnectionErrorPolicy::Abort {
551 anyhow::bail!(
552 "aborting test: {failed} load-generating connection(s) terminated with an \
553 error (most recent: {reason})"
554 );
555 }
556 }
557
558 if failed > 0 && self.load_generator.count_loads() == 0 {
563 self.starved_intervals += 1;
564
565 if self.starved_intervals >= 2 {
566 anyhow::bail!(
567 "aborting test: no load-generating connections could be sustained \
568 ({failed} terminated with an error); the link was never saturated so a \
569 responsiveness result would be meaningless"
570 );
571 }
572 } else {
573 self.starved_intervals = 0;
574 }
575
576 Ok(())
577 }
578
579 #[tracing::instrument(skip_all)]
583 fn new_load_generating_connection(
584 &self,
585 event_tx: mpsc::Sender<Event>,
586 env: &Env,
587 shutdown: CancellationToken,
588 ) -> anyhow::Result<()> {
589 let oneshot_res = self.load_generator.new_loaded_connection(
590 self.direction,
591 self.config.conn_type,
592 Arc::clone(&env.network),
593 Arc::clone(&env.time),
594 shutdown,
595 )?;
596
597 tokio::spawn(
598 async move {
599 let _ = match oneshot_res.await {
600 Ok(conn) => event_tx.send(Event::NewLoadedConnection(conn)),
601 Err(e) => event_tx.send(Event::Error(e)),
602 }
603 .await;
604 }
605 .in_current_span(),
606 );
607
608 Ok(())
609 }
610
611 fn send_foreign_probe(
621 &mut self,
622 event_tx: mpsc::Sender<Event>,
623 env: &Env,
624 shutdown: CancellationToken,
625 ) -> anyhow::Result<()> {
626 let client = ThroughputClient::download()
627 .new_connection(ConnectionType::H2)
628 .scoped_headers(self.config.scoped_headers.clone());
629
630 let inflight_body_fut = client.send(
631 self.config.small_download_url.as_str().parse()?,
632 Arc::clone(&env.network),
633 Arc::clone(&env.time),
634 shutdown,
635 )?;
636
637 tokio::spawn(report_err(
638 event_tx.clone(),
639 async move {
640 let inflight_body = inflight_body_fut.await?;
641
642 let finished_result = wait_for_finish(inflight_body.events).await?;
643
644 let Some(connection_timing) = inflight_body.timing else {
645 anyhow::bail!("a new connection with timing should have been created");
646 };
647
648 let (tcp, tls, http) =
649 foreign_probe_phases(&connection_timing, finished_result.finished_at);
650
651 if event_tx
652 .send(Event::ForeignProbe(ForeignProbeResult {
653 start: connection_timing.start(),
654 tcp,
655 tls,
656 http,
657 }))
658 .await
659 .is_err()
660 {
661 anyhow::bail!("unable to send foreign probe result");
662 }
663
664 Ok(())
665 }
666 .in_current_span(),
667 ));
668
669 Ok(())
670 }
671
672 fn send_self_probe(
686 &mut self,
687 event_tx: mpsc::Sender<Event>,
688 env: &Env,
689 shutdown: CancellationToken,
690 ) -> anyhow::Result<bool> {
691 let Some(connection) = self.load_generator.random_connection() else {
694 return Ok(false);
695 };
696
697 let client = ThroughputClient::download()
698 .with_connection(connection)
699 .scoped_headers(self.config.scoped_headers.clone());
700
701 let inflight_body_fut = client.send(
702 self.config.small_download_url.as_str().parse()?,
703 Arc::clone(&env.network),
704 Arc::clone(&env.time),
705 shutdown,
706 )?;
707
708 tokio::spawn(report_err(
709 event_tx.clone(),
710 async move {
711 let inflight_body = inflight_body_fut.await?;
712
713 let finish_result = wait_for_finish(inflight_body.events).await?;
714 debug!("self_probe_finished: {finish_result:?}");
715
716 if event_tx
717 .send(Event::SelfProbe(SelfProbeResult {
718 start: inflight_body.start,
719 time_body: finish_result
720 .finished_at
721 .duration_since(inflight_body.start),
722 }))
723 .await
724 .is_err()
725 {
726 anyhow::bail!("unable to send self probe result");
727 }
728
729 Ok(())
730 }
731 .in_current_span(),
732 ));
733
734 Ok(true)
735 }
736}
737
738async fn report_err(event_tx: mpsc::Sender<Event>, f: impl Future<Output = anyhow::Result<()>>) {
739 if let Err(e) = f.await {
740 let _ = event_tx.send(Event::Error(e)).await;
741 }
742}
743
744#[derive(Default)]
745pub struct ForeignProbeResults {
746 connect: TimeSeries,
747 secure: TimeSeries,
748 http: TimeSeries,
749}
750
751impl ForeignProbeResults {
752 pub fn add(&mut self, result: ForeignProbeResult) {
753 self.connect
754 .add(result.start, result.tcp.as_secs_f64() * 1000.0);
755 self.secure
756 .add(result.start, result.tls.as_secs_f64() * 1000.0);
757 self.http
758 .add(result.start, result.http.as_secs_f64() * 1000.0);
759 }
760
761 pub fn connect(&self) -> &TimeSeries {
762 &self.connect
763 }
764
765 pub fn secure(&self) -> &TimeSeries {
766 &self.secure
767 }
768
769 pub fn http(&self) -> &TimeSeries {
770 &self.http
771 }
772}
773
774#[derive(Default)]
775pub struct SelfProbeResults {
776 http: TimeSeries,
777}
778
779impl SelfProbeResults {
780 pub fn add(&mut self, result: SelfProbeResult) {
781 self.http
782 .add(result.start, result.time_body.as_secs_f64() * 1000.0);
783 }
784
785 pub fn http(&self) -> &TimeSeries {
786 &self.http
787 }
788}
789
790fn select_reported_rpm(saturated: Option<f64>, last_interval: Option<f64>) -> Option<f64> {
814 saturated.or(last_interval)
815}
816
817fn compute_responsiveness(
818 foreign_results: &ForeignProbeResults,
819 self_results: &SelfProbeResults,
820 from: Timestamp,
821 to: Timestamp,
822 percentile: f64,
823) -> Option<f64> {
824 let tm = |ts: &TimeSeries| ts.interval_trimmed_mean(from, to, percentile);
825
826 let tcp_f = tm(foreign_results.connect())?;
827 let tls_f = tm(foreign_results.secure())?;
828 let http_f = tm(foreign_results.http())?;
829 let http_l = tm(self_results.http())?;
830
831 let foreign_rtt = (tcp_f + tls_f + http_f) / 3.0;
833 let loaded_rtt = http_l;
834
835 if foreign_rtt <= 0.0 || loaded_rtt <= 0.0 {
837 return None;
838 }
839
840 let foreign_rpm = 60_000.0 / foreign_rtt;
841 let loaded_rpm = 60_000.0 / loaded_rtt;
842
843 let responsiveness = (foreign_rpm + loaded_rpm) / 2.0;
844
845 responsiveness.is_finite().then_some(responsiveness)
846}
847
848#[derive(Debug)]
849pub struct ForeignProbeResult {
850 start: Timestamp,
852 tcp: Duration,
854 tls: Duration,
857 http: Duration,
859}
860
861fn foreign_probe_phases(
875 timing: &ConnectionTiming,
876 finished_at: Timestamp,
877) -> (Duration, Duration, Duration) {
878 let tcp_f = timing.tcp_handshake();
879 let tls_f = timing.tls_handshake() / timing.tls_round_trips();
880 let request_issued = timing.start() + timing.time_application();
881 let http_f = finished_at.duration_since(request_issued);
882
883 (tcp_f, tls_f, http_f)
884}
885
886#[derive(Debug)]
887pub struct SelfProbeResult {
888 start: Timestamp,
889 time_body: Duration,
890}
891
892enum Event {
893 ForeignProbe(ForeignProbeResult),
894 SelfProbe(SelfProbeResult),
895 NewLoadedConnection(LoadedConnection),
896 Error(anyhow::Error),
897}
898
899impl Debug for Event {
900 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
901 match self {
902 Self::ForeignProbe(_) => f.debug_tuple("ForeignProbe").finish(),
903 Self::SelfProbe(_) => f.debug_tuple("SelfProbe").finish(),
904 Self::NewLoadedConnection(_) => f.debug_tuple("NewLoadedConnection").finish(),
905 Self::Error(_) => f.debug_tuple("Error").finish(),
906 }
907 }
908}
909
910#[derive(Clone)]
911struct Env {
912 time: Arc<dyn Time>,
913 network: Arc<dyn Network>,
914}
915
916#[derive(Default, Debug)]
917pub struct ResponsivenessResult {
918 pub duration: Duration,
919 pub capacity: f64,
920 pub rpm: Option<f64>,
927 pub self_probe_latencies: TimeSeries,
928 pub loaded_connections: Vec<LoadedConnection>,
929 pub average_goodput_series: TimeSeries,
930 pub failed_connections: usize,
934}
935
936impl ResponsivenessResult {
937 pub fn throughput(&self) -> Option<usize> {
938 self.average_goodput_series
939 .quantile(0.90)
940 .map(|t| t as usize)
941 }
942}
943
944impl Display for ResponsivenessResult {
945 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
946 let custom_options = humansize::FormatSizeOptions::from(DECIMAL)
947 .base_unit(humansize::BaseUnit::Bit)
948 .long_units(false)
949 .decimal_places(2);
950 writeln!(
951 f,
952 "{:8}: {}/s",
953 "capacity",
954 format_size(self.capacity as usize, custom_options)
955 )?;
956 match self.rpm {
957 Some(rpm) => write!(f, "{:>8}: {}", "rpm", rpm.round() as usize),
958 None => write!(f, "{:>8}: unavailable", "rpm"),
959 }
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use std::time::Duration;
967
968 fn ms(v: f64) -> Duration {
969 Duration::from_secs_f64(v / 1000.0)
970 }
971
972 fn series(
976 tcp_ms: f64,
977 tls_ms: f64,
978 http_f_ms: f64,
979 http_l_ms: f64,
980 ) -> (ForeignProbeResults, SelfProbeResults, Timestamp, Timestamp) {
981 let start = Timestamp::now();
982 let mut foreign = ForeignProbeResults::default();
983 let mut selfp = SelfProbeResults::default();
984
985 for i in 0..10u64 {
986 let at = start + Duration::from_millis(i);
987 foreign.add(ForeignProbeResult {
988 start: at,
989 tcp: ms(tcp_ms),
990 tls: ms(tls_ms),
991 http: ms(http_f_ms),
992 });
993 selfp.add(SelfProbeResult {
994 start: at,
995 time_body: ms(http_l_ms),
996 });
997 }
998
999 (foreign, selfp, start, start + Duration::from_millis(100))
1000 }
1001
1002 fn draft03(tcp: f64, tls: f64, http_f: f64, http_l: f64) -> f64 {
1005 let foreign_sum = tcp + tls + http_f;
1006 60_000.0 / (foreign_sum / 6.0 + http_l / 2.0)
1007 }
1008
1009 #[test]
1010 fn arithmetic_mean_of_the_two_rpms() {
1011 let (f, s, from, to) = series(30.0, 30.0, 30.0, 30.0);
1015 let rpm = compute_responsiveness(&f, &s, from, to, 0.95).unwrap();
1016 assert!((rpm - 2000.0).abs() < 1e-6, "got {rpm}");
1017 }
1018
1019 #[test]
1020 fn equals_draft03_only_when_foreign_equals_loaded() {
1021 let (f, s, from, to) = series(30.0, 30.0, 30.0, 30.0);
1023 let rpm = compute_responsiveness(&f, &s, from, to, 0.95).unwrap();
1024 assert!((rpm - draft03(30.0, 30.0, 30.0, 30.0)).abs() < 1e-6);
1025 }
1026
1027 #[test]
1028 fn reports_higher_than_draft03_when_rtts_diverge() {
1029 let (f, s, from, to) = series(60.0, 60.0, 60.0, 20.0);
1033 let rpm = compute_responsiveness(&f, &s, from, to, 0.95).unwrap();
1034 let old = draft03(60.0, 60.0, 60.0, 20.0);
1035 assert!((rpm - 2000.0).abs() < 1e-6, "got {rpm}");
1036 assert!(rpm > old, "new {rpm} should exceed draft-03 {old}");
1037 }
1038
1039 #[test]
1040 fn returns_none_without_samples() {
1041 let f = ForeignProbeResults::default();
1042 let s = SelfProbeResults::default();
1043 let start = Timestamp::now();
1044 let to = start + Duration::from_millis(100);
1045 assert!(compute_responsiveness(&f, &s, start, to, 0.95).is_none());
1046 }
1047
1048 #[test]
1049 fn returns_none_on_zero_rtt() {
1050 let (f, s, from, to) = series(0.0, 0.0, 0.0, 0.0);
1052 assert!(compute_responsiveness(&f, &s, from, to, 0.95).is_none());
1053 }
1054
1055 fn conn_timing(
1058 connect_ms: u64,
1059 secure_ms: u64,
1060 application_ms: u64,
1061 tls_round_trips: u32,
1062 ) -> (ConnectionTiming, Timestamp) {
1063 let start = Timestamp::now();
1064 let mut t = ConnectionTiming::new(start);
1065 t.set_connect(start + Duration::from_millis(connect_ms));
1066 t.set_secure(start + Duration::from_millis(secure_ms));
1067 t.set_application(start + Duration::from_millis(application_ms));
1068 t.set_tls_round_trips(tls_round_trips);
1069 (t, start)
1070 }
1071
1072 #[test]
1073 fn foreign_phases_are_independent_single_rtt_each() {
1074 let (t, start) = conn_timing(30, 60, 62, 1);
1077 let finished_at = start + Duration::from_millis(92);
1078 let (tcp, tls, http) = foreign_probe_phases(&t, finished_at);
1079 assert_eq!(tcp, Duration::from_millis(30));
1080 assert_eq!(tls, Duration::from_millis(30));
1081 assert_eq!(http, Duration::from_millis(30));
1082 }
1083
1084 #[test]
1085 fn foreign_tls_phase_normalized_by_round_trips() {
1086 let (t, start) = conn_timing(30, 90, 92, 2);
1089 let finished_at = start + Duration::from_millis(122);
1090 let (tcp, tls, http) = foreign_probe_phases(&t, finished_at);
1091 assert_eq!(tcp, Duration::from_millis(30));
1092 assert_eq!(tls, Duration::from_millis(30)); assert_eq!(http, Duration::from_millis(30));
1094 }
1095
1096 #[test]
1097 fn foreign_phases_differ_from_cumulative_measurement() {
1098 let (t, start) = conn_timing(30, 60, 62, 1);
1101 let finished_at = start + Duration::from_millis(92);
1102
1103 let (tcp, tls, http) = foreign_probe_phases(&t, finished_at);
1104 let new_sum = (tcp + tls + http).as_secs_f64() * 1000.0; let old_tcp = t.time_connect().as_secs_f64() * 1000.0; let old_tls = t.time_secure().as_secs_f64() * 1000.0; let old_http = finished_at.duration_since(t.start()).as_secs_f64() * 1000.0; let old_sum = old_tcp + old_tls + old_http; assert!(new_sum < old_sum, "new {new_sum} should be < old {old_sum}");
1113 assert!((new_sum - 90.0).abs() < 1e-6);
1114 assert!((old_sum - 182.0).abs() < 1e-6);
1115 }
1116
1117 #[test]
1118 fn reports_the_saturated_value_when_responsiveness_converged() {
1119 assert_eq!(select_reported_rpm(Some(340.0), Some(999.0)), Some(340.0));
1121 }
1122
1123 #[test]
1124 fn reports_the_last_interval_when_the_time_limit_is_reached() {
1125 assert_eq!(select_reported_rpm(None, Some(347.9)), Some(347.9));
1128 }
1129
1130 #[test]
1131 fn reports_nothing_when_no_interval_ever_measured() {
1132 assert_eq!(select_reported_rpm(None, None), None);
1135 }
1136}