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