Skip to main content

cf_mach/nq_rpm/
mod.rs

1// Copyright (c) 2023-2024 Cloudflare, Inc.
2// Licensed under the BSD-3-Clause license found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
3
4use 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/// What to do when a load-generating connection terminates with an error.
25///
26/// draft-ietf-ippm-responsiveness-09 §5.4 says "if at any point one of these
27/// connections terminates with an error, the test should be aborted". That
28/// "should" is lowercase, so it is advisory rather than a BCP 14 requirement,
29/// and aborting outright is often not the most useful behaviour: a server that
30/// rejects oversized uploads (HTTP 413) would abort every run. The default
31/// therefore retires the failed connection and lets the ramp replace it, while
32/// still recording and reporting the failure.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum ConnectionErrorPolicy {
35    /// Retire the failed connection, keep measuring, and report the failure
36    /// count. Aborts only if load can no longer be sustained at all.
37    #[default]
38    Retire,
39    /// Abort the test on the first failure, as the draft literally describes.
40    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    /// Maximum bytes sent in any single upload load-generating request.
57    ///
58    /// Upload load is generated as a sequence of requests of this size on each
59    /// connection, rather than one enormous request, because servers may cap
60    /// request body size and reject anything larger with HTTP 413. Such caps
61    /// apply per-request, so staying under one here keeps the link loaded
62    /// indefinitely without ever tripping it.
63    ///
64    /// Must be below the smallest such cap on the path, with margin. It has no
65    /// effect on connections too slow to send this many bytes within the test
66    /// duration, since their first request never completes either way.
67    pub upload_bytes_per_request: usize,
68    /// What to do when a load-generating connection terminates with an error.
69    pub on_connection_error: ConnectionErrorPolicy,
70    /// Headers attached only to requests whose host matches the scope's
71    /// allowlist.
72    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
86/// Default bytes per upload load-generating request.
87///
88/// 100 MB sits well under the request body caps servers commonly impose, with
89/// margin for the stricter ones. On links too slow to send that much within the
90/// test duration the first request never completes anyway, so for them this is
91/// indistinguishable from an unbounded request.
92pub 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    /// The value to report, set once responsiveness saturation is declared.
131    /// `None` until then; see [`Self::last_rpm`] for the unconverged case.
132    rpm: Option<f64>,
133    /// RPM at the most recent interval that actually produced a measurement.
134    ///
135    /// This is the value reported when the test hits its time limit without
136    /// declaring saturation, which for the upload leg is the norm rather than
137    /// the exception. Each sample is already a trimmed mean over the moving
138    /// average window (see [`compute_responsiveness`]), so it is reported as-is
139    /// and must not be averaged a second time.
140    last_rpm: Option<f64>,
141    capacity: f64,
142    /// Load-generating connections that terminated early with an error.
143    failed_connections: usize,
144    /// Consecutive intervals that ended with no live load-generating
145    /// connection while failures were occurring.
146    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        // Read before `config` is moved into the struct below.
154        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            // For uploads this is the size of each individual request, which the
169            // load generator re-issues on the same connection for the duration of
170            // the test -- not a total to be reached.
171            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    /// Run the responsiveness tests. This is a simple event loop which:
185    /// - executes an interval of the RPM algorithm every `interval_duration`
186    ///   seconds.
187    /// - sends alternating self and foreign probes. todo(fisher): need to limit
188    ///   to 100 probes/sec. (simple semaphore enough?).
189    ///
190    /// When the test completes or the test has been running too long, the test
191    /// completes and the results are reported.
192    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        // todo(fisher): switch to `Time` trait based sleep/interval impl to not
206        // rely on tokio for rpm tests.
207        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                            // There might not be an available load generating
228                            // connection to send a self probe on. If that's the
229                            // case, send another foreign probe.
230                            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                    // updated the load generating connection state.
246                    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        // The loop above exited without responsiveness ever stabilizing, which
272        // happens whenever the time limit is reached first -- the normal outcome
273        // for the upload leg. draft-ietf-ippm-responsiveness-09 §5.4 says to
274        // report the current result in that case rather than nothing, and
275        // "current_responsiveness" means the value at the final interval, not an
276        // average of recent ones: each sample is already a trimmed mean across
277        // the moving average window.
278        //
279        // This deliberately does NOT read a wall-clock window. It used to be
280        // `interval_average(now - 2s, now)`, but samples are stamped with the
281        // computed `start + interval_duration * interval` rather than the time
282        // they were taken, and `on_interval(i)` runs about one interval after the
283        // instant it stamps. The newest sample therefore sat almost exactly 2s
284        // behind `now`, so whether it fell inside the window came down to how
285        // promptly the loop happened to exit. When the exit was ~1s late the
286        // window matched nothing and `unwrap_or(0.0)` reported 0 RPM for an
287        // otherwise healthy run. Measured over 14 local runs: 13 exited within
288        // a millisecond and squeaked in, one exited 0.999s later and reported
289        // zero.
290        self.rpm = select_reported_rpm(self.rpm, self.last_rpm);
291
292        // stop all on-going loads.
293        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    /// Execute a single iteration of the responsiveness algorithm:
308    ///
309    /// * Create a load-generating connection.
310    ///
311    /// * At each interval:
312    ///
313    ///   - Create an additional load-generating connection.
314    ///
315    ///   - If goodput has not saturated:
316    ///
317    ///     - Compute the moving average aggregate goodput at interval i as
318    ///       current_average.
319    ///
320    ///     - If the standard deviation of the past MAD average goodput values is less
321    ///       than SDT of the current_average, declare goodput saturation and move on
322    ///       to probe responsiveness.
323    ///
324    ///   - If goodput saturation has been declared:
325    ///
326    ///     - Compute the responsiveness at interval i as current_responsiveness.
327    ///
328    ///     - If the standard deviation of the past MAD responsiveness values is less
329    ///       than SDT of the current_responsiveness, declare responsiveness
330    ///       saturation and report current_responsiveness as the final test result.
331    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        // Determine the currently interval and round it to the interval duration.
339        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        // always start a load generating connection
349        // TODO: only if goodput is not saturated?
350        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        // Goodput is saturated if the std of the last MAD goodputs is within
366        // tolerance % of the current_average.
367        let goodput_saturated = std_goodput < current_goodput * self.config.std_tolerance;
368        if goodput_saturated {
369            // Goodput has stabilized, set the capacity to the average
370            // throughput of the last interval.
371            self.capacity = current_goodput;
372            self.goodput_saturated = true;
373        }
374
375        // `None` means this window held no probe measurements at all.
376        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        // An interval with no probes still contributes a 0.0 sample. That is
385        // arguably wrong on its face, but it is load-bearing and must not be
386        // "cleaned up" in isolation: a 0.0 sitting among values near 340 is a
387        // large outlier that inflates `interval_std`, and that inflation is the
388        // only thing currently preventing responsiveness from being declared
389        // stable during the ramp. Saturation is not gated on goodput saturation
390        // (see the conformance audit), so an early declaration latches
391        // `self.rpm` at a high ramp value and keeps it.
392        //
393        // Measured: dropping these samples made the upload leg latch at interval
394        // 1-2 while throughput_saturated was still false, reporting 593-709 RPM
395        // against a true value near 331 -- roughly double. Removing them
396        // requires gating saturation on goodput first, which is a separate
397        // change with its own validation.
398        //
399        // No NaN check is needed here: `compute_responsiveness` only yields
400        // `Some` for finite values, and 0.0 is finite.
401        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        // Only genuine measurements are eligible to be reported at the end, so a
405        // probe-less final interval cannot surface as "0 RPM".
406        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            // RPM is saturated if the std of the last MAD RPMs is
416            // within tolerance % of the current_rpm.
417            //
418            // When `current_rpm_or_zero` is 0.0 this is `std_rpm < 0.0`, which is
419            // never true, so a probe-less interval can never latch 0 RPM.
420            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        // stop testing if both goodput and RPM saturated:
443        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        // pretty print the results of the interval
459        let custom_options = humansize::FormatSizeOptions::from(DECIMAL)
460            .base_unit(humansize::BaseUnit::Bit)
461            .long_units(false)
462            .decimal_places(2);
463
464        // Logs the value the algorithm actually used, including the substituted
465        // 0.0, so the log matches the arithmetic. The substitution itself is
466        // surfaced separately below rather than being silent.
467        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    /// moving average aggregate goodput at interval p: The number of total
499    /// bytes of data transferred within interval p and the MAD (Moving Average Distance) - 1 immediately
500    /// preceding intervals, divided by MAD times ID (Interval Duration).
501    ///
502    /// <https://datatracker.ietf.org/doc/html/draft-ietf-ippm-responsiveness-09#section-5.4-5.2.1>
503    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    /// Apply [`ConnectionErrorPolicy`] to load-generating connections that
523    /// terminated early.
524    ///
525    /// Implements draft-ietf-ippm-responsiveness-09 §5.4's guidance that the
526    /// test should be aborted when a connection terminates with an error. See
527    /// [`ConnectionErrorPolicy`] for why the default is more forgiving than the
528    /// literal wording.
529    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        // Retiring failed connections only helps if the ramp can replace them.
559        // If an interval ends with nothing left transferring while failures are
560        // happening, no load is being generated and any responsiveness figure
561        // would be measured off an idle link -- so refuse to report one.
562        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    /// A GET/POST to an endpoint which sends/receives a large number of bytes
580    /// as quickly as possible. The intent of these connections is to saturate
581    /// a single connection's flow.
582    #[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    /// Sends a foreign probe which is a GET on a newly created connection.
612    ///
613    /// > An HTTP GET request on a connection separate from the load-generating
614    /// > connections ("foreign probes"). This probe type mimics the time it
615    /// > takes for a web browser to connect to a new web server and request the
616    /// > first element of a web page (e.g., "index.html"), or the startup time
617    /// > for a video streaming client to launch and begin fetching media.
618    ///
619    /// <https://datatracker.ietf.org/doc/html/draft-ietf-ippm-responsiveness-09#section-5.3-3.1.1>
620    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    /// Sends a self probe which is a GET on a load-generating connection.
673    ///
674    ///
675    /// > An HTTP GET request multiplexed on the load-generating connections
676    /// > ("self probes"). This probe type mimics the time it takes for a video
677    /// > streaming client to skip ahead to a different chapter in the same
678    /// > video stream, or for a navigation mapping application to react and
679    /// > fetch new map tiles when the user scrolls the map to view a different
680    /// > area. In a well functioning system, fetching new data over an existing
681    /// > connection should take less time than creating a brand new TLS
682    /// > connection from scratch to do the same thing.
683    ///
684    /// <https://datatracker.ietf.org/doc/html/draft-ietf-ippm-responsiveness-09#section-5.3-3.2.1>
685    fn send_self_probe(
686        &mut self,
687        event_tx: mpsc::Sender<Event>,
688        env: &Env,
689        shutdown: CancellationToken,
690    ) -> anyhow::Result<bool> {
691        // The test client should uniformly and randomly select from the active
692        // load-generating connections on which to send self probes.
693        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
790/// Responsiveness per draft-ietf-ippm-responsiveness-09 §5.3.1.1 (TLS-enabled
791/// case): convert each side to RPM first, then take the arithmetic mean of the
792/// two RPMs.
793///
794///   Foreign_Responsiveness = 60000 / ((TM(tcp_f) + TM(tls_f) + TM(http_f)) / 3)
795///   Loaded_Responsiveness  = 60000 / TM(http_l)
796///   Responsiveness         = (Foreign_Responsiveness + Loaded_Responsiveness) / 2
797///
798/// <https://datatracker.ietf.org/doc/html/draft-ietf-ippm-responsiveness-09#section-5.3.1.1>
799/// Pick the RPM to report for a leg that has finished.
800///
801/// `saturated` holds a value only once responsiveness saturation has been
802/// declared, which draft-ietf-ippm-responsiveness-09 §5.4 wants reported as the
803/// final result. Otherwise the test hit its time limit, and the draft directs us
804/// to report the current result instead -- the most recent interval that
805/// produced a measurement.
806///
807/// `None` from both means no interval ever measured anything, which is missing
808/// data and must not be flattened into a number by callers.
809///
810/// Deliberately takes no clock and no time window; see the call site in
811/// [`Responsiveness::run_test`] for the wall-clock window this replaced and why
812/// it could report zero.
813fn 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    // Mean foreign round-trip time and loaded round-trip time, in milliseconds.
832    let foreign_rtt = (tcp_f + tls_f + http_f) / 3.0;
833    let loaded_rtt = http_l;
834
835    // Guard against non-positive RTTs, which would produce a non-finite RPM.
836    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    /// Timestamp used to place the probe within the measurement window.
851    start: Timestamp,
852    /// TCP handshake duration (`tcp_f`).
853    tcp: Duration,
854    /// TLS handshake duration, normalized to the number of TLS round-trips
855    /// (`tls_f`).
856    tls: Duration,
857    /// HTTP request-issued to full-response-received duration (`http_f`).
858    http: Duration,
859}
860
861/// Computes the three independent foreign-probe phases per
862/// draft-ietf-ippm-responsiveness-09 §5.3:
863///
864/// * `tcp_f`  — the TCP handshake duration (DNS excluded).
865/// * `tls_f`  — the TLS handshake duration, normalized to the number of TLS
866///   round-trips the negotiated version uses.
867/// * `http_f` — the elapsed time between issuing the GET request and receiving
868///   the entire response, derived as `finished_at - (start + time_application)`,
869///   i.e. the interval after the connection is ready to transmit data.
870///
871/// These are deliberately non-overlapping: the earlier draft-03-style code
872/// measured every phase cumulatively from the connection start, which
873/// over-counted the foreign round-trip time (and thus under-reported RPM).
874fn 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    /// Round-trips per minute under working conditions.
921    ///
922    /// `None` means the test produced no responsiveness measurement at all --
923    /// not that responsiveness was zero. Consumers must surface that as missing
924    /// data rather than substituting a placeholder, because a plausible-looking
925    /// number is indistinguishable from a real one once it leaves this crate.
926    pub rpm: Option<f64>,
927    pub self_probe_latencies: TimeSeries,
928    pub loaded_connections: Vec<LoadedConnection>,
929    pub average_goodput_series: TimeSeries,
930    /// Load-generating connections that terminated early with an error. A
931    /// non-zero value means the link was not fully loaded for part of the run,
932    /// so the result is degraded.
933    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    /// Build foreign/self probe series with `n` identical samples for the given
973    /// per-phase latencies (in milliseconds), returning the results plus a
974    /// [from, to] window that covers all samples.
975    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    /// The old draft-03 harmonic combination, kept here only to prove the new
1003    /// formula reports a higher (less biased) value.
1004    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        // F = (30+30+30)/3 = 30 -> foreign_rpm = 2000
1012        // L = 30            -> loaded_rpm  = 2000
1013        // responsiveness    = (2000 + 2000) / 2 = 2000
1014        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        // When F == L the arithmetic and harmonic means coincide.
1022        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        // Foreign RTT (60ms) slower than loaded RTT (20ms): AM > HM.
1030        // new: (60000/60 + 60000/20)/2 = (1000 + 3000)/2 = 2000
1031        // old: 60000/((180/6) + (20/2)) = 60000/40 = 1500
1032        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        // Degenerate all-zero latencies must not yield a non-finite RPM.
1051        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    /// Build a ConnectionTiming with phases at the given ms offsets from a
1056    /// post-DNS baseline, plus a TLS round-trip count.
1057    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        // connect @30, secure @60, application @62, body finished @92.
1075        // tcp_f = 30, tls_f = 30 (1 RT), http_f = 92 - 62 = 30.
1076        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        // TLS 1.2 (2 round-trips): raw TLS handshake 60ms -> normalized 30ms.
1087        // connect @30, secure @90 (60ms TLS), application @92, finished @122.
1088        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)); // 60ms / 2
1093        assert_eq!(http, Duration::from_millis(30));
1094    }
1095
1096    #[test]
1097    fn foreign_phases_differ_from_cumulative_measurement() {
1098        // Proves the fix changed behavior: the old code used cumulative
1099        // durations (connect-from-start, secure-from-start, finished-from-start).
1100        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; // 90ms
1105
1106        // Old (draft-03-style) cumulative sum.
1107        let old_tcp = t.time_connect().as_secs_f64() * 1000.0; // 30
1108        let old_tls = t.time_secure().as_secs_f64() * 1000.0; // 60
1109        let old_http = finished_at.duration_since(t.start()).as_secs_f64() * 1000.0; // 92
1110        let old_sum = old_tcp + old_tls + old_http; // 182
1111
1112        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        // A declared saturation value wins over the last interval's sample.
1120        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        // The unconverged case, which is the norm for the upload leg: report the
1126        // most recent measurement rather than nothing.
1127        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        // Must stay absent rather than becoming 0.0: a zero is indistinguishable
1133        // from a real measurement once it leaves this crate.
1134        assert_eq!(select_reported_rpm(None, None), None);
1135    }
1136}