1use std::process::ExitCode;
3use std::time::Duration;
4
5use crate::{
6 Acceptance, BreakReason, Broken, CancellationToken, Observer, Phase,
7 PhaseStats, Plan, Stats, Target,
8};
9use clap::Parser;
10
11#[derive(Parser)]
12#[command(
13 name = "brk",
14 version,
15 color = clap::ColorChoice::Never,
16 about = env!("CARGO_PKG_DESCRIPTION")
17)]
18struct Args {
19 url: String,
21 #[arg(long)]
23 conn: usize,
24 #[arg(long)]
26 start: Option<usize>,
27 #[arg(long)]
29 pace: usize,
30 #[arg(long, value_parser = humantime::parse_duration)]
32 phase: Duration,
33 #[arg(long, value_parser = humantime::parse_duration)]
35 timeout: Duration,
36 #[arg(long, alias = "sla", default_value_t = 99.99)]
38 accept: f64,
39 #[arg(long = "max")]
41 max_concurrency: Option<usize>,
42 #[arg(long)]
44 expect_body: Option<String>,
45}
46
47pub fn main() -> ExitCode {
48 let args = Args::parse();
49 match execute(args) {
50 Ok(done) => {
51 print_boundary(&done.stats);
52 if let Some(last) = &done.stats.last_accepted {
53 println!(
54 "Done ({} concurrent requests): declared ceiling completed; breakpoint not found.",
55 last.phase.total_concurrency
56 );
57 }
58 ExitCode::SUCCESS
59 }
60 Err(broken) => {
61 print_boundary(&broken.stats);
62 let label = if matches!(broken.reason, BreakReason::Cancelled) {
63 "Cancelled"
64 } else {
65 "Broken"
66 };
67 if let Some(last) = &broken.stats.last_phase {
68 eprintln!(
69 "{label} ({} concurrent requests): {}",
70 last.phase.total_concurrency, broken.reason
71 );
72 } else {
73 eprintln!("{label}: {}", broken.reason);
74 }
75 if !matches!(broken.reason, BreakReason::Acceptance) {
76 eprintln!("No server capacity claim for this failure.");
77 }
78 if matches!(broken.reason, BreakReason::Cancelled) {
79 ExitCode::from(130)
80 } else {
81 ExitCode::FAILURE
82 }
83 }
84 }
85}
86
87fn print_boundary(stats: &Stats) {
88 if let Some(last) = &stats.last_accepted {
89 println!(
90 "Last accepted: {} requests/connection, {} total concurrent requests",
91 last.phase.requests_per_connection, last.phase.total_concurrency
92 );
93 }
94 if let Some(last) = &stats.last_phase {
95 println!(
96 "Last tested: phase {}, {} requests/connection, {} total concurrent requests",
97 last.phase.index,
98 last.phase.requests_per_connection,
99 last.phase.total_concurrency
100 );
101 }
102}
103
104fn execute(args: Args) -> Result<crate::Done, Broken> {
105 let setup_error = |reason| Broken { reason, stats: Box::default() };
106 let mut target = Target::new(&args.url).map_err(setup_error)?;
107 if let Some(body) = args.expect_body {
108 target = target.expect_body(body.into_bytes());
109 }
110 let plan = Plan {
111 conn: args.conn,
112 start: args.start.unwrap_or(args.pace),
113 pace: args.pace,
114 phase_duration: args.phase,
115 timeout: args.timeout,
116 accept: args.accept,
117 max_concurrency: args.max_concurrency,
118 };
119 let acceptance = plan.validate().map_err(setup_error)?;
120 let stop = CancellationToken::new();
121 let signal = stop.clone();
122 ctrlc::set_handler(move || signal.cancel()).map_err(|_| {
123 setup_error(BreakReason::Worker("could not install Ctrl-C handler"))
124 })?;
125 crate::run(&target, plan, &stop, &mut Printer(acceptance))
126}
127
128struct Printer(Acceptance);
129impl Observer for Printer {
130 fn phase_started(
131 &mut self,
132 phase: &Phase,
133 ) {
134 println!(
135 "Phase {}: {} connections x {} requests = {} concurrent requests | window={}",
136 phase.index,
137 phase.connections,
138 phase.requests_per_connection,
139 phase.total_concurrency,
140 humantime::format_duration(phase.duration)
141 );
142 }
143 fn phase_finished(
144 &mut self,
145 stats: &PhaseStats,
146 ) {
147 let marker = if stats.cancelled {
148 "Cancelled"
149 } else if stats.accepted(self.0) {
150 "Done"
151 } else {
152 "Broken"
153 };
154 if stats.accepted(self.0) {
155 println!(
156 " {marker} ({} concurrent requests)",
157 stats.phase.total_concurrency
158 );
159 } else {
160 eprintln!(
161 " {marker} ({} concurrent requests)",
162 stats.phase.total_concurrency
163 );
164 }
165 println!(
166 " sent={} attempts={} successful={} errors={} successful-requests/s={:.2} elapsed={}",
167 stats.counts.sent,
168 stats.counts.attempted,
169 stats.counts.successful,
170 stats.counts.failed(),
171 stats.requests_per_sec(),
172 humantime::format_duration(stats.elapsed)
173 );
174 println!(
175 " target-established={}/{} timeout={} transport={} status={} body={}",
176 stats.connections_at_target,
177 stats.phase.connections,
178 stats.counts.timeout,
179 stats.counts.transport,
180 stats.counts.status,
181 stats.counts.body
182 );
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 #[test]
190 fn parses_numeric_concurrency_without_rate_units_or_warmup() {
191 let base = [
192 "brk",
193 "http://example.test/plain",
194 "--conn",
195 "4",
196 "--phase",
197 "10s",
198 "--pace",
199 "100",
200 "--timeout",
201 "2s",
202 ];
203 let mut arguments = base.to_vec();
204 arguments.extend(["--start", "500", "--sla", "99.99", "--max", "1000"]);
205 let args = Args::try_parse_from(arguments).unwrap();
206 assert_eq!(args.conn, 4);
207 assert_eq!(args.start, Some(500));
208 assert_eq!(args.pace, 100);
209 assert_eq!(args.max_concurrency, Some(1_000));
210 for unsupported in ["--warmup", "--tls", "--proto", "-t", "--max-rate"]
211 {
212 let mut arguments = base.to_vec();
213 arguments.extend([unsupported, "1"]);
214 assert!(Args::try_parse_from(arguments).is_err());
215 }
216 assert!(Args::try_parse_from(["brk", "http://example.test"]).is_err());
217 }
218}