Skip to main content

brk/
cli.rs

1//! Command-line adapter; the library itself installs no process signal handler.
2use 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    /// HTTP URL to test.
20    url: String,
21    /// Fixed number of connections.
22    #[arg(long)]
23    conn: usize,
24    /// Initial concurrent requests per connection; defaults to pace.
25    #[arg(long)]
26    start: Option<usize>,
27    /// Additional concurrent requests per connection in each phase.
28    #[arg(long)]
29    pace: usize,
30    /// Time at each concurrency level, for example 10s.
31    #[arg(long, value_parser = humantime::parse_duration)]
32    phase: Duration,
33    /// Request deadline, including setup for the first request on a connection.
34    #[arg(long, value_parser = humantime::parse_duration)]
35    timeout: Duration,
36    /// Minimum successful 2xx percentage within the request deadline.
37    #[arg(long, alias = "sla", default_value_t = 99.99)]
38    accept: f64,
39    /// Optional per-connection ceiling; reaching it does not find a breakpoint.
40    #[arg(long = "max")]
41    max_concurrency: Option<usize>,
42    /// Additionally require this exact response body, without text decoding.
43    #[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}