Skip to main content

mockforge_bench/
chunked_command.rs

1//! `mockforge bench-chunked` driver: single-target, spec-driven, and
2//! multi-target (`--targets-file`) chunked-encoding runs.
3//!
4//! [`crate::chunked_bench::run`] drives one URL. This module layers the CLI
5//! modes on top of it:
6//!
7//! - **Single target** (`--target URL`, no `--spec`): one run against the URL.
8//! - **Spec-driven** (`--spec`): one run per POST/PUT/PATCH operation, with
9//!   `--target` as the base URL.
10//! - **Multi-target** (`--targets-file`, issue #79): every target in the file
11//!   runs the single-target or spec-driven flow in parallel (bounded by
12//!   `--max-concurrency`). It uses the same file format as
13//!   `mockforge bench --targets-file`, including per-target `auth`, `headers`
14//!   and `spec`. Per-target artifacts go to `<output>/target_<N>/`, and a
15//!   roll-up goes to `<output>/chunked-multi-target-summary.json`.
16//!
17//! Any mode can loop as a campaign (`--rounds` / `--repeat-until`, issue
18//! #79): each pass writes to `<output>/round_<N>/`, per-round stats go to
19//! `<output>/campaign.jsonl` + `<output>/round-summaries/`, and
20//! `--keep-rounds` prunes old `round_*` dirs, matching `mockforge bench`.
21
22use crate::chunked_bench::{
23    build_json_body, is_json_content_type, per_second, run, ChunkedBenchConfig, ChunkedBenchResult,
24};
25use crate::parallel_executor::ParallelExecutor;
26use crate::request_gen::RequestGenerator;
27use crate::spec_parser::SpecParser;
28use crate::target_parser::{parse_targets_file, TargetConfig};
29use anyhow::{bail, Context};
30use futures::StreamExt;
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36/// Options shared by every `bench-chunked` mode.
37#[derive(Debug, Clone)]
38pub struct ChunkedCommand {
39    /// Single target URL. Mutually exclusive with `targets_file`.
40    pub target: Option<String>,
41    /// File with multiple targets (same format as `bench --targets-file`).
42    pub targets_file: Option<PathBuf>,
43    /// Max targets running at once in multi-target mode.
44    pub max_concurrency: u32,
45    pub spec: Option<PathBuf>,
46    pub base_path: Option<String>,
47    pub operation_id: Option<String>,
48    pub method: String,
49    pub concurrency: u32,
50    pub duration: Duration,
51    pub chunk_size_bytes: usize,
52    pub total_size_bytes: usize,
53    /// Send raw `X` filler regardless of the declared Content-Type.
54    pub raw_body: bool,
55    pub chunk_interval_ms: u64,
56    pub headers: HashMap<String, String>,
57    pub insecure: bool,
58    pub validate_requests: bool,
59    pub export_requests: bool,
60    pub output: PathBuf,
61    /// Per-target cap on request starts per second (shared by its workers).
62    pub rps: Option<u32>,
63    /// New TCP/TLS connection per request, so connections/s = requests/s.
64    pub cps: bool,
65    /// Campaign: run the whole plan this many times.
66    pub rounds: Option<u32>,
67    /// Campaign: keep starting new rounds until this much wall clock elapsed.
68    pub repeat_until: Option<Duration>,
69    /// Campaign: keep only the newest N `round_*` dirs.
70    pub keep_rounds: Option<u32>,
71    /// Per-request timeout. `None` derives one from the body size and chunk
72    /// pacing ([`ChunkedBenchConfig::auto_request_timeout`]); zero disables it.
73    pub request_timeout: Option<Duration>,
74    /// Spec mode: start each operation as soon as the previous one's load
75    /// window closes, instead of after its in-flight uploads finish, so the
76    /// start rate stays continuous across operations.
77    pub overlap_operations: bool,
78}
79
80/// One target's resolved run plan.
81#[derive(Debug, Clone)]
82struct TargetRun {
83    /// `""` in single-target mode, `"[target_N] "` in multi-target mode.
84    prefix: String,
85    url: String,
86    headers: HashMap<String, String>,
87    spec: Option<PathBuf>,
88    output: PathBuf,
89}
90
91/// Roll-up of one target's run(s).
92#[derive(Debug, Clone, Default)]
93struct TargetOutcome {
94    total_requests: u64,
95    successful: u64,
96    failed: u64,
97    bytes_sent: u64,
98    elapsed: Duration,
99    /// Sum of each operation's request-start window (`--duration`).
100    load_window: Duration,
101    /// Failed attempts that hit the request timeout.
102    timed_out: u64,
103    status_counts: HashMap<u16, u64>,
104    /// An operation errored out entirely (vs. individual requests failing).
105    run_failed: bool,
106}
107
108impl TargetOutcome {
109    /// Report fields derived from the counters, shared by the single- and
110    /// multi-target summaries.
111    fn rate_fields(&self) -> serde_json::Value {
112        serde_json::json!({
113            "elapsed_seconds": self.elapsed.as_secs_f64(),
114            "measured_rps": per_second(self.total_requests as f64, self.elapsed),
115            "load_window_seconds": self.load_window.as_secs_f64(),
116            "start_window_rps": per_second(self.total_requests as f64, self.load_window),
117            "upload_mbps": per_second(self.bytes_sent as f64 * 8.0, self.elapsed) / 1_000_000.0,
118            "timed_out": self.timed_out,
119        })
120    }
121
122    fn absorb(&mut self, r: &ChunkedBenchResult) {
123        self.total_requests += r.total_requests;
124        self.successful += r.successful;
125        self.failed += r.failed;
126        self.bytes_sent += r.bytes_sent;
127        self.elapsed += r.elapsed;
128        self.load_window += r.load_window;
129        self.timed_out += r.timed_out;
130        for (code, n) in &r.status_counts {
131            *self.status_counts.entry(*code).or_insert(0) += n;
132        }
133    }
134}
135
136impl ChunkedCommand {
137    /// Run the bench. Returns `Ok(true)` when every run completed, `Ok(false)`
138    /// when at least one operation or target failed to run.
139    pub async fn execute(&self) -> anyhow::Result<bool> {
140        match (&self.target, &self.targets_file) {
141            (Some(_), Some(_)) => bail!("--target and --targets-file are mutually exclusive"),
142            (None, None) => bail!("either --target or --targets-file is required"),
143            _ => {}
144        }
145        if self.rounds == Some(0) {
146            bail!("--rounds must be >= 1 (or omit it for a single pass / --repeat-until)");
147        }
148        println!(
149            "Request-start limit per target: {}",
150            self.rps
151                .map(|n| format!("{n} req/s"))
152                .unwrap_or_else(|| "none (--rps not set)".into())
153        );
154        println!(
155            "Connections: {}",
156            if self.cps {
157                "new TCP/TLS connection per request (--cps)"
158            } else {
159                "pooled (--cps not set)"
160            }
161        );
162        if self.raw_body {
163            println!("Request bodies: raw X filler; declared Content-Type preserved (--raw-body)");
164        }
165        let max_rounds = self.rounds.unwrap_or(if self.repeat_until.is_some() {
166            u32::MAX
167        } else {
168            1
169        });
170        let looping = max_rounds > 1 || self.repeat_until.is_some();
171        if !looping {
172            if self.keep_rounds.is_some() {
173                eprintln!(
174                    "Note: --keep-rounds only applies to campaign runs (--repeat-until / --rounds > 1); ignoring"
175                );
176            }
177            return Ok(self.execute_pass(&self.output).await?.0);
178        }
179
180        match self.repeat_until {
181            Some(until) => println!(
182                "Campaign: re-running every pass until {}s of wall clock (or --rounds {})",
183                until.as_secs(),
184                self.rounds.map(|n| n.to_string()).unwrap_or_else(|| "unlimited".into())
185            ),
186            None => println!("Campaign: {max_rounds} round(s)"),
187        }
188        if let Some(keep) = self.keep_rounds {
189            println!(
190                "Round pruning: keeping newest {keep} round_* dir(s); per-round stats persist in campaign.jsonl + round-summaries/"
191            );
192        }
193
194        let campaign_start = std::time::Instant::now();
195        let mut all_ok = true;
196        let mut round: u32 = 0;
197        while round < max_rounds {
198            if let Some(until) = self.repeat_until {
199                if round > 0 && campaign_start.elapsed() >= until {
200                    println!("Reached --repeat-until; stopping after {round} round(s)");
201                    break;
202                }
203            }
204            round += 1;
205            let round_output = self.output.join(format!("round_{round}"));
206            println!(
207                "\n=== Round {round} (campaign elapsed {}s) → {} ===",
208                campaign_start.elapsed().as_secs(),
209                round_output.display()
210            );
211            let round_start = std::time::Instant::now();
212            let (ok, summary) = self.execute_pass(&round_output).await?;
213            all_ok &= ok;
214            write_round_record(
215                &self.output,
216                round,
217                campaign_start.elapsed(),
218                round_start.elapsed(),
219                ok,
220                summary,
221            )?;
222            if let Some(keep) = self.keep_rounds {
223                ParallelExecutor::prune_old_rounds(&self.output, keep);
224            }
225        }
226        Ok(all_ok)
227    }
228
229    /// One pass over the whole plan, writing artifacts under `output`.
230    /// Returns whether every run completed plus a JSON roll-up of the pass.
231    async fn execute_pass(&self, output: &Path) -> anyhow::Result<(bool, serde_json::Value)> {
232        match (&self.target, &self.targets_file) {
233            (Some(target), _) => {
234                let run = TargetRun {
235                    prefix: String::new(),
236                    url: target.clone(),
237                    headers: self.headers.clone(),
238                    spec: self.spec.clone(),
239                    output: output.to_path_buf(),
240                };
241                let o = self.run_target(&run).await?;
242                let summary = serde_json::json!({
243                    "url": target,
244                    "total_requests": o.total_requests,
245                    "successful": o.successful,
246                    "failed": o.failed,
247                    "bytes_sent": o.bytes_sent,
248                    "status_counts": o.status_counts,
249                    "run_failed": o.run_failed,
250                    "rps_per_target": self.rps,
251                    "rate_limit_enabled": self.rps.is_some(),
252                    "new_connection_per_request": self.cps,
253                    "raw_body": self.raw_body,
254                    "overlap_operations": self.overlap_operations,
255                });
256                let summary = merge_json(summary, self.request_timeout_fields());
257                Ok((!o.run_failed, merge_json(summary, o.rate_fields())))
258            }
259            (None, Some(file)) => self.execute_multi_target(file, output).await,
260            (None, None) => bail!("either --target or --targets-file is required"),
261        }
262    }
263
264    async fn execute_multi_target(
265        &self,
266        file: &Path,
267        output: &Path,
268    ) -> anyhow::Result<(bool, serde_json::Value)> {
269        let targets = parse_targets_file(file)
270            .map_err(|e| anyhow::anyhow!("{e}"))
271            .with_context(|| format!("failed to read targets file {}", file.display()))?;
272        if targets.is_empty() {
273            bail!("no targets found in {}", file.display());
274        }
275        let runs = plan_target_runs(targets, &self.headers, self.spec.as_ref(), output);
276        let max_concurrency = self.max_concurrency.max(1) as usize;
277        println!(
278            "→ Chunked bench across {} targets from {:?} (max {} in parallel)",
279            runs.len(),
280            file,
281            max_concurrency
282        );
283
284        let mut outcomes: Vec<(usize, TargetRun, anyhow::Result<TargetOutcome>)> =
285            futures::stream::iter(runs.into_iter().enumerate())
286                .map(|(i, run)| async move {
287                    let res = self.run_target(&run).await;
288                    (i, run, res)
289                })
290                .buffer_unordered(max_concurrency)
291                .collect()
292                .await;
293        outcomes.sort_by_key(|(i, _, _)| *i);
294
295        println!();
296        println!("Multi-target chunked summary:");
297        let mut all_ok = true;
298        let mut rows = Vec::with_capacity(outcomes.len());
299        for (i, run, res) in &outcomes {
300            let name = format!("target_{}", i + 1);
301            match res {
302                Ok(o) => {
303                    if o.run_failed {
304                        all_ok = false;
305                    }
306                    println!(
307                        "  {:<10} {}  requests={} ok={} failed={} bytes={} start_rps={:.2} rps={:.2}{}",
308                        name,
309                        run.url,
310                        o.total_requests,
311                        o.successful,
312                        o.failed,
313                        o.bytes_sent,
314                        per_second(o.total_requests as f64, o.load_window),
315                        per_second(o.total_requests as f64, o.elapsed),
316                        if o.run_failed {
317                            "  (some operations failed to run)"
318                        } else {
319                            ""
320                        }
321                    );
322                    rows.push(merge_json(
323                        serde_json::json!({
324                            "target": name,
325                            "url": run.url,
326                            "spec": run.spec.as_ref().map(|p| p.display().to_string()),
327                            // Per-target files (exports / violations) exist only in spec mode.
328                            "output_dir": run.spec.as_ref().map(|_| run.output.display().to_string()),
329                            "total_requests": o.total_requests,
330                            "successful": o.successful,
331                            "failed": o.failed,
332                            "bytes_sent": o.bytes_sent,
333                            "status_counts": o.status_counts,
334                            "run_failed": o.run_failed,
335                        }),
336                        o.rate_fields(),
337                    ));
338                }
339                Err(e) => {
340                    all_ok = false;
341                    println!("  {:<10} {}  ERROR: {:#}", name, run.url, e);
342                    rows.push(serde_json::json!({
343                        "target": name,
344                        "url": run.url,
345                        "error": format!("{e:#}"),
346                    }));
347                }
348            }
349        }
350
351        std::fs::create_dir_all(output)
352            .with_context(|| format!("failed to create output directory {:?}", output))?;
353        let path = output.join("chunked-multi-target-summary.json");
354        let payload = serde_json::json!({
355            "targets_file": file.display().to_string(),
356            "concurrency_per_target": self.concurrency,
357            "max_concurrency": max_concurrency,
358            "duration_secs": self.duration.as_secs(),
359            "chunk_size_bytes": self.chunk_size_bytes,
360            "total_size_bytes": self.total_size_bytes,
361            "chunk_interval_ms": self.chunk_interval_ms,
362            "rps_per_target": self.rps,
363            "rate_limit_enabled": self.rps.is_some(),
364            "new_connection_per_request": self.cps,
365            "raw_body": self.raw_body,
366            "overlap_operations": self.overlap_operations,
367            "targets": rows,
368        });
369        let payload = merge_json(payload, self.request_timeout_fields());
370        std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
371            .with_context(|| format!("failed to write {:?}", path))?;
372        println!("📝 Wrote {:?}", path);
373        Ok((all_ok, payload))
374    }
375
376    fn bench_config(
377        &self,
378        url: String,
379        method: reqwest::Method,
380        headers: &HashMap<String, String>,
381        json_seed: Option<&serde_json::Value>,
382    ) -> ChunkedBenchConfig {
383        // A JSON Content-Type gets a real JSON document padded to
384        // --total-size-bytes; WAFs reject `X` filler as malformed JSON (#79).
385        let body = (!self.raw_body && is_json_content_type(headers))
386            .then(|| Arc::new(build_json_body(json_seed, self.total_size_bytes)));
387        if let Some(b) = &body {
388            if b.len() > self.total_size_bytes {
389                eprintln!(
390                    "Note: --total-size-bytes {} is below the smallest valid JSON body; sending {} bytes",
391                    self.total_size_bytes,
392                    b.len()
393                );
394            }
395        }
396        let mut cfg = ChunkedBenchConfig {
397            target_url: url,
398            method,
399            concurrency: self.concurrency,
400            duration: self.duration,
401            chunk_size_bytes: self.chunk_size_bytes,
402            total_size_bytes: self.total_size_bytes,
403            chunk_interval_ms: self.chunk_interval_ms,
404            headers: headers.clone(),
405            skip_tls_verify: self.insecure,
406            rps: self.rps,
407            no_keep_alive: self.cps,
408            body,
409            request_timeout: None,
410        };
411        cfg.request_timeout = match self.request_timeout {
412            None => Some(cfg.auto_request_timeout()),
413            Some(t) if t.is_zero() => None,
414            Some(t) => Some(t),
415        };
416        cfg
417    }
418
419    /// How the summary describes `--request-timeout`.
420    fn request_timeout_fields(&self) -> serde_json::Value {
421        let (mode, secs) = match self.request_timeout {
422            None => ("auto", None),
423            Some(t) if t.is_zero() => ("off", None),
424            Some(t) => ("fixed", Some(t.as_secs_f64())),
425        };
426        serde_json::json!({ "request_timeout_mode": mode, "request_timeout_secs": secs })
427    }
428
429    async fn run_target(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
430        match &t.spec {
431            Some(spec) => self.run_spec(t, spec).await,
432            None => self.run_single(t).await,
433        }
434    }
435
436    async fn run_single(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
437        let p = &t.prefix;
438        // Only warn once: in multi-target mode every target would repeat it.
439        if p.is_empty() {
440            if self.base_path.is_some() {
441                eprintln!(
442                    "Note: --base-path has no effect without --spec; \
443                     single-target mode uses --target verbatim"
444                );
445            }
446            if self.validate_requests {
447                eprintln!("Note: --validate-requests requires --spec (skipped)");
448            }
449            if self.export_requests {
450                eprintln!("Note: --export-requests requires --spec (skipped)");
451            }
452            if self.overlap_operations {
453                eprintln!("Note: --overlap-operations requires --spec (single operation)");
454            }
455        }
456        let method = reqwest::Method::from_bytes(self.method.to_uppercase().as_bytes())
457            .map_err(|_| anyhow::anyhow!("Invalid HTTP method: {}", self.method))?;
458        if !p.is_empty() {
459            println!("{p}→ {} {}", self.method.to_uppercase(), t.url);
460        }
461        let r = run(self.bench_config(t.url.clone(), method, &t.headers, None))
462            .await
463            .context("chunked bench failed")?;
464        print_result(
465            p,
466            if p.is_empty() {
467                "single-target"
468            } else {
469                &t.url
470            },
471            &r,
472        );
473        let mut outcome = TargetOutcome::default();
474        outcome.absorb(&r);
475        Ok(outcome)
476    }
477
478    async fn run_spec(&self, t: &TargetRun, spec_path: &Path) -> anyhow::Result<TargetOutcome> {
479        let p = &t.prefix;
480        let parser = SpecParser::from_file(spec_path)
481            .await
482            .map_err(|e| anyhow::anyhow!("Failed to parse spec {:?}: {}", spec_path, e))?;
483        let base_url = t.url.trim_end_matches('/').to_string();
484        // CLI --base-path > spec.servers > none. Empty string from CLI
485        // explicitly disables (matches `mockforge bench` semantics).
486        let effective_base_path: Option<String> = match &self.base_path {
487            Some(bp) if bp.is_empty() => None,
488            Some(bp) => Some(bp.clone()),
489            None => parser.get_base_path(),
490        };
491        let want_methods = ["POST", "PUT", "PATCH"];
492        let ops: Vec<_> = parser
493            .get_operations()
494            .into_iter()
495            .filter(|op| want_methods.contains(&op.method.to_uppercase().as_str()))
496            .filter(|op| match &self.operation_id {
497                Some(id) => op.operation_id.as_deref() == Some(id.as_str()),
498                None => true,
499            })
500            .collect();
501        if ops.is_empty() {
502            bail!(
503                "No POST/PUT/PATCH operations matched in {:?}{}",
504                spec_path,
505                self.operation_id
506                    .as_deref()
507                    .map(|id| format!(" (operation-id={id})"))
508                    .unwrap_or_default()
509            );
510        }
511
512        // Pre-flight validation. Any failure here aborts before touching
513        // the network so the bench doesn't half-run.
514        let mut violations: Vec<serde_json::Value> = Vec::new();
515        // (label, method, url, op.path, generated request body)
516        let mut planned: Vec<(String, String, String, String, Option<serde_json::Value>)> =
517            Vec::with_capacity(ops.len());
518        for op in &ops {
519            let label = op.display_name();
520            match RequestGenerator::generate_template(op) {
521                Ok(template) => {
522                    let path_with_base = match &effective_base_path {
523                        Some(bp) if !bp.is_empty() => {
524                            format!("{}{}", bp, template.generate_path())
525                        }
526                        _ => template.generate_path().to_string(),
527                    };
528                    let url = format!("{}{}", base_url, path_with_base);
529                    if reqwest::Method::from_bytes(op.method.to_uppercase().as_bytes()).is_err() {
530                        violations.push(serde_json::json!({
531                            "operation": label,
532                            "method": op.method,
533                            "path": op.path,
534                            "kind": "invalid_method",
535                            "detail": format!("`{}` is not a valid HTTP method", op.method),
536                        }));
537                    } else {
538                        planned.push((
539                            label,
540                            op.method.to_uppercase(),
541                            url,
542                            op.path.clone(),
543                            template.body.clone(),
544                        ));
545                    }
546                }
547                Err(e) => {
548                    violations.push(serde_json::json!({
549                        "operation": label,
550                        "method": op.method,
551                        "path": op.path,
552                        "kind": "template_build_failure",
553                        "detail": e.to_string(),
554                    }));
555                }
556            }
557        }
558
559        if self.validate_requests {
560            if !violations.is_empty() {
561                std::fs::create_dir_all(&t.output)
562                    .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
563                let path = t.output.join("chunked-request-violations.json");
564                let payload = serde_json::json!({
565                    "spec": spec_path.display().to_string(),
566                    "base_url": base_url,
567                    "base_path": effective_base_path,
568                    "violations": violations,
569                });
570                std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
571                    .with_context(|| format!("Failed to write {:?}", path))?;
572                bail!("--validate-requests: {} violation(s); see {:?}", violations.len(), path);
573            }
574            println!("{p}✅ --validate-requests: all {} operations OK", planned.len());
575        }
576
577        println!(
578            "{p}→ {} chunked bench {} from {:?} (base {}{})",
579            planned.len(),
580            if planned.len() == 1 {
581                "operation"
582            } else {
583                "operations"
584            },
585            spec_path,
586            base_url,
587            effective_base_path
588                .as_deref()
589                .map(|bp| format!(", base-path {}", bp))
590                .unwrap_or_default()
591        );
592
593        let mut outcome = TargetOutcome::default();
594        let mut export_records: Vec<serde_json::Value> = Vec::new();
595        // Sequential: each operation starts after the previous one's in-flight
596        // uploads finish. Overlapped: operation k starts k load windows in, as
597        // the previous window closes, so starts never pause between operations.
598        let pass_started = tokio::time::Instant::now();
599        let in_parallel = if self.overlap_operations {
600            planned.len().max(1)
601        } else {
602            1
603        };
604        let mut runs = futures::stream::iter(planned.iter().enumerate())
605            .map(|(i, (label, method_str, url, op_path, json_seed))| async move {
606                if self.overlap_operations {
607                    tokio::time::sleep_until(pass_started + self.duration * i as u32).await;
608                }
609                let result = match reqwest::Method::from_bytes(method_str.as_bytes()) {
610                    Ok(method) => {
611                        println!();
612                        println!("{p}→ {} {}  ({})", method_str, op_path, url);
613                        let cfg =
614                            self.bench_config(url.clone(), method, &t.headers, json_seed.as_ref());
615                        Some(run(cfg).await)
616                    }
617                    Err(_) => None,
618                };
619                (label, method_str, url, op_path, result)
620            })
621            .buffered(in_parallel);
622        while let Some((label, method_str, url, op_path, result)) = runs.next().await {
623            let Some(result) = result else {
624                eprintln!("{p}✗ {}: invalid method `{}`", label, method_str);
625                outcome.run_failed = true;
626                continue;
627            };
628            match result {
629                Ok(r) => {
630                    print_result(p, label, &r);
631                    outcome.absorb(&r);
632                    if self.export_requests {
633                        export_records.push(export_record(
634                            self, label, method_str, url, op_path, &t.headers, &r,
635                        ));
636                    }
637                }
638                Err(e) => {
639                    eprintln!("{p}✗ {}: chunked bench failed: {}", label, e);
640                    outcome.run_failed = true;
641                }
642            }
643        }
644        drop(runs);
645        if self.overlap_operations {
646            // Operations ran concurrently, so summing their runtimes would
647            // double count; use the pass's wall clock instead.
648            outcome.elapsed = pass_started.elapsed();
649        }
650
651        if self.export_requests {
652            std::fs::create_dir_all(&t.output)
653                .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
654            let path = t.output.join("chunked-requests.json");
655            let payload = serde_json::json!({
656                "spec": spec_path.display().to_string(),
657                "base_url": base_url,
658                "base_path": effective_base_path,
659                "operations": export_records,
660            });
661            std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
662                .with_context(|| format!("Failed to write {:?}", path))?;
663            println!(
664                "{p}📝 --export-requests: wrote {} operations to {:?}",
665                export_records.len(),
666                path
667            );
668        }
669
670        Ok(outcome)
671    }
672}
673
674/// Resolve each targets-file entry into a run plan: normalized URL, CLI
675/// headers overlaid with the target's `headers` and `auth`, the target's
676/// `spec` falling back to the CLI `--spec`, and `<output>/target_<N>/`.
677fn plan_target_runs(
678    targets: Vec<TargetConfig>,
679    cli_headers: &HashMap<String, String>,
680    cli_spec: Option<&PathBuf>,
681    output: &Path,
682) -> Vec<TargetRun> {
683    targets
684        .into_iter()
685        .enumerate()
686        .map(|(i, mut t)| {
687            t.normalize_url();
688            let mut headers = cli_headers.clone();
689            if let Some(h) = &t.headers {
690                headers.extend(h.iter().map(|(k, v)| (k.clone(), v.clone())));
691            }
692            if let Some(auth) = &t.auth {
693                headers.insert("Authorization".to_string(), auth.clone());
694            }
695            let name = format!("target_{}", i + 1);
696            TargetRun {
697                prefix: format!("[{name}] "),
698                url: t.url,
699                headers,
700                spec: t.spec.or_else(|| cli_spec.cloned()),
701                output: output.join(name),
702            }
703        })
704        .collect()
705}
706
707/// Campaign bookkeeping for one finished round: a pruning-proof copy under
708/// `<output>/round-summaries/` and one line appended to
709/// `<output>/campaign.jsonl`.
710fn write_round_record(
711    output: &Path,
712    round: u32,
713    campaign_elapsed: Duration,
714    round_elapsed: Duration,
715    ok: bool,
716    pass: serde_json::Value,
717) -> anyhow::Result<()> {
718    use std::io::Write;
719    let record = serde_json::json!({
720        "round": round,
721        "campaign_elapsed_seconds": campaign_elapsed.as_secs(),
722        "round_elapsed_seconds": round_elapsed.as_secs_f64(),
723        "all_runs_completed": ok,
724        "pass": pass,
725    });
726    let summaries = output.join("round-summaries");
727    std::fs::create_dir_all(&summaries)
728        .with_context(|| format!("failed to create {:?}", summaries))?;
729    std::fs::write(
730        summaries.join(format!("round_{round}.json")),
731        serde_json::to_string_pretty(&record)?,
732    )?;
733    let mut line = serde_json::to_string(&record)?;
734    line.push('\n');
735    std::fs::OpenOptions::new()
736        .create(true)
737        .append(true)
738        .open(output.join("campaign.jsonl"))?
739        .write_all(line.as_bytes())
740        .context("failed to append campaign.jsonl")?;
741    Ok(())
742}
743
744fn export_record(
745    cmd: &ChunkedCommand,
746    label: &str,
747    method: &str,
748    url: &str,
749    op_path: &str,
750    headers: &HashMap<String, String>,
751    r: &ChunkedBenchResult,
752) -> serde_json::Value {
753    serde_json::json!({
754        "operation": label,
755        "method": method,
756        "url": url,
757        "spec_path": op_path,
758        "headers": headers,
759        "chunk_size_bytes": cmd.chunk_size_bytes,
760        "total_size_bytes": cmd.total_size_bytes,
761        "chunk_interval_ms": cmd.chunk_interval_ms,
762        "concurrency": cmd.concurrency,
763        "rps": cmd.rps,
764        "rate_limit_enabled": cmd.rps.is_some(),
765        "new_connection_per_request": cmd.cps,
766        "raw_body": cmd.raw_body,
767        "duration_secs": cmd.duration.as_secs(),
768        "result": {
769            "total_requests": r.total_requests,
770            "successful": r.successful,
771            "failed": r.failed,
772            "bytes_sent": r.bytes_sent,
773            "elapsed_ms": r.elapsed.as_millis(),
774            "req_per_sec": r.req_per_sec,
775            "load_window_ms": r.load_window.as_millis(),
776            "start_rate_per_sec": r.start_rate_per_sec,
777            "upload_mbps": r.upload_mbps,
778            "timed_out": r.timed_out,
779            "request_timeout_ms": r.request_timeout.map(|t| t.as_millis()),
780            "avg_latency_ms": r.avg_latency_ms,
781            "p50_ms": r.p50_ms,
782            "p95_ms": r.p95_ms,
783            "p99_ms": r.p99_ms,
784            "status_counts": r.status_counts,
785            "error_samples": r.error_samples.iter().map(|s| serde_json::json!({
786                "status": s.status,
787                "server_header": s.server_header,
788                "body_excerpt": s.body_excerpt,
789            })).collect::<Vec<_>>(),
790        },
791    })
792}
793
794fn merge_json(mut base: serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
795    if let (Some(base), serde_json::Value::Object(extra)) = (base.as_object_mut(), extra) {
796        base.extend(extra);
797    }
798    base
799}
800
801/// Print one result block. The block is built first and printed with one
802/// call so parallel targets don't interleave their lines.
803fn print_result(prefix: &str, label: &str, r: &ChunkedBenchResult) {
804    let mut out = String::new();
805    let mut line = |s: String| {
806        out.push_str(prefix);
807        out.push_str(&s);
808        out.push('\n');
809    };
810    line(format!("Chunked bench [{}] complete in {:?}", label, r.elapsed));
811    line(format!("  Total requests: {}", r.total_requests));
812    line(format!("  Successful:     {}", r.successful));
813    match r.request_timeout {
814        Some(t) if r.timed_out > 0 => line(format!(
815            "  Failed:         {} ({} timed out after {:.1}s)",
816            r.failed,
817            r.timed_out,
818            t.as_secs_f64()
819        )),
820        _ => line(format!("  Failed:         {}", r.failed)),
821    }
822    line(format!("  Bytes sent:     {}", r.bytes_sent));
823    line(format!(
824        "  RPS (started):  {:.2} req/s over the {:.1}s load window",
825        r.start_rate_per_sec,
826        r.load_window.as_secs_f64()
827    ));
828    line(format!(
829        "  RPS (overall):  {:.2} req/s over {:.1}s, incl. in-flight drain",
830        r.req_per_sec,
831        r.elapsed.as_secs_f64()
832    ));
833    line(format!("  Upload:         {:.2} Mbps", r.upload_mbps));
834    line(format!(
835        "  Latency:        avg={:.1}ms p50={}ms p95={}ms p99={}ms",
836        r.avg_latency_ms, r.p50_ms, r.p95_ms, r.p99_ms
837    ));
838    if !r.status_counts.is_empty() {
839        line("  Status codes:".to_string());
840        let mut codes: Vec<_> = r.status_counts.iter().collect();
841        codes.sort_by_key(|(k, _)| **k);
842        for (code, n) in codes {
843            line(format!("    {} = {}", code, n));
844        }
845    }
846    // Surface the captured error responses so the user can tell whether
847    // errors came from MockForge, an upstream proxy, a CDN, etc. Hint at the
848    // most common cause when 5xx is involved (proxy upstream timeout on long
849    // chunked uploads).
850    if !r.error_samples.is_empty() {
851        line("  Error response samples:".to_string());
852        for s in &r.error_samples {
853            let server = s.server_header.as_deref().unwrap_or("(no Server header)");
854            line(format!("    [{}] Server: {}", s.status, server));
855            if !s.body_excerpt.is_empty() {
856                line(format!("       body: {}", s.body_excerpt.replace('\n', " ")));
857            }
858        }
859        if r.status_counts.keys().any(|c| (500..600).contains(c)) {
860            line(
861                "  Hint: 5xx responses with `Server:` revealing a proxy/LB usually \
862                 mean the proxy timed out reading from upstream. Each chunked request \
863                 takes >= (total_size_bytes / chunk_size_bytes) * chunk_interval_ms; \
864                 if that exceeds the proxy's upstream timeout, errors are inevitable."
865                    .to_string(),
866            );
867        }
868    }
869    print!("{out}");
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    #[test]
877    fn plan_target_runs_merges_headers_auth_spec_and_output() {
878        let mut target_headers = HashMap::new();
879        target_headers.insert("X-Env".to_string(), "b".to_string());
880        let targets = vec![
881            TargetConfig::from_url("10.0.0.1:8080".to_string()),
882            TargetConfig {
883                url: "https://b.example".to_string(),
884                auth: Some("Bearer t".to_string()),
885                headers: Some(target_headers),
886                spec: Some(PathBuf::from("b.yaml")),
887            },
888        ];
889        let mut cli_headers = HashMap::new();
890        cli_headers.insert("X-Env".to_string(), "cli".to_string());
891        let cli_spec = PathBuf::from("cli.yaml");
892
893        let runs = plan_target_runs(targets, &cli_headers, Some(&cli_spec), Path::new("out"));
894
895        assert_eq!(runs.len(), 2);
896        assert_eq!(runs[0].url, "http://10.0.0.1:8080");
897        assert_eq!(runs[0].headers.get("X-Env").map(String::as_str), Some("cli"));
898        assert_eq!(runs[0].spec.as_deref(), Some(Path::new("cli.yaml")));
899        assert_eq!(runs[0].output, Path::new("out").join("target_1"));
900        assert_eq!(runs[0].prefix, "[target_1] ");
901
902        assert_eq!(runs[1].headers.get("X-Env").map(String::as_str), Some("b"));
903        assert_eq!(runs[1].headers.get("Authorization").map(String::as_str), Some("Bearer t"));
904        assert_eq!(runs[1].spec.as_deref(), Some(Path::new("b.yaml")));
905        assert_eq!(runs[1].output, Path::new("out").join("target_2"));
906    }
907
908    fn cmd(target: Option<&str>, targets_file: Option<PathBuf>) -> ChunkedCommand {
909        ChunkedCommand {
910            target: target.map(str::to_string),
911            targets_file,
912            max_concurrency: 10,
913            spec: None,
914            base_path: None,
915            operation_id: None,
916            method: "POST".to_string(),
917            concurrency: 1,
918            duration: Duration::from_millis(10),
919            chunk_size_bytes: 1024,
920            total_size_bytes: 4096,
921            raw_body: false,
922            chunk_interval_ms: 0,
923            headers: HashMap::new(),
924            insecure: false,
925            validate_requests: false,
926            export_requests: false,
927            output: PathBuf::from("bench-results"),
928            rps: None,
929            cps: false,
930            rounds: None,
931            repeat_until: None,
932            keep_rounds: None,
933            request_timeout: None,
934            overlap_operations: false,
935        }
936    }
937
938    #[tokio::test]
939    async fn rejects_target_and_targets_file_together() {
940        let c = cmd(Some("http://127.0.0.1:1"), Some(PathBuf::from("t.txt")));
941        assert!(c.execute().await.is_err());
942    }
943
944    #[tokio::test]
945    async fn rejects_neither_target_nor_targets_file() {
946        assert!(cmd(None, None).execute().await.is_err());
947    }
948
949    fn scratch_dir(name: &str) -> PathBuf {
950        let dir = std::env::temp_dir().join(format!(
951            "mf-chunked-{name}-{}-{}",
952            std::process::id(),
953            line!()
954        ));
955        let _ = std::fs::remove_dir_all(&dir);
956        std::fs::create_dir_all(&dir).unwrap();
957        dir
958    }
959
960    #[tokio::test]
961    async fn campaign_rounds_write_records_and_prune_round_dirs() {
962        let mut server = mockito::Server::new_async().await;
963        let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
964        let dir = scratch_dir("campaign");
965        let targets = dir.join("targets.txt");
966        std::fs::write(&targets, format!("{}/upload\n{}/upload\n", server.url(), server.url()))
967            .unwrap();
968        let mut c = cmd(None, Some(targets));
969        c.output = dir.join("out");
970        c.duration = Duration::from_millis(50);
971        c.rounds = Some(3);
972        c.keep_rounds = Some(1);
973
974        assert!(c.execute().await.unwrap());
975
976        let jsonl = std::fs::read_to_string(c.output.join("campaign.jsonl")).unwrap();
977        let rounds: Vec<serde_json::Value> =
978            jsonl.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
979        assert_eq!(rounds.len(), 3);
980        assert_eq!(rounds[2]["round"], 3);
981        assert_eq!(rounds[2]["pass"]["targets"].as_array().unwrap().len(), 2);
982        for n in 1..=3 {
983            assert!(c.output.join(format!("round-summaries/round_{n}.json")).exists());
984        }
985        assert!(!c.output.join("round_1").exists());
986        assert!(!c.output.join("round_2").exists());
987        assert!(c.output.join("round_3/chunked-multi-target-summary.json").exists());
988        let _ = std::fs::remove_dir_all(&dir);
989    }
990
991    #[tokio::test]
992    async fn campaign_repeat_until_stops_on_wall_clock() {
993        let mut server = mockito::Server::new_async().await;
994        let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
995        let dir = scratch_dir("until");
996        let mut c = cmd(Some(&format!("{}/upload", server.url())), None);
997        c.output = dir.clone();
998        // Short rounds against a roomy budget: a loaded CI runner can stretch
999        // one round's setup well past its nominal duration, so assert only that
1000        // the campaign repeated and then stopped, not an exact count.
1001        c.duration = Duration::from_millis(50);
1002        c.repeat_until = Some(Duration::from_millis(500));
1003
1004        assert!(c.execute().await.unwrap());
1005
1006        let n = std::fs::read_to_string(dir.join("campaign.jsonl")).unwrap().lines().count();
1007        assert!((2..=11).contains(&n), "expected 2-11 rounds of 50ms in 500ms, got {n}");
1008        let _ = std::fs::remove_dir_all(&dir);
1009    }
1010
1011    #[tokio::test]
1012    async fn raw_body_preserves_json_headers_in_spec_campaign_and_exports() {
1013        let mut server = mockito::Server::new_async().await;
1014        let m = server
1015            .mock("POST", "/upload")
1016            .match_header("content-type", "application/json")
1017            .match_header("transfer-encoding", "chunked")
1018            .match_body(mockito::Matcher::Exact("X".repeat(4096)))
1019            .with_status(400)
1020            .expect_at_least(2)
1021            .create_async()
1022            .await;
1023        let dir = tempfile::tempdir().unwrap();
1024        let spec = dir.path().join("api.json");
1025        std::fs::write(
1026            &spec,
1027            serde_json::json!({
1028                "openapi": "3.0.3", "info": {"title": "test", "version": "1"},
1029                "paths": {"/upload": {"post": {
1030                    "operationId": "upload",
1031                    "requestBody": {"content": {"application/json": {"schema": {
1032                        "type": "object", "properties": {"name": {"type": "string"}}
1033                    }}}},
1034                    "responses": {"200": {"description": "ok"}}
1035                }}}
1036            })
1037            .to_string(),
1038        )
1039        .unwrap();
1040        let targets = dir.path().join("targets.json");
1041        std::fs::write(
1042            &targets,
1043            serde_json::json!([
1044                {"url": server.url(), "headers": {"Content-Type": "application/json"}}
1045            ])
1046            .to_string(),
1047        )
1048        .unwrap();
1049        let mut c = cmd(None, Some(targets));
1050        c.spec = Some(spec);
1051        c.raw_body = true;
1052        c.output = dir.path().join("out");
1053        c.rounds = Some(2);
1054        c.rps = Some(10);
1055        c.cps = true;
1056        c.export_requests = true;
1057        assert!(c.execute().await.unwrap());
1058        m.assert_async().await;
1059        let record: serde_json::Value = serde_json::from_slice(
1060            &std::fs::read(c.output.join("round-summaries/round_2.json")).unwrap(),
1061        )
1062        .unwrap();
1063        assert!(record["round_elapsed_seconds"].as_f64().unwrap() > 0.0);
1064        let pass = &record["pass"];
1065        assert_eq!(pass["raw_body"], true);
1066        assert_eq!(pass["rps_per_target"], 10);
1067        assert_eq!(pass["rate_limit_enabled"], true);
1068        assert_eq!(pass["new_connection_per_request"], true);
1069        let row = &pass["targets"][0];
1070        let requests = row["total_requests"].as_u64().unwrap();
1071        let elapsed = row["elapsed_seconds"].as_f64().unwrap();
1072        assert!(requests > 0);
1073        // JSON serialization can round elapsed and throughput independently.
1074        let measured = row["measured_rps"].as_f64().unwrap();
1075        let expected = requests as f64 / elapsed;
1076        assert!((measured - expected).abs() <= expected.abs() * 1e-12);
1077        let window = row["load_window_seconds"].as_f64().unwrap();
1078        assert!(window > 0.0);
1079        let started = row["start_window_rps"].as_f64().unwrap();
1080        assert!((started - requests as f64 / window).abs() <= started * 1e-12);
1081        assert!(row["upload_mbps"].as_f64().unwrap() > 0.0);
1082        assert_eq!(row["status_counts"]["400"], requests);
1083        let exported: serde_json::Value = serde_json::from_slice(
1084            &std::fs::read(c.output.join("round_2/target_1/chunked-requests.json")).unwrap(),
1085        )
1086        .unwrap();
1087        assert_eq!(exported["operations"][0]["raw_body"], true);
1088        assert_eq!(exported["operations"][0]["headers"]["Content-Type"], "application/json");
1089    }
1090
1091    #[tokio::test]
1092    async fn automatic_json_and_raw_body_work_without_spec() {
1093        let mut server = mockito::Server::new_async().await;
1094        let json = server
1095            .mock("POST", "/json")
1096            .match_header("content-type", "application/vnd.test+json")
1097            .match_header("transfer-encoding", "chunked")
1098            .match_body(mockito::Matcher::PartialJson(
1099                serde_json::json!({"_padding": "X".repeat(4081)}),
1100            ))
1101            .with_status(200)
1102            .expect_at_least(1)
1103            .create_async()
1104            .await;
1105        let raw = server
1106            .mock("POST", "/raw")
1107            .match_header("content-type", "application/vnd.test+json")
1108            .match_header("transfer-encoding", "chunked")
1109            .match_body(mockito::Matcher::Exact("X".repeat(4096)))
1110            .with_status(400)
1111            .expect_at_least(1)
1112            .create_async()
1113            .await;
1114        let mut c = cmd(Some(&format!("{}/json", server.url())), None);
1115        c.headers.insert("Content-Type".into(), "application/vnd.test+json".into());
1116        let (_, summary) = c.execute_pass(Path::new("unused")).await.unwrap();
1117        assert!(summary["total_requests"].as_u64().unwrap() > 0);
1118        assert_eq!(summary["rps_per_target"], serde_json::Value::Null);
1119        assert_eq!(summary["rate_limit_enabled"], false);
1120        assert_eq!(summary["new_connection_per_request"], false);
1121        assert!(summary["measured_rps"].as_f64().unwrap() > 0.0);
1122        json.assert_async().await;
1123        c.target = Some(format!("{}/raw", server.url()));
1124        c.raw_body = true;
1125        let (_, summary) = c.execute_pass(Path::new("unused")).await.unwrap();
1126        assert!(summary["status_counts"]["400"].as_u64().unwrap() > 0);
1127        raw.assert_async().await;
1128    }
1129
1130    #[test]
1131    fn start_window_rps_excludes_in_flight_drain() {
1132        // Srikanth's 30s `--rps 1` run: 30 starts, last response at 77s.
1133        let o = TargetOutcome {
1134            total_requests: 30,
1135            bytes_sent: 30 * 1_048_576,
1136            elapsed: Duration::from_secs(77),
1137            load_window: Duration::from_secs(30),
1138            ..Default::default()
1139        };
1140        let f = o.rate_fields();
1141        assert_eq!(f["start_window_rps"], 1.0);
1142        assert_eq!(f["load_window_seconds"], 30.0);
1143        assert!((f["measured_rps"].as_f64().unwrap() - 30.0 / 77.0).abs() < 1e-12);
1144        let mbps = 30.0 * 1_048_576.0 * 8.0 / 77.0 / 1e6;
1145        assert!((f["upload_mbps"].as_f64().unwrap() - mbps).abs() < 1e-12);
1146    }
1147
1148    /// Records when each request arrives (by path), then holds the response
1149    /// for `hold` so every operation has an in-flight drain.
1150    async fn slow_receiver(
1151        hold: Duration,
1152    ) -> (String, Arc<std::sync::Mutex<Vec<(String, std::time::Instant)>>>) {
1153        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1154        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1155        let url = format!("http://{}", listener.local_addr().unwrap());
1156        let arrivals = Arc::new(std::sync::Mutex::new(Vec::new()));
1157        let log = arrivals.clone();
1158        tokio::spawn(async move {
1159            loop {
1160                let (mut sock, _) = listener.accept().await.unwrap();
1161                let log = log.clone();
1162                tokio::spawn(async move {
1163                    let mut buf = Vec::new();
1164                    let mut chunk = [0u8; 4096];
1165                    loop {
1166                        // One request: headers, then a chunked body ending in 0\r\n\r\n.
1167                        let header_end = loop {
1168                            if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1169                                break i + 4;
1170                            }
1171                            match sock.read(&mut chunk).await {
1172                                Ok(0) | Err(_) => return,
1173                                Ok(n) => buf.extend_from_slice(&chunk[..n]),
1174                            }
1175                        };
1176                        let head = String::from_utf8_lossy(&buf[..header_end]).to_string();
1177                        let path = head.split_whitespace().nth(1).unwrap_or("").to_string();
1178                        log.lock().unwrap().push((path, std::time::Instant::now()));
1179                        let body_end = loop {
1180                            if let Some(i) =
1181                                buf[header_end..].windows(5).position(|w| w == b"0\r\n\r\n")
1182                            {
1183                                break header_end + i + 5;
1184                            }
1185                            match sock.read(&mut chunk).await {
1186                                Ok(0) | Err(_) => return,
1187                                Ok(n) => buf.extend_from_slice(&chunk[..n]),
1188                            }
1189                        };
1190                        buf.drain(..body_end);
1191                        tokio::time::sleep(hold).await;
1192                        let ok = b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n";
1193                        if sock.write_all(ok).await.is_err() {
1194                            return;
1195                        }
1196                    }
1197                });
1198            }
1199        });
1200        (url, arrivals)
1201    }
1202
1203    async fn second_operation_start(overlap: bool) -> (Duration, serde_json::Value) {
1204        let (url, arrivals) = slow_receiver(Duration::from_millis(1200)).await;
1205        let dir = tempfile::tempdir().unwrap();
1206        let spec = dir.path().join("api.json");
1207        let op = |id: &str| {
1208            serde_json::json!({"post": {
1209                "operationId": id,
1210                "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string"}}}},
1211                "responses": {"200": {"description": "ok"}}
1212            }})
1213        };
1214        std::fs::write(
1215            &spec,
1216            serde_json::json!({
1217                "openapi": "3.0.3", "info": {"title": "t", "version": "1"},
1218                "paths": {"/a": op("a"), "/b": op("b")}
1219            })
1220            .to_string(),
1221        )
1222        .unwrap();
1223        let mut c = cmd(Some(&url), None);
1224        c.spec = Some(spec);
1225        c.duration = Duration::from_millis(400);
1226        c.overlap_operations = overlap;
1227        let started = std::time::Instant::now();
1228        let (ok, summary) = c.execute_pass(dir.path()).await.unwrap();
1229        assert!(ok);
1230        let first_b = arrivals
1231            .lock()
1232            .unwrap()
1233            .iter()
1234            .find(|(path, _)| path == "/b")
1235            .map(|(_, at)| at.duration_since(started))
1236            .expect("operation /b ran");
1237        (first_b, summary)
1238    }
1239
1240    #[tokio::test]
1241    async fn overlap_operations_starts_the_next_operation_when_the_window_closes() {
1242        // Each request is held 1.2s against a 0.4s load window.
1243        let (sequential, seq_summary) = second_operation_start(false).await;
1244        let (overlapped, overlap_summary) = second_operation_start(true).await;
1245        assert!(sequential >= Duration::from_millis(1200), "sequential /b at {sequential:?}");
1246        assert!(overlapped < Duration::from_millis(900), "overlapped /b at {overlapped:?}");
1247
1248        assert_eq!(overlap_summary["overlap_operations"], true);
1249        assert_eq!(seq_summary["overlap_operations"], false);
1250        // Overlapped elapsed is the pass's wall clock, not the sum of runtimes.
1251        let wall = overlap_summary["elapsed_seconds"].as_f64().unwrap();
1252        let summed = seq_summary["elapsed_seconds"].as_f64().unwrap();
1253        assert!(wall < summed - 0.5, "overlap {wall}s vs sequential {summed}s");
1254        assert_eq!(overlap_summary["request_timeout_mode"], "auto");
1255    }
1256
1257    #[test]
1258    fn round_records_distinguish_round_time_from_campaign_time() {
1259        let dir = tempfile::tempdir().unwrap();
1260        write_round_record(
1261            dir.path(),
1262            2,
1263            Duration::from_secs(30),
1264            Duration::from_millis(12500),
1265            true,
1266            serde_json::json!({}),
1267        )
1268        .unwrap();
1269        let record: serde_json::Value = serde_json::from_slice(
1270            &std::fs::read(dir.path().join("round-summaries/round_2.json")).unwrap(),
1271        )
1272        .unwrap();
1273        assert_eq!(record["campaign_elapsed_seconds"], 30);
1274        assert_eq!(record["round_elapsed_seconds"], 12.5);
1275        let jsonl: serde_json::Value = serde_json::from_str(
1276            &std::fs::read_to_string(dir.path().join("campaign.jsonl")).unwrap(),
1277        )
1278        .unwrap();
1279        assert_eq!(jsonl, record);
1280    }
1281
1282    #[tokio::test]
1283    async fn rejects_zero_rounds() {
1284        let mut c = cmd(Some("http://127.0.0.1:1"), None);
1285        c.rounds = Some(0);
1286        assert!(c.execute().await.is_err());
1287    }
1288}