1use 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#[derive(Debug, Clone)]
38pub struct ChunkedCommand {
39 pub target: Option<String>,
41 pub targets_file: Option<PathBuf>,
43 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 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 pub rps: Option<u32>,
63 pub cps: bool,
65 pub rounds: Option<u32>,
67 pub repeat_until: Option<Duration>,
69 pub keep_rounds: Option<u32>,
71}
72
73#[derive(Debug, Clone)]
75struct TargetRun {
76 prefix: String,
78 url: String,
79 headers: HashMap<String, String>,
80 spec: Option<PathBuf>,
81 output: PathBuf,
82}
83
84#[derive(Debug, Clone, Default)]
86struct TargetOutcome {
87 total_requests: u64,
88 successful: u64,
89 failed: u64,
90 bytes_sent: u64,
91 elapsed: Duration,
92 load_window: Duration,
94 status_counts: HashMap<u16, u64>,
95 run_failed: bool,
97}
98
99impl TargetOutcome {
100 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 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 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 "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 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 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 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 let mut violations: Vec<serde_json::Value> = Vec::new();
480 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
612fn 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
645fn 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
737fn 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 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 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 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 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}