1#![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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
40pub enum Cause {
41 Rejected(u16),
43 Timeout,
45 Transport,
47 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#[derive(Debug, Clone, Copy)]
64pub struct Plan {
65 pub calls: usize,
67 pub rate: f64,
74 pub most_in_flight: usize,
80}
81
82#[derive(Debug, Clone, Copy)]
87pub struct BoundedPlan {
88 pub calls: Option<usize>,
90 pub duration: Option<Duration>,
94 pub rate: f64,
96 pub seed: u64,
98 pub most_in_flight: usize,
104 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 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#[derive(Debug, Clone, Default)]
142pub struct Stop {
143 token: CancellationToken,
144}
145
146impl Stop {
147 #[must_use]
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 pub fn request(&self) {
155 self.token.cancel();
156 }
157
158 pub async fn requested(&self) {
160 self.token.cancelled().await;
161 }
162
163 #[must_use]
165 pub fn is_requested(&self) -> bool {
166 self.token.is_cancelled()
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum AdmissionEnd {
173 Calls,
175 Duration,
177 Requested,
179}
180
181#[derive(Debug, Clone)]
183pub struct BoundedOutcome {
184 pub outcome: Outcome,
186 pub peak_in_flight: usize,
188 pub admission_end: AdmissionEnd,
190 pub cleanup_complete: bool,
192}
193
194impl Plan {
195 #[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 #[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#[derive(Debug, Default, Clone)]
223pub struct Outcome {
224 pub attempted: usize,
226 pub succeeded: usize,
228 pub failures: BTreeMap<Cause, usize>,
230 pub setup: Vec<Duration>,
232 pub elapsed: Duration,
234}
235
236impl Outcome {
237 #[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 self.succeeded as f64 / seconds
247 }
248
249 #[must_use]
254 pub fn percentile(&self, fraction: f64) -> Option<Duration> {
255 if self.setup.is_empty() || !fraction.is_finite() {
256 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 #[must_use]
270 pub fn failed(&self) -> usize {
271 self.failures.values().sum()
272 }
273
274 #[must_use]
276 pub fn report(&self) -> String {
277 use std::fmt::Write as _;
278 let mut out = String::new();
279 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 let _ = writeln!(out, " setup {label}: {:.0} ms", at.as_secs_f64() * 1000.0);
293 }
294 }
295 for (cause, count) in &self.failures {
297 let _ = writeln!(out, " {count} × {cause}");
299 }
300 out
301 }
302}
303
304pub 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 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 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#[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 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 let result = place(index, call_stop).await;
517 (result, at.elapsed())
518 });
519 };
520
521 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 #[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 #[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 #[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), },
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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 #[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}