Skip to main content

sipx_call/
load.rs

1//! Placing many calls at once, and reporting honestly about what happened.
2//!
3//! Generic over what "a call" means — it is a closure returning a future — so the same harness
4//! drives sipx against itself and sipx against a third-party server. That is not generality for
5//! its own sake: a limit found with sipx on both ends cannot be attributed to either half, and
6//! the whole point of a load test is to find out which side gives out first.
7//!
8//! Two rules about the reporting, both of which exist because breaking them makes the numbers
9//! worse than useless:
10//!
11//! **Failures are counted by cause, never aggregated.** A run that goes from 99% to 97% success
12//! looks like mild degradation and may be a new failure appearing while an old one recedes.
13//! Which failure is growing is the entire question.
14//!
15//! **Latency is reported as percentiles, never as a mean.** Call setup latency is not normally
16//! distributed — it is a tight cluster with a tail of retransmission timeouts — and a mean sits
17//! in the empty space between the two, describing a call that never happened.
18
19// Counts here are call counts and percentile ranks: a run large enough to lose `f64` precision
20// would need more calls than there are microseconds in a century.
21#![allow(
22    clippy::cast_precision_loss,
23    clippy::cast_possible_truncation,
24    clippy::cast_sign_loss
25)]
26
27use std::collections::BTreeMap;
28use std::sync::Arc;
29use std::sync::atomic::{AtomicUsize, Ordering};
30use std::time::Duration;
31
32use tokio_util::sync::CancellationToken;
33
34/// Why a call did not succeed.
35///
36/// Deliberately coarse. Finer categories would be guesses: the harness sees a failure and a
37/// duration, and inventing a taxonomy it cannot actually distinguish would produce a report
38/// that looks precise and is not.
39#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
40pub enum Cause {
41    /// The far end refused, with the status it gave.
42    Rejected(u16),
43    /// Nothing came back in time.
44    Timeout,
45    /// The transport failed — refused connection, closed socket, unreachable host.
46    Transport,
47    /// Something else, described.
48    Other(String),
49}
50
51impl std::fmt::Display for Cause {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            Self::Rejected(status) => write!(f, "rejected {status}"),
55            Self::Timeout => f.write_str("timeout"),
56            Self::Transport => f.write_str("transport"),
57            Self::Other(what) => write!(f, "{what}"),
58        }
59    }
60}
61
62/// How much load to apply.
63#[derive(Debug, Clone, Copy)]
64pub struct Plan {
65    /// How many calls to place in total.
66    pub calls: usize,
67    /// How many to start per second.
68    ///
69    /// The arrival rate, not the concurrency: a rate of 50 with calls lasting two seconds
70    /// settles at about a hundred in progress. Driving by rate rather than by concurrency is
71    /// what makes a run reproducible — a harness that keeps N in flight speeds up when the
72    /// system under test slows down, which is the opposite of a load test.
73    pub rate: f64,
74    /// The most to have in progress at once, whatever the rate says.
75    ///
76    /// A backstop, not a target. Without it, a system that has stopped answering entirely
77    /// accumulates every call the plan asks for and the harness runs out of sockets before the
78    /// thing it is testing does.
79    pub most_in_flight: usize,
80}
81
82/// Finite admission and cleanup limits for an externally controllable run.
83///
84/// Unlike [`Plan`], either `calls` or `duration` may end admission. At least one must be present;
85/// command layers validate that contract before calling this harness.
86#[derive(Debug, Clone, Copy)]
87pub struct BoundedPlan {
88    /// Maximum calls admitted, if count-bounded.
89    pub calls: Option<usize>,
90    /// Maximum time during which calls may be admitted, if time-bounded.
91    /// A duration beyond the runtime clock's range closes admission immediately rather than
92    /// panicking; command layers should reject it as invalid input.
93    pub duration: Option<Duration>,
94    /// Calls admitted per second.
95    pub rate: f64,
96    /// Reproducible arrival-jitter seed.
97    pub seed: u64,
98    /// Maximum simultaneously active calls.
99    ///
100    /// The harness normalizes zero to one and values above Tokio's semaphore ceiling to that
101    /// ceiling so a programmatically constructed plan cannot panic. User-facing command layers
102    /// should reject either value and report the invalid configuration instead.
103    pub most_in_flight: usize,
104    /// Time allowed for every owned call to acknowledge stop and finish.
105    pub cleanup: Duration,
106}
107
108impl BoundedPlan {
109    fn interval(self) -> Duration {
110        Plan {
111            calls: self.calls.unwrap_or(0),
112            rate: self.rate,
113            most_in_flight: self.most_in_flight,
114        }
115        .interval()
116    }
117
118    fn gap(self, index: usize) -> Duration {
119        let base = self.interval();
120        if base.is_zero() {
121            return base;
122        }
123        // A stateless integer mixer gives each call a stable, well-distributed value without
124        // mutable scheduler state. The factor in [0.5, 1.5) preserves the requested average while
125        // avoiding an artificial metronome. Wrapping arithmetic is intentional, not an overflow
126        // of a workload bound.
127        let mut value = self.seed.wrapping_add(
128            u64::try_from(index)
129                .unwrap_or(u64::MAX)
130                .wrapping_mul(0x9e37_79b9_7f4a_7c15),
131        );
132        value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
133        value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
134        value ^= value >> 31;
135        let unit = (value >> 11) as f64 / ((1u64 << 53) as f64);
136        Duration::try_from_secs_f64(base.as_secs_f64() * (0.5 + unit)).unwrap_or(base)
137    }
138}
139
140/// A clonable stop signal shared by admission and every owned call.
141#[derive(Debug, Clone, Default)]
142pub struct Stop {
143    token: CancellationToken,
144}
145
146impl Stop {
147    /// A fresh signal that has not been requested.
148    #[must_use]
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Close admission and ask active calls to clean up.
154    pub fn request(&self) {
155        self.token.cancel();
156    }
157
158    /// Wait until cleanup has been requested.
159    pub async fn requested(&self) {
160        self.token.cancelled().await;
161    }
162
163    /// Whether cleanup has already been requested.
164    #[must_use]
165    pub fn is_requested(&self) -> bool {
166        self.token.is_cancelled()
167    }
168}
169
170/// Why the harness closed admission.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum AdmissionEnd {
173    /// The configured call count was admitted.
174    Calls,
175    /// The configured admission duration elapsed.
176    Duration,
177    /// The owner requested interruption.
178    Requested,
179}
180
181/// Outcome plus the lifecycle facts a bounded command must report.
182#[derive(Debug, Clone)]
183pub struct BoundedOutcome {
184    /// Per-call counts and setup measurements.
185    pub outcome: Outcome,
186    /// Greatest number of calls active simultaneously.
187    pub peak_in_flight: usize,
188    /// The event that closed admission.
189    pub admission_end: AdmissionEnd,
190    /// Whether every owned task finished inside the cleanup budget.
191    pub cleanup_complete: bool,
192}
193
194impl Plan {
195    /// A plan placing this many calls at this rate.
196    #[must_use]
197    pub fn new(calls: usize, rate: f64) -> Self {
198        Self {
199            calls,
200            rate,
201            most_in_flight: 512,
202        }
203    }
204
205    /// The gap between two calls starting.
206    ///
207    /// `rate` is a public field, so it can be anything a caller can write — and
208    /// `Duration::from_secs_f64` *panics* on NaN or on a value too large to represent. A
209    /// denormal rate such as `1e-300` reaches the second case. A load harness that panics on
210    /// its own configuration is not a load harness, so both collapse to "as fast as possible",
211    /// which is what a nonsensical rate most nearly means.
212    #[must_use]
213    pub fn interval(&self) -> Duration {
214        if !self.rate.is_finite() || self.rate <= 0.0 {
215            return Duration::ZERO;
216        }
217        Duration::try_from_secs_f64(1.0 / self.rate).unwrap_or(Duration::ZERO)
218    }
219}
220
221/// What a run produced.
222#[derive(Debug, Default, Clone)]
223pub struct Outcome {
224    /// How many were attempted.
225    pub attempted: usize,
226    /// How many succeeded.
227    pub succeeded: usize,
228    /// Failures, by cause. Never summed into a single number.
229    pub failures: BTreeMap<Cause, usize>,
230    /// How long each successful call took to set up.
231    pub setup: Vec<Duration>,
232    /// How long the whole run took.
233    pub elapsed: Duration,
234}
235
236impl Outcome {
237    /// Calls completed per second over the run.
238    #[must_use]
239    pub fn calls_per_second(&self) -> f64 {
240        let seconds = self.elapsed.as_secs_f64();
241        if seconds <= 0.0 {
242            return 0.0;
243        }
244        // Successes, not attempts: a harness that counted attempts would report its highest
245        // throughput at the moment the system under test stopped working.
246        self.succeeded as f64 / seconds
247    }
248
249    /// The setup latency at this percentile, from 0.0 to 1.0.
250    ///
251    /// Nearest-rank, which is the definition that always names a measurement that actually
252    /// happened rather than interpolating between two that did.
253    #[must_use]
254    pub fn percentile(&self, fraction: f64) -> Option<Duration> {
255        if self.setup.is_empty() || !fraction.is_finite() {
256            // A NaN fraction would `clamp` to NaN, cast to 0, and silently return the *fastest*
257            // call as though it were the answer to whatever was asked.
258            return None;
259        }
260        let mut sorted = self.setup.clone();
261        sorted.sort_unstable();
262        let rank = (fraction.clamp(0.0, 1.0) * sorted.len() as f64).ceil() as usize;
263        sorted
264            .get(rank.saturating_sub(1).min(sorted.len() - 1))
265            .copied()
266    }
267
268    /// How many failed, all causes.
269    #[must_use]
270    pub fn failed(&self) -> usize {
271        self.failures.values().sum()
272    }
273
274    /// A report a person can read.
275    #[must_use]
276    pub fn report(&self) -> String {
277        use std::fmt::Write as _;
278        let mut out = String::new();
279        // discard: writing formatted text into a String is infallible
280        let _ = writeln!(
281            out,
282            "{} attempted, {} succeeded, {} failed in {:.1}s ({:.1} calls/s)",
283            self.attempted,
284            self.succeeded,
285            self.failed(),
286            self.elapsed.as_secs_f64(),
287            self.calls_per_second()
288        );
289        for (label, fraction) in [("p50", 0.50), ("p95", 0.95), ("p99", 0.99)] {
290            if let Some(at) = self.percentile(fraction) {
291                // discard: writing formatted text into a String is infallible
292                let _ = writeln!(out, "  setup {label}: {:.0} ms", at.as_secs_f64() * 1000.0);
293            }
294        }
295        // Every cause on its own line. Which one is growing is the whole question.
296        for (cause, count) in &self.failures {
297            // discard: writing formatted text into a String is infallible
298            let _ = writeln!(out, "  {count} × {cause}");
299        }
300        out
301    }
302}
303
304/// Run a plan, placing calls with `place`.
305///
306/// `place` is given the call's index and returns whether it worked. Everything about what a
307/// call *is* lives there, which is what lets the same harness point at sipx or at somebody
308/// else's server.
309pub async fn run<F, Fut>(plan: Plan, place: F) -> Outcome
310where
311    F: Fn(usize) -> Fut + Send + Sync + 'static,
312    Fut: std::future::Future<Output = Result<(), Cause>> + Send + 'static,
313{
314    let place = std::sync::Arc::new(place);
315    let permits = std::sync::Arc::new(tokio::sync::Semaphore::new(
316        plan.most_in_flight
317            .clamp(1, tokio::sync::Semaphore::MAX_PERMITS),
318    ));
319
320    let started = tokio::time::Instant::now();
321    let interval = plan.interval();
322    let mut running = Vec::with_capacity(plan.calls);
323
324    for index in 0..plan.calls {
325        // Paced by the clock rather than by completion, so the load applied does not depend on
326        // how fast the system under test is answering.
327        if !interval.is_zero() {
328            tokio::time::sleep_until(started + interval * u32::try_from(index).unwrap_or(0)).await;
329        }
330        let Ok(permit) = std::sync::Arc::clone(&permits).acquire_owned().await else {
331            break;
332        };
333
334        let place = std::sync::Arc::clone(&place);
335        running.push(tokio::spawn(async move {
336            let at = tokio::time::Instant::now();
337            let outcome = place(index).await;
338            let took = at.elapsed();
339            drop(permit);
340            (outcome, took)
341        }));
342    }
343
344    let mut outcome = Outcome {
345        attempted: running.len(),
346        ..Outcome::default()
347    };
348    // Joined rather than collected from a channel, because a channel only hears from calls that
349    // *finished*. A `place` future that panics unwinds its task and sends nothing, so its call
350    // would appear in neither `succeeded` nor `failures` — a run with fifty panics reporting
351    // "300 attempted, 250 succeeded, 0 failed" and an empty cause map. The one thing this
352    // module promises is that every failure has a cause.
353    for handle in running {
354        match handle.await {
355            Ok((Ok(()), took)) => {
356                outcome.succeeded += 1;
357                outcome.setup.push(took);
358            }
359            Ok((Err(cause), _)) => *outcome.failures.entry(cause).or_default() += 1,
360            Err(joined) => {
361                let what = if joined.is_panic() {
362                    "panicked"
363                } else {
364                    "cancelled"
365                };
366                *outcome
367                    .failures
368                    .entry(Cause::Other(what.to_owned()))
369                    .or_default() += 1;
370            }
371        }
372    }
373    outcome.elapsed = started.elapsed();
374    outcome
375}
376
377fn account_bounded(
378    joined: std::result::Result<(std::result::Result<(), Cause>, Duration), tokio::task::JoinError>,
379    outcome: &mut Outcome,
380) -> bool {
381    match joined {
382        Ok((Ok(()), took)) => {
383            outcome.succeeded += 1;
384            outcome.setup.push(took);
385            false
386        }
387        Ok((Err(cause), _)) => {
388            let internal = matches!(cause, Cause::Other(_));
389            *outcome.failures.entry(cause).or_default() += 1;
390            internal
391        }
392        Err(joined) => {
393            let label = if joined.is_panic() {
394                "panicked"
395            } else {
396                "cancelled"
397            };
398            *outcome
399                .failures
400                .entry(Cause::Other(label.to_owned()))
401                .or_default() += 1;
402            true
403        }
404    }
405}
406
407struct ActiveCall {
408    active: Arc<AtomicUsize>,
409}
410
411impl Drop for ActiveCall {
412    fn drop(&mut self) {
413        self.active.fetch_sub(1, Ordering::SeqCst);
414    }
415}
416
417/// Run a finitely bounded plan, stopping admission and draining every owned call before return.
418///
419/// The call future receives the same [`Stop`] as the scheduler. Once it has established a call it
420/// should select that signal alongside its normal holding period, then perform its protocol cleanup
421/// before returning. The harness never detaches work: even a cleanup-budget failure aborts and joins
422/// every local task before it reports that cleanup was incomplete.
423#[allow(
424    clippy::too_many_lines,
425    reason = "admission and drain are one lifecycle; splitting them would make detached cleanup easier to write"
426)]
427pub async fn run_bounded<F, Fut>(plan: BoundedPlan, stop: Stop, place: F) -> BoundedOutcome
428where
429    F: Fn(usize, Stop) -> Fut + Send + Sync + 'static,
430    Fut: std::future::Future<Output = Result<(), Cause>> + Send + 'static,
431{
432    let place = Arc::new(place);
433    let permits = Arc::new(tokio::sync::Semaphore::new(
434        plan.most_in_flight
435            .clamp(1, tokio::sync::Semaphore::MAX_PERMITS),
436    ));
437    let active = Arc::new(AtomicUsize::new(0));
438    let peak = Arc::new(AtomicUsize::new(0));
439    let started = tokio::time::Instant::now();
440    let duration_deadline = plan
441        .duration
442        .map(|duration| started.checked_add(duration).unwrap_or(started));
443    let mut running = tokio::task::JoinSet::new();
444    let mut admitted = 0usize;
445    let mut scheduled = started;
446    let mut outcome = Outcome::default();
447
448    let admission_end = loop {
449        // Completed tasks stay allocated inside a JoinSet until they are joined. Drain them on
450        // every admission turn rather than retaining one record per call until admission closes;
451        // a long count-bounded run then uses memory in proportion to active concurrency.
452        let mut internal_failure = false;
453        while let Some(joined) = running.try_join_next() {
454            internal_failure |= account_bounded(joined, &mut outcome);
455        }
456        if internal_failure {
457            stop.request();
458        }
459        if stop.is_requested() {
460            break AdmissionEnd::Requested;
461        }
462        if plan.calls.is_some_and(|calls| admitted >= calls) {
463            break AdmissionEnd::Calls;
464        }
465
466        let admission_wait = tokio::time::sleep_until(scheduled);
467        tokio::pin!(admission_wait);
468        if let Some(deadline) = duration_deadline {
469            tokio::select! {
470                biased;
471                () = stop.requested() => break AdmissionEnd::Requested,
472                () = tokio::time::sleep_until(deadline) => break AdmissionEnd::Duration,
473                () = &mut admission_wait => {}
474            }
475        } else {
476            tokio::select! {
477                biased;
478                () = stop.requested() => break AdmissionEnd::Requested,
479                () = &mut admission_wait => {}
480            }
481        }
482
483        let permit = if let Some(deadline) = duration_deadline {
484            tokio::select! {
485                biased;
486                () = stop.requested() => break AdmissionEnd::Requested,
487                () = tokio::time::sleep_until(deadline) => break AdmissionEnd::Duration,
488                permit = Arc::clone(&permits).acquire_owned() => permit,
489            }
490        } else {
491            tokio::select! {
492                biased;
493                () = stop.requested() => break AdmissionEnd::Requested,
494                permit = Arc::clone(&permits).acquire_owned() => permit,
495            }
496        };
497        let Ok(permit) = permit else {
498            break AdmissionEnd::Requested;
499        };
500
501        let index = admitted;
502        admitted += 1;
503        scheduled += plan.gap(index);
504        let place = Arc::clone(&place);
505        let call_stop = stop.clone();
506        let active = Arc::clone(&active);
507        let peak = Arc::clone(&peak);
508        running.spawn(async move {
509            let _permit = permit;
510            let now_active = active.fetch_add(1, Ordering::SeqCst) + 1;
511            let _active = ActiveCall { active };
512            peak.fetch_max(now_active, Ordering::SeqCst);
513            let at = tokio::time::Instant::now();
514            // The JoinSet owns this future directly. Aborting the set therefore drops the call
515            // future, its permit and its active guard before the harness can return.
516            let result = place(index, call_stop).await;
517            (result, at.elapsed())
518        });
519    };
520
521    // A count or duration bound is also an instruction to end the calls it owns. A call may have
522    // connected just before admission closed; it observes this before the summary is emitted.
523    stop.request();
524    let cleanup_deadline = tokio::time::Instant::now() + plan.cleanup;
525    outcome.attempted = admitted;
526    let mut cleanup_complete = true;
527    while !running.is_empty() {
528        match tokio::time::timeout_at(cleanup_deadline, running.join_next()).await {
529            Ok(Some(joined)) => {
530                let _internal = account_bounded(joined, &mut outcome);
531            }
532            Ok(None) => break,
533            Err(_) => {
534                cleanup_complete = false;
535                let unfinished = running.len();
536                running.abort_all();
537                while running.join_next().await.is_some() {}
538                *outcome
539                    .failures
540                    .entry(Cause::Other("cleanup budget exhausted".to_owned()))
541                    .or_default() += unfinished;
542            }
543        }
544    }
545    outcome.elapsed = started.elapsed();
546
547    BoundedOutcome {
548        outcome,
549        peak_in_flight: peak.load(Ordering::SeqCst),
550        admission_end,
551        cleanup_complete,
552    }
553}
554
555#[cfg(test)]
556#[allow(
557    clippy::unwrap_used,
558    clippy::expect_used,
559    clippy::panic,
560    clippy::indexing_slicing
561)]
562mod tests {
563    use super::*;
564
565    struct DropFlag(Arc<AtomicUsize>);
566
567    impl Drop for DropFlag {
568        fn drop(&mut self) {
569            self.0.fetch_add(1, Ordering::SeqCst);
570        }
571    }
572
573    /// DPH-10: the count bound closes admission, signals every owned call, and the result is not
574    /// returned until all of them have acknowledged cleanup.
575    #[tokio::test]
576    async fn bounded_run_reaches_its_call_bound_and_cleans_every_owned_call() {
577        let cleaned = Arc::new(AtomicUsize::new(0));
578        let seen = Arc::clone(&cleaned);
579        let bounded = run_bounded(
580            BoundedPlan {
581                calls: Some(6),
582                duration: None,
583                rate: 100_000.0,
584                seed: 7,
585                most_in_flight: 6,
586                cleanup: Duration::from_secs(1),
587            },
588            Stop::new(),
589            move |_, stop| {
590                let cleaned = Arc::clone(&seen);
591                async move {
592                    stop.requested().await;
593                    cleaned.fetch_add(1, Ordering::SeqCst);
594                    Ok(())
595                }
596            },
597        )
598        .await;
599
600        assert_eq!(bounded.admission_end, AdmissionEnd::Calls);
601        assert_eq!(bounded.outcome.attempted, 6);
602        assert_eq!(bounded.outcome.succeeded, 6);
603        assert_eq!(cleaned.load(Ordering::SeqCst), 6);
604        assert!(bounded.cleanup_complete);
605    }
606
607    /// DPH-11: interruption is a causal signal, not a sleep followed by an assumption. Once the
608    /// first call announces that it started, interruption closes admission and cleanup completes
609    /// before the harness returns.
610    #[tokio::test]
611    async fn interrupted_run_stops_admission_and_waits_for_cleanup() {
612        let stop = Stop::new();
613        let controller = stop.clone();
614        let started = Arc::new(tokio::sync::Notify::new());
615        let began = Arc::clone(&started);
616        let cleaned = Arc::new(AtomicUsize::new(0));
617        let seen = Arc::clone(&cleaned);
618
619        let run = tokio::spawn(run_bounded(
620            BoundedPlan {
621                calls: Some(10_000),
622                duration: None,
623                rate: 1.0,
624                seed: 9,
625                most_in_flight: 2,
626                cleanup: Duration::from_secs(1),
627            },
628            stop,
629            move |_, stop| {
630                began.notify_one();
631                let cleaned = Arc::clone(&seen);
632                async move {
633                    stop.requested().await;
634                    cleaned.fetch_add(1, Ordering::SeqCst);
635                    Ok(())
636                }
637            },
638        ));
639
640        started.notified().await;
641        controller.request();
642        let bounded = run.await.expect("the bounded harness joins");
643
644        assert_eq!(bounded.admission_end, AdmissionEnd::Requested);
645        assert!(bounded.outcome.attempted < 10_000);
646        assert_eq!(
647            cleaned.load(Ordering::SeqCst),
648            bounded.outcome.attempted,
649            "the summary follows cleanup of every owned call"
650        );
651        assert!(bounded.cleanup_complete);
652    }
653
654    /// A cleanup deadline may abort work, but may never detach it. The flag is owned by the call
655    /// future itself, so observing its drop proves `run_bounded` joined the aborted future before
656    /// returning rather than only aborting an outer wrapper.
657    #[tokio::test]
658    async fn cleanup_timeout_drops_the_owned_call_before_returning() {
659        let dropped = Arc::new(AtomicUsize::new(0));
660        let observed = Arc::clone(&dropped);
661        let bounded = run_bounded(
662            BoundedPlan {
663                calls: Some(1),
664                duration: None,
665                rate: 1.0,
666                seed: 0,
667                most_in_flight: 1,
668                cleanup: Duration::from_millis(20), // A bound on failure: this call never ends.
669            },
670            Stop::new(),
671            move |_, _| {
672                let flag = DropFlag(Arc::clone(&observed));
673                async move {
674                    let _flag = flag;
675                    std::future::pending::<Result<(), Cause>>().await
676                }
677            },
678        )
679        .await;
680
681        assert!(!bounded.cleanup_complete);
682        assert_eq!(dropped.load(Ordering::SeqCst), 1);
683        assert_eq!(bounded.outcome.attempted, 1);
684        assert_eq!(bounded.outcome.failed(), 1);
685    }
686
687    /// X-4's exit criterion, and the reason it is about the harness rather than about sipx: a
688    /// load harness that miscounts is worse than no load harness, because the numbers look
689    /// like measurements.
690    #[tokio::test]
691    async fn the_harness_reports_a_failure_it_was_given() {
692        let outcome = run(Plan::new(10, 1000.0), |index| async move {
693            if index % 2 == 0 {
694                Err(Cause::Rejected(486))
695            } else {
696                Ok(())
697            }
698        })
699        .await;
700
701        assert_eq!(outcome.attempted, 10);
702        assert_eq!(outcome.succeeded, 5);
703        assert_eq!(outcome.failed(), 5);
704        assert_eq!(outcome.failures.get(&Cause::Rejected(486)), Some(&5));
705    }
706
707    /// Causes are kept apart. A run whose success rate slips two points may be a new failure
708    /// appearing while an old one recedes, and an aggregate hides exactly that.
709    #[tokio::test]
710    async fn failures_are_counted_by_cause() {
711        let outcome = run(Plan::new(9, 1000.0), |index| async move {
712            match index % 3 {
713                0 => Err(Cause::Timeout),
714                1 => Err(Cause::Rejected(503)),
715                _ => Err(Cause::Transport),
716            }
717        })
718        .await;
719
720        assert_eq!(outcome.succeeded, 0);
721        assert_eq!(outcome.failures.get(&Cause::Timeout), Some(&3));
722        assert_eq!(outcome.failures.get(&Cause::Rejected(503)), Some(&3));
723        assert_eq!(outcome.failures.get(&Cause::Transport), Some(&3));
724        assert_eq!(outcome.failures.len(), 3, "three causes, not one number");
725    }
726
727    /// The tail is the point. A mean of these would sit in the empty space between the cluster
728    /// and the tail, describing a call that never happened.
729    #[tokio::test]
730    async fn percentiles_describe_the_tail_rather_than_the_average() {
731        let mut outcome = Outcome {
732            attempted: 100,
733            succeeded: 100,
734            elapsed: Duration::from_secs(1),
735            ..Outcome::default()
736        };
737        // Ninety fast calls and ten very slow ones — the shape call setup actually has.
738        outcome.setup = (0..90)
739            .map(|_| Duration::from_millis(20))
740            .chain((0..10).map(|_| Duration::from_secs(2)))
741            .collect();
742
743        assert_eq!(outcome.percentile(0.50), Some(Duration::from_millis(20)));
744        assert_eq!(
745            outcome.percentile(0.95),
746            Some(Duration::from_secs(2)),
747            "the tail must be visible at p95"
748        );
749
750        // What a mean would have said, for contrast: 218 ms, which describes none of them.
751        let mean: Duration = outcome.setup.iter().sum::<Duration>() / 100;
752        assert!(mean > Duration::from_millis(200) && mean < Duration::from_millis(230));
753        assert_ne!(
754            Some(mean),
755            outcome.percentile(0.50),
756            "a mean here is not the typical call"
757        );
758    }
759
760    #[tokio::test]
761    async fn percentiles_of_nothing_are_nothing() {
762        let outcome = Outcome::default();
763        assert_eq!(outcome.percentile(0.5), None, "not zero, which is a claim");
764    }
765
766    /// Throughput counts successes. Counting attempts would report the highest number at the
767    /// moment the system under test stopped working.
768    #[test]
769    fn throughput_counts_calls_that_worked() {
770        let outcome = Outcome {
771            attempted: 100,
772            succeeded: 40,
773            elapsed: Duration::from_secs(2),
774            ..Outcome::default()
775        };
776        assert!((outcome.calls_per_second() - 20.0).abs() < 0.001);
777    }
778
779    /// The rate is an arrival rate. A harness that instead kept N in flight would speed up as
780    /// the system under test slowed down, which is the opposite of applying load.
781    #[tokio::test(start_paused = true)]
782    async fn calls_are_paced_by_the_clock_not_by_completion() {
783        let started = tokio::time::Instant::now();
784        let outcome = run(Plan::new(10, 10.0), |_| async {
785            tokio::time::sleep(Duration::from_secs(5)).await;
786            Ok(())
787        })
788        .await;
789
790        assert_eq!(outcome.succeeded, 10);
791        // Ten calls at ten per second is about a second of launching, plus the five seconds the
792        // last one takes. A completion-driven harness would have taken fifty.
793        assert!(
794            started.elapsed() < Duration::from_secs(20),
795            "the harness waited for each call before starting the next: {:?}",
796            started.elapsed()
797        );
798    }
799
800    #[tokio::test]
801    async fn the_report_names_every_cause() {
802        let outcome = run(Plan::new(4, 1000.0), |index| async move {
803            if index == 0 {
804                Err(Cause::Timeout)
805            } else {
806                Err(Cause::Other("no route".to_owned()))
807            }
808        })
809        .await;
810
811        let report = outcome.report();
812        assert!(report.contains("timeout"), "{report}");
813        assert!(report.contains("no route"), "{report}");
814        assert!(report.contains("4 attempted"), "{report}");
815    }
816}
817
818#[cfg(test)]
819#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
820mod robustness {
821    use super::*;
822
823    /// A call whose future panics must still be accounted for. Losing it makes the harness
824    /// under-report silently, and the one thing this module promises is that every failure has
825    /// a cause.
826    #[tokio::test]
827    async fn a_panicking_call_is_reported_rather_than_lost() {
828        let outcome = run(Plan::new(6, 1000.0), |index| async move {
829            assert!(index % 2 != 0, "deliberate");
830            Ok(())
831        })
832        .await;
833
834        assert_eq!(outcome.attempted, 6);
835        assert_eq!(outcome.succeeded, 3);
836        assert_eq!(
837            outcome.succeeded + outcome.failed(),
838            outcome.attempted,
839            "every attempt must land somewhere: {outcome:?}"
840        );
841        assert_eq!(
842            outcome.failures.get(&Cause::Other("panicked".to_owned())),
843            Some(&3)
844        );
845    }
846
847    /// `rate` is a public field, so it can be anything a caller can write — and
848    /// `Duration::from_secs_f64` panics on NaN. A load harness that panics on its own
849    /// configuration is not a load harness.
850    #[test]
851    fn a_nonsensical_rate_does_not_panic() {
852        for rate in [
853            f64::NAN,
854            f64::INFINITY,
855            f64::NEG_INFINITY,
856            -1.0,
857            0.0,
858            1e-300,
859        ] {
860            let interval = Plan {
861                calls: 1,
862                rate,
863                most_in_flight: 1,
864            }
865            .interval();
866            assert!(
867                interval.is_zero() || interval > Duration::ZERO,
868                "rate {rate}"
869            );
870        }
871    }
872
873    /// And a NaN percentile answers "no measurement" rather than silently returning the fastest
874    /// call as though it were the answer.
875    #[test]
876    fn a_nonsensical_percentile_is_none_rather_than_the_fastest_call() {
877        let outcome = Outcome {
878            setup: vec![Duration::from_millis(1), Duration::from_secs(9)],
879            ..Outcome::default()
880        };
881        assert_eq!(outcome.percentile(f64::NAN), None);
882        assert_eq!(outcome.percentile(0.0), Some(Duration::from_millis(1)));
883    }
884}