Skip to main content

mockforge_bench/
parallel_executor.rs

1//! Parallel execution engine for multi-target bench testing
2//!
3//! Executes load tests against multiple targets in parallel with configurable
4//! concurrency limits. Uses tokio for async execution and semaphores for
5//! backpressure control.
6
7use crate::command::BenchCommand;
8use crate::error::{BenchError, Result};
9use crate::executor::{K6Executor, K6Results};
10use crate::k6_gen::{K6Config, K6ScriptGenerator};
11use crate::reporter::TerminalReporter;
12use crate::request_gen::RequestGenerator;
13use crate::scenarios::LoadScenario;
14use crate::spec_parser::SpecParser;
15use crate::target_parser::TargetConfig;
16use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
17use mockforge_openapi::spec::OpenApiSpec;
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::str::FromStr;
21use std::sync::Arc;
22use tokio::sync::Semaphore;
23use tokio::task::JoinHandle;
24
25/// Result for a single target execution
26#[derive(Debug, Clone)]
27pub struct TargetResult {
28    /// Target URL that was tested
29    pub target_url: String,
30    /// Index of the target (for ordering)
31    pub target_index: usize,
32    /// k6 test results
33    pub results: K6Results,
34    /// Output directory for this target
35    pub output_dir: PathBuf,
36    /// Whether the test succeeded
37    pub success: bool,
38    /// Error message if test failed
39    pub error: Option<String>,
40}
41
42/// Aggregated results from all target executions
43#[derive(Debug, Clone)]
44pub struct AggregatedResults {
45    /// Results for each target
46    pub target_results: Vec<TargetResult>,
47    /// Overall statistics
48    pub total_targets: usize,
49    pub successful_targets: usize,
50    pub failed_targets: usize,
51    /// Aggregated metrics across all targets
52    pub aggregated_metrics: AggregatedMetrics,
53}
54
55/// Aggregated metrics across all targets
56#[derive(Debug, Clone)]
57pub struct AggregatedMetrics {
58    /// Total requests across all targets
59    pub total_requests: u64,
60    /// Total failed requests across all targets
61    pub total_failed_requests: u64,
62    /// Average response time across all targets (ms)
63    pub avg_duration_ms: f64,
64    /// p95 response time across all targets (ms)
65    pub p95_duration_ms: f64,
66    /// p99 response time across all targets (ms)
67    pub p99_duration_ms: f64,
68    /// Overall error rate percentage
69    pub error_rate: f64,
70    /// Total RPS across all targets
71    pub total_rps: f64,
72    /// Average RPS per target
73    pub avg_rps: f64,
74    /// Total max VUs across all targets
75    pub total_vus_max: u32,
76    /// Total connections opened across all targets (Issue #79 round 12 —
77    /// Srikanth's multi-target bench output was missing the CPS / connection
78    /// counts that single-target runs surface).
79    pub total_connections_opened: u64,
80    /// Total iterations completed across all targets.
81    pub total_iterations_completed: u64,
82}
83
84impl AggregatedMetrics {
85    /// Calculate aggregated metrics from target results
86    fn from_results(results: &[TargetResult]) -> Self {
87        let mut total_requests = 0u64;
88        let mut total_failed_requests = 0u64;
89        let mut durations = Vec::new();
90        let mut p95_values = Vec::new();
91        let mut p99_values = Vec::new();
92        let mut total_rps = 0.0f64;
93        let mut total_vus_max = 0u32;
94        let mut total_connections_opened = 0u64;
95        let mut total_iterations_completed = 0u64;
96        let mut successful_count = 0usize;
97
98        for result in results {
99            if result.success {
100                total_requests += result.results.total_requests;
101                total_failed_requests += result.results.failed_requests;
102                durations.push(result.results.avg_duration_ms);
103                p95_values.push(result.results.p95_duration_ms);
104                p99_values.push(result.results.p99_duration_ms);
105                total_rps += result.results.rps;
106                total_vus_max += result.results.vus_max;
107                total_connections_opened += result.results.tcp_connect_samples;
108                total_iterations_completed += result.results.iterations_completed;
109                successful_count += 1;
110            }
111        }
112
113        let avg_duration_ms = if !durations.is_empty() {
114            durations.iter().sum::<f64>() / durations.len() as f64
115        } else {
116            0.0
117        };
118
119        let p95_duration_ms = if !p95_values.is_empty() {
120            p95_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
121            let index = (p95_values.len() as f64 * 0.95).ceil() as usize - 1;
122            p95_values[index.min(p95_values.len() - 1)]
123        } else {
124            0.0
125        };
126
127        let p99_duration_ms = if !p99_values.is_empty() {
128            p99_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
129            let index = (p99_values.len() as f64 * 0.99).ceil() as usize - 1;
130            p99_values[index.min(p99_values.len() - 1)]
131        } else {
132            0.0
133        };
134
135        let error_rate = if total_requests > 0 {
136            (total_failed_requests as f64 / total_requests as f64) * 100.0
137        } else {
138            0.0
139        };
140
141        let avg_rps = if successful_count > 0 {
142            total_rps / successful_count as f64
143        } else {
144            0.0
145        };
146
147        Self {
148            total_requests,
149            total_failed_requests,
150            avg_duration_ms,
151            p95_duration_ms,
152            p99_duration_ms,
153            error_rate,
154            total_rps,
155            avg_rps,
156            total_vus_max,
157            total_connections_opened,
158            total_iterations_completed,
159        }
160    }
161}
162
163/// Parallel executor for multi-target bench testing
164pub struct ParallelExecutor {
165    /// Base command configuration (shared across all targets)
166    base_command: BenchCommand,
167    /// List of targets to test
168    targets: Vec<TargetConfig>,
169    /// Explicit `--max-concurrency` override. `None` means auto (10, or 3
170    /// for huge specs — round 65 / #79).
171    max_concurrency_override: Option<usize>,
172    /// Base output directory
173    base_output: PathBuf,
174}
175
176impl ParallelExecutor {
177    /// Create a new parallel executor
178    pub fn new(
179        base_command: BenchCommand,
180        targets: Vec<TargetConfig>,
181        max_concurrency: Option<usize>,
182    ) -> Self {
183        let base_output = base_command.output.clone();
184        Self {
185            base_command,
186            targets,
187            max_concurrency_override: max_concurrency,
188            base_output,
189        }
190    }
191
192    /// Round 63 (#79): `--max-concurrency` is a semaphore, not a shared VU
193    /// pool. Each batch of up to `max_concurrency` targets runs the full
194    /// duration, then the next batch starts. Wall clock ≈
195    /// ceil(targets / concurrency) * duration.
196    pub(crate) fn estimated_wall_clock(
197        n_targets: usize,
198        max_concurrency: usize,
199        duration_secs: u64,
200    ) -> (usize, u64) {
201        let conc = max_concurrency.max(1);
202        let batches = if n_targets == 0 {
203            0
204        } else {
205            n_targets.div_ceil(conc)
206        };
207        (batches, (batches as u64).saturating_mul(duration_secs))
208    }
209
210    /// Human-readable duration for the wall-clock estimate (`35m00s`).
211    pub(crate) fn format_wall_clock(secs: u64) -> String {
212        let hours = secs / 3600;
213        let mins = (secs % 3600) / 60;
214        let rem = secs % 60;
215        if hours > 0 {
216            format!("{hours}h{mins:02}m{rem:02}s")
217        } else if mins > 0 {
218            format!("{mins}m{rem:02}s")
219        } else {
220            format!("{rem}s")
221        }
222    }
223
224    /// Execute tests against all targets in parallel
225    pub async fn execute_all(&self) -> Result<AggregatedResults> {
226        let total_targets = self.targets.len();
227        TerminalReporter::print_progress(&format!(
228            "Starting parallel execution for {} targets",
229            total_targets
230        ));
231
232        // Validate k6 installation
233        if !K6Executor::is_k6_installed() {
234            TerminalReporter::print_error("k6 is not installed");
235            TerminalReporter::print_warning(
236                "Install k6 from: https://k6.io/docs/get-started/installation/",
237            );
238            return Err(BenchError::K6NotFound);
239        }
240
241        // #79: --targets-file used to ignore --wafbench-verbatim. It always
242        // required a spec and built templates from spec operations, so the
243        // command either died with "No spec files provided" or fuzzed spec
244        // URLs instead of sending the traffic file as written.
245        let spec_supplied =
246            !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
247        let verbatim = self.base_command.wafbench_verbatim;
248
249        let (templates, parser) = if verbatim {
250            let verbatim_templates = self.base_command.load_verbatim_templates()?;
251            if verbatim_templates.is_empty() {
252                return Err(BenchError::Other(
253                    "--wafbench-verbatim was set but no traffic cases were loaded. Check \
254                     --wafbench-dir points at a file, directory or glob containing cases with \
255                     a `request.uri`."
256                        .to_string(),
257                ));
258            }
259            TerminalReporter::print_success(&format!(
260                "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
261                verbatim_templates.len()
262            ));
263            let parser = if spec_supplied {
264                TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
265                let merged_spec = self.base_command.load_and_merge_specs().await?;
266                TerminalReporter::print_success("Specification(s) loaded (base path only)");
267                SpecParser::from_spec(merged_spec)
268            } else {
269                SpecParser::from_spec(OpenApiSpec {
270                    spec: Default::default(),
271                    file_path: None,
272                    raw_document: None,
273                })
274            };
275            (verbatim_templates, parser)
276        } else {
277            TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
278            let merged_spec = self.base_command.load_and_merge_specs().await?;
279            let parser = SpecParser::from_spec(merged_spec);
280            TerminalReporter::print_success("Specification(s) loaded");
281
282            let operations = if let Some(filter) = &self.base_command.operations {
283                parser.filter_operations(filter)?
284            } else {
285                parser.get_operations()
286            };
287
288            if operations.is_empty() {
289                return Err(BenchError::Other("No operations found in spec".to_string()));
290            }
291
292            TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
293
294            TerminalReporter::print_progress("Generating request templates...");
295            let templates: Vec<_> = operations
296                .iter()
297                .map(RequestGenerator::generate_template)
298                .collect::<Result<Vec<_>>>()?;
299            TerminalReporter::print_success("Request templates generated");
300            (templates, parser)
301        };
302
303        // Pre-load per-target specs. In verbatim mode a per-target spec may
304        // still resolve --base-path, but it must not replace the traffic-file
305        // templates. That swap is how --targets-file kept fuzzing spec URLs
306        // after --wafbench-verbatim was added (#79).
307        let mut per_target_data: HashMap<
308            PathBuf,
309            (Vec<crate::request_gen::RequestTemplate>, Option<String>),
310        > = HashMap::new();
311        if !verbatim {
312            let mut unique_specs: Vec<PathBuf> = Vec::new();
313            for t in &self.targets {
314                if let Some(spec_path) = &t.spec {
315                    if !unique_specs.contains(spec_path) {
316                        unique_specs.push(spec_path.clone());
317                    }
318                }
319            }
320            for spec_path in &unique_specs {
321                TerminalReporter::print_progress(&format!(
322                    "Loading per-target spec: {}",
323                    spec_path.display()
324                ));
325                match SpecParser::from_file(spec_path).await {
326                    Ok(target_parser) => {
327                        let target_ops = if let Some(filter) = &self.base_command.operations {
328                            match target_parser.filter_operations(filter) {
329                                Ok(ops) => ops,
330                                Err(e) => {
331                                    TerminalReporter::print_warning(&format!(
332                                        "Failed to filter operations from {}: {}. Using shared spec.",
333                                        spec_path.display(),
334                                        e
335                                    ));
336                                    continue;
337                                }
338                            }
339                        } else {
340                            target_parser.get_operations()
341                        };
342                        let target_templates: Vec<_> = match target_ops
343                            .iter()
344                            .map(RequestGenerator::generate_template)
345                            .collect::<Result<Vec<_>>>()
346                        {
347                            Ok(t) => t,
348                            Err(e) => {
349                                TerminalReporter::print_warning(&format!(
350                                    "Failed to generate templates from {}: {}. Using shared spec.",
351                                    spec_path.display(),
352                                    e
353                                ));
354                                continue;
355                            }
356                        };
357                        let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
358                            if cli_bp.is_empty() {
359                                None
360                            } else {
361                                Some(cli_bp.clone())
362                            }
363                        } else {
364                            target_parser.get_base_path()
365                        };
366                        TerminalReporter::print_success(&format!(
367                            "Loaded {} operations from {}",
368                            target_templates.len(),
369                            spec_path.display()
370                        ));
371                        per_target_data
372                            .insert(spec_path.clone(), (target_templates, target_base_path));
373                    }
374                    Err(e) => {
375                        TerminalReporter::print_warning(&format!(
376                            "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
377                            spec_path.display(),
378                            e
379                        ));
380                    }
381                }
382            }
383        }
384
385        // Parse base headers
386        let base_headers = self.base_command.parse_headers()?;
387
388        // Resolve base path (CLI option takes priority over spec's servers URL)
389        let base_path = self.resolve_base_path(&parser);
390        if let Some(ref bp) = base_path {
391            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
392        }
393
394        // Parse scenario
395        let scenario = LoadScenario::from_str(&self.base_command.scenario)
396            .map_err(BenchError::InvalidScenario)?;
397
398        let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
399
400        // Round 65 (#79) — resolve concurrency after we know op count so huge
401        // specs auto-cap parallel heavyweight k6 processes.
402        let (max_concurrency, conc_warn) = crate::k6_gen::resolve_max_concurrency(
403            self.max_concurrency_override,
404            templates.len(),
405            total_targets,
406        );
407        if let Some(msg) = conc_warn {
408            TerminalReporter::print_warning(&msg);
409        } else {
410            TerminalReporter::print_progress(&format!(
411                "Max concurrency: {} ({} target{})",
412                max_concurrency,
413                total_targets,
414                if total_targets == 1 { "" } else { "s" },
415            ));
416        }
417
418        // Round 65 (#79) — collapse per-op metrics once for all targets.
419        let (per_op_metrics, per_op_warn) = crate::k6_gen::resolve_per_op_metrics(
420            self.base_command.per_op_metrics,
421            templates.len(),
422            duration_secs_val,
423        );
424        if let Some(msg) = per_op_warn {
425            TerminalReporter::print_warning(&msg);
426        }
427
428        // Round 63 (#79): batches of `--max-concurrency` each run the full
429        // `--duration`. Wall clock is ceil(targets / concurrency) * duration,
430        // not duration alone. --vus and --rps are per target, not shared.
431        let (batches, one_pass_wall_secs) =
432            Self::estimated_wall_clock(total_targets, max_concurrency, duration_secs_val);
433        TerminalReporter::print_progress(&format!(
434            "One pass: {batches} batch(es) × {duration_secs_val}s ≈ {} ({one_pass_wall_secs}s). --vus and --rps are per target, not shared.",
435            Self::format_wall_clock(one_pass_wall_secs),
436        ));
437
438        // Round 66 (#79) — optional campaign loop so low concurrency can still
439        // hit every target over a longevity window without manual restarts.
440        let repeat_until_secs = match &self.base_command.repeat_until {
441            Some(d) => Some(BenchCommand::parse_duration(d)?),
442            None => None,
443        };
444        if let Some(n) = self.base_command.rounds {
445            if n == 0 {
446                return Err(BenchError::Other(
447                    "--rounds must be >= 1 (or omit it for a single pass / --repeat-until)".into(),
448                ));
449            }
450        }
451        let max_rounds = self.base_command.rounds.unwrap_or(if repeat_until_secs.is_some() {
452            u32::MAX
453        } else {
454            1
455        });
456        let looping = max_rounds > 1 || repeat_until_secs.is_some();
457        if let Some(until) = repeat_until_secs {
458            TerminalReporter::print_progress(&format!(
459                "Campaign loop: will re-run all targets until wall clock reaches {} \
460                 (or --rounds {}). Each k6 process only lives `--duration` ({duration_secs_val}s), \
461                 so RSS resets between batches.",
462                Self::format_wall_clock(until),
463                self.base_command
464                    .rounds
465                    .map(|n| n.to_string())
466                    .unwrap_or_else(|| "unlimited".into()),
467            ));
468        } else if max_rounds > 1 {
469            TerminalReporter::print_progress(&format!(
470                "Campaign loop: will re-run all targets for {max_rounds} round(s)."
471            ));
472        }
473
474        let security_testing_enabled_val = self.base_command.security_testing_enabled();
475
476        if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
477            TerminalReporter::print_progress(
478                "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
479            );
480        }
481
482        // Pre-compute enhancement code once (same for all targets)
483        let has_advanced_features = self.base_command.data_file.is_some()
484            || self.base_command.error_rate.is_some()
485            || self.base_command.security_testing_enabled()
486            || self.base_command.parallel_create.is_some();
487
488        let enhancement_code = if has_advanced_features {
489            let dummy_script = "export const options = {};";
490            let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
491            if let Some(pos) = enhanced.find("export const options") {
492                enhanced[..pos].to_string()
493            } else {
494                String::new()
495            }
496        } else {
497            String::new()
498        };
499
500        let campaign_start = std::time::Instant::now();
501        let mut last_results: Option<AggregatedResults> = None;
502        let mut round: u32 = 0;
503
504        while round < max_rounds {
505            // Stop before starting a new round once wall clock is exhausted.
506            if let Some(until) = repeat_until_secs {
507                if round > 0 && campaign_start.elapsed().as_secs() >= until {
508                    TerminalReporter::print_progress(&format!(
509                        "Reached --repeat-until wall clock ({}); stopping after {} round(s).",
510                        Self::format_wall_clock(until),
511                        round,
512                    ));
513                    break;
514                }
515            }
516
517            round += 1;
518            let round_output = if looping {
519                let dir = self.base_output.join(format!("round_{round}"));
520                let wall_note = repeat_until_secs
521                    .map(|u| {
522                        format!(
523                            " (wall {} / {})",
524                            Self::format_wall_clock(campaign_start.elapsed().as_secs()),
525                            Self::format_wall_clock(u)
526                        )
527                    })
528                    .unwrap_or_default();
529                TerminalReporter::print_progress(&format!(
530                    "Round {round}{wall_note} — results under {}",
531                    dir.display()
532                ));
533                dir
534            } else {
535                self.base_output.clone()
536            };
537
538            // Create semaphore for concurrency control
539            let semaphore = Arc::new(Semaphore::new(max_concurrency));
540            let multi_progress = MultiProgress::new();
541
542            // Create progress bars for each target
543            let progress_bars: Vec<ProgressBar> = (0..total_targets)
544                .map(|i| {
545                    let pb = multi_progress.add(ProgressBar::new(1));
546                    pb.set_style(
547                        ProgressStyle::default_bar()
548                            .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
549                            .unwrap(),
550                    );
551                    pb.set_message(format!("Target {}", i + 1));
552                    pb
553                })
554                .collect();
555
556            // Spawn tasks for each target
557            let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
558
559            for (index, target) in self.targets.iter().enumerate() {
560                let target = target.clone();
561                // Clone necessary fields from base_command instead of passing reference
562                let duration = self.base_command.duration.clone();
563                let vus = self.base_command.vus;
564                let scenario_str = self.base_command.scenario.clone();
565                let operations = self.base_command.operations.clone();
566                let auth = self.base_command.auth.clone();
567                let headers = self.base_command.headers.clone();
568                let threshold_percentile = self.base_command.threshold_percentile.clone();
569                let threshold_ms = self.base_command.threshold_ms;
570                let max_error_rate = self.base_command.max_error_rate;
571                // Issue #79 r62 (Srikanth on 0.3.209): the round-60 abort valve
572                // stopped his WAF stress runs at ~2min because a legitimate high
573                // rejection rate crossed 0.95. Thread the opt-out/threshold through
574                // the per-target path so `--no-abort-on-error` reaches the k6 script.
575                let abort_on_error = self.base_command.abort_on_error;
576                let abort_on_error_rate = self.base_command.abort_on_error_rate;
577                let verbose = self.base_command.verbose;
578                let skip_tls_verify = self.base_command.skip_tls_verify;
579                let chunked_request_bodies = self.base_command.chunked_request_bodies;
580                let target_rps = self.base_command.target_rps;
581                let no_keep_alive = self.base_command.no_keep_alive;
582                // Issue #79 r54 (Srikanth on 0.3.200): `--source-ip` was silently
583                // dropped in multi-target (`--targets-file`) mode because this
584                // executor built `K6Executor::new()` without `.with_local_ips` and
585                // the per-target BenchCommand clone zeroed `source_ips`. Thread the
586                // source IPs (as the k6 `--local-ips` list) and geo config through
587                // to each target's run.
588                let local_ips = self.base_command.source_ips.join(",");
589                let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
590                let geo_source_ips = self.base_command.geo_source_ips.clone();
591                let geo_source_headers = self.base_command.geo_source_headers.clone();
592
593                // Select per-target templates/base_path if this target has a custom spec.
594                // Verbatim templates stay the traffic file's requests even when a
595                // target lists its own spec.
596                let (templates, base_path) = if verbatim {
597                    (templates.clone(), base_path.clone())
598                } else if let Some(spec_path) = &target.spec {
599                    if let Some((t, bp)) = per_target_data.get(spec_path) {
600                        (t.clone(), bp.clone())
601                    } else {
602                        (templates.clone(), base_path.clone())
603                    }
604                } else {
605                    (templates.clone(), base_path.clone())
606                };
607
608                let base_headers = base_headers.clone();
609                let scenario = scenario.clone();
610                let duration_secs = duration_secs_val;
611                let base_output = round_output.clone();
612                let semaphore = semaphore.clone();
613                let progress_bar = progress_bars[index].clone();
614                let target_index = index;
615                let security_testing_enabled = security_testing_enabled_val;
616                let enhancement_code = enhancement_code.clone();
617
618                let handle = tokio::spawn(async move {
619                    // Acquire semaphore permit
620                    let _permit = semaphore.acquire().await.map_err(|e| {
621                        BenchError::Other(format!("Failed to acquire semaphore: {}", e))
622                    })?;
623
624                    progress_bar.set_message(format!("Testing {}", target.url));
625
626                    // Execute test for this target
627                    let result = Self::execute_single_target_internal(
628                        &duration,
629                        vus,
630                        &scenario_str,
631                        &operations,
632                        &auth,
633                        &headers,
634                        &threshold_percentile,
635                        threshold_ms,
636                        max_error_rate,
637                        abort_on_error,
638                        abort_on_error_rate,
639                        per_op_metrics,
640                        verbose,
641                        skip_tls_verify,
642                        base_path.as_ref(),
643                        &target,
644                        target_index,
645                        &templates,
646                        &base_headers,
647                        &scenario,
648                        duration_secs,
649                        &base_output,
650                        security_testing_enabled,
651                        chunked_request_bodies,
652                        target_rps,
653                        no_keep_alive,
654                        &enhancement_code,
655                        &local_ips,
656                        &dns_policy,
657                        &geo_source_ips,
658                        &geo_source_headers,
659                        verbatim,
660                    )
661                    .await;
662
663                    progress_bar.inc(1);
664                    progress_bar.finish_with_message(format!("Completed {}", target.url));
665
666                    result
667                });
668
669                handles.push(handle);
670            }
671
672            // Wait for all tasks to complete and collect results
673            let mut target_results = Vec::new();
674            for (index, handle) in handles.into_iter().enumerate() {
675                match handle.await {
676                    Ok(Ok(result)) => {
677                        target_results.push(result);
678                    }
679                    Ok(Err(e)) => {
680                        // Create error result
681                        let target_url = self.targets[index].url.clone();
682                        target_results.push(TargetResult {
683                            target_url: target_url.clone(),
684                            target_index: index,
685                            results: K6Results::default(),
686                            output_dir: round_output.join(format!("target_{}", index + 1)),
687                            success: false,
688                            error: Some(e.to_string()),
689                        });
690                    }
691                    Err(e) => {
692                        // Join error
693                        let target_url = self.targets[index].url.clone();
694                        target_results.push(TargetResult {
695                            target_url: target_url.clone(),
696                            target_index: index,
697                            results: K6Results::default(),
698                            output_dir: round_output.join(format!("target_{}", index + 1)),
699                            success: false,
700                            error: Some(format!("Task join error: {}", e)),
701                        });
702                    }
703                }
704            }
705
706            // Sort results by target index
707            target_results.sort_by_key(|r| r.target_index);
708
709            // Calculate aggregated metrics
710            let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
711
712            let successful_targets = target_results.iter().filter(|r| r.success).count();
713            let failed_targets = total_targets - successful_targets;
714
715            last_results = Some(AggregatedResults {
716                target_results,
717                total_targets,
718                successful_targets,
719                failed_targets,
720                aggregated_metrics,
721            });
722        }
723
724        last_results.ok_or_else(|| {
725            BenchError::Other("No rounds executed (check --rounds / --repeat-until)".to_string())
726        })
727    }
728
729    /// Resolve the effective base path for API endpoints
730    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
731        // CLI option takes priority (including empty string to disable)
732        if let Some(cli_base_path) = &self.base_command.base_path {
733            if cli_base_path.is_empty() {
734                return None;
735            }
736            return Some(cli_base_path.clone());
737        }
738        // Fall back to spec's base path
739        parser.get_base_path()
740    }
741
742    /// Execute a single target test (internal method that doesn't require BenchCommand)
743    #[allow(clippy::too_many_arguments)]
744    async fn execute_single_target_internal(
745        _duration: &str,
746        vus: u32,
747        _scenario_str: &str,
748        _operations: &Option<String>,
749        auth: &Option<String>,
750        _headers: &[String],
751        threshold_percentile: &str,
752        threshold_ms: u64,
753        max_error_rate: f64,
754        abort_on_error: bool,
755        abort_on_error_rate: f64,
756        per_op_metrics: bool,
757        verbose: bool,
758        skip_tls_verify: bool,
759        base_path: Option<&String>,
760        target: &TargetConfig,
761        target_index: usize,
762        templates: &[crate::request_gen::RequestTemplate],
763        base_headers: &HashMap<String, String>,
764        scenario: &LoadScenario,
765        duration_secs: u64,
766        base_output: &Path,
767        security_testing_enabled: bool,
768        chunked_request_bodies: bool,
769        target_rps: Option<u32>,
770        no_keep_alive: bool,
771        enhancement_code: &str,
772        local_ips: &str,
773        dns_policy: &str,
774        geo_source_ips: &[String],
775        geo_source_headers: &[String],
776        wafbench_verbatim: bool,
777    ) -> Result<TargetResult> {
778        // Merge target-specific headers with base headers
779        let mut custom_headers = base_headers.clone();
780        if let Some(target_headers) = &target.headers {
781            custom_headers.extend(target_headers.clone());
782        }
783
784        // Round 63 (#79): keep Connection on the wire; force HTTP/1.1.
785        let force_http1 = crate::request_gen::should_force_k6_http1(
786            wafbench_verbatim,
787            templates,
788            &custom_headers,
789        );
790
791        // Use target-specific auth if provided, otherwise use base auth
792        let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
793
794        // Create k6 config for this target
795        let k6_config = K6Config {
796            target_url: target.url.clone(),
797            base_path: base_path.cloned(),
798            scenario: scenario.clone(),
799            duration_secs,
800            max_vus: vus,
801            threshold_percentile: threshold_percentile.to_string(),
802            threshold_ms,
803            max_error_rate,
804            auth_header,
805            custom_headers,
806            skip_tls_verify,
807            security_testing_enabled,
808            chunked_request_bodies,
809            target_rps,
810            no_keep_alive,
811            geo_source_ips: geo_source_ips.to_vec(),
812            geo_source_headers: geo_source_headers.to_vec(),
813        };
814
815        // Generate k6 script
816        let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
817            .with_abort_valve(abort_on_error, abort_on_error_rate)
818            .with_force_http1(force_http1)
819            .with_per_op_metrics(per_op_metrics);
820        let mut script = generator.generate()?;
821
822        // Apply pre-computed enhancement code (security definitions, etc.)
823        if !enhancement_code.is_empty() {
824            if let Some(pos) = script.find("export const options") {
825                script.insert_str(pos, enhancement_code);
826            }
827        }
828
829        // Validate script
830        let validation_errors = K6ScriptGenerator::validate_script(&script);
831        if !validation_errors.is_empty() {
832            return Err(BenchError::Other(format!(
833                "Script validation failed for target {}: {}",
834                target.url,
835                validation_errors.join(", ")
836            )));
837        }
838
839        // Create output directory for this target
840        let output_dir = base_output.join(format!("target_{}", target_index + 1));
841        std::fs::create_dir_all(&output_dir)?;
842
843        // Write script to file
844        let script_path = output_dir.join("k6-script.js");
845        std::fs::write(&script_path, script)?;
846
847        // Execute k6 with its own REST API server port per target. k6 defaults
848        // to localhost:6565 and parallel instances collide on it.
849        //
850        // Issue #79 r58 — Srikanth on 0.3.205: every one of 10 targets failed
851        // with `exit status: 106` (k6 `CannotStartRESTAPI`) and 0 requests, so
852        // NO load ran at all (which read as "--discard-response-bodies isn't
853        // helping"). The old scheme pinned each k6 to a FIXED port
854        // (6565 + index → 6566, 6567, ...), which collides whenever those
855        // ports are already taken — e.g. orphaned k6 processes left behind by
856        // a previous run that was OOM-killed (his r56/r57 SIGKILL), a second
857        // concurrent bench, or any local service. Bind an OS-assigned
858        // ephemeral port (`:0`) instead: k6 asks the kernel for a free port at
859        // start, so it can never be "already in use". We never query the REST
860        // API (results come from summary.json on disk), so a random port is
861        // fine. Verified: two concurrent k6 with `--address localhost:0` both
862        // start cleanly where the fixed port yields exit 106.
863        let api_port = 0; // ephemeral: kernel picks a free port, no collision
864                          // Issue #79 r54 — forward `--source-ip` (as k6 `--local-ips`) so each
865                          // target's VUs rotate through the configured source IP pool. Without
866                          // this, `--source-ip` was a silent no-op in multi-target mode.
867                          // Issue #79 r56 — plain multi-target load only checks status codes, so
868                          // discard response bodies to keep k6's RSS bounded on long, high-VU runs.
869                          // Without this, 10+ targets * 10 VUs * 70 min buffered every body and the
870                          // kernel OOM-killer sent k6 `signal: 9 (SIGKILL)`.
871        let executor = K6Executor::new()?
872            .with_local_ips(local_ips.to_string())
873            .with_dns_policy(dns_policy.to_string())
874            .with_discard_response_bodies(true)
875            .with_force_http1(force_http1);
876        let results = executor
877            .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
878            .await;
879
880        match results {
881            Ok(k6_results) => Ok(TargetResult {
882                target_url: target.url.clone(),
883                target_index,
884                results: k6_results,
885                output_dir,
886                success: true,
887                error: None,
888            }),
889            Err(e) => Ok(TargetResult {
890                target_url: target.url.clone(),
891                target_index,
892                results: K6Results::default(),
893                output_dir,
894                success: false,
895                error: Some(e.to_string()),
896            }),
897        }
898    }
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904
905    #[test]
906    fn test_aggregated_metrics_from_results() {
907        let results = vec![
908            TargetResult {
909                target_url: "http://api1.com".to_string(),
910                target_index: 0,
911                results: K6Results {
912                    total_requests: 100,
913                    failed_requests: 5,
914                    avg_duration_ms: 100.0,
915                    p95_duration_ms: 200.0,
916                    p99_duration_ms: 300.0,
917                    ..Default::default()
918                },
919                output_dir: PathBuf::from("output1"),
920                success: true,
921                error: None,
922            },
923            TargetResult {
924                target_url: "http://api2.com".to_string(),
925                target_index: 1,
926                results: K6Results {
927                    total_requests: 200,
928                    failed_requests: 10,
929                    avg_duration_ms: 150.0,
930                    p95_duration_ms: 250.0,
931                    p99_duration_ms: 350.0,
932                    ..Default::default()
933                },
934                output_dir: PathBuf::from("output2"),
935                success: true,
936                error: None,
937            },
938        ];
939
940        let metrics = AggregatedMetrics::from_results(&results);
941        assert_eq!(metrics.total_requests, 300);
942        assert_eq!(metrics.total_failed_requests, 15);
943        assert_eq!(metrics.avg_duration_ms, 125.0); // (100 + 150) / 2
944    }
945
946    #[test]
947    fn test_aggregated_metrics_with_failed_targets() {
948        let results = vec![
949            TargetResult {
950                target_url: "http://api1.com".to_string(),
951                target_index: 0,
952                results: K6Results {
953                    total_requests: 100,
954                    failed_requests: 5,
955                    avg_duration_ms: 100.0,
956                    p95_duration_ms: 200.0,
957                    p99_duration_ms: 300.0,
958                    ..Default::default()
959                },
960                output_dir: PathBuf::from("output1"),
961                success: true,
962                error: None,
963            },
964            TargetResult {
965                target_url: "http://api2.com".to_string(),
966                target_index: 1,
967                results: K6Results::default(),
968                output_dir: PathBuf::from("output2"),
969                success: false,
970                error: Some("Network error".to_string()),
971            },
972        ];
973
974        let metrics = AggregatedMetrics::from_results(&results);
975        // Only successful target should be counted
976        assert_eq!(metrics.total_requests, 100);
977        assert_eq!(metrics.total_failed_requests, 5);
978        assert_eq!(metrics.avg_duration_ms, 100.0);
979    }
980
981    #[test]
982    fn test_aggregated_metrics_empty_results() {
983        let results = vec![];
984        let metrics = AggregatedMetrics::from_results(&results);
985        assert_eq!(metrics.total_requests, 0);
986        assert_eq!(metrics.total_failed_requests, 0);
987        assert_eq!(metrics.avg_duration_ms, 0.0);
988        assert_eq!(metrics.error_rate, 0.0);
989    }
990
991    #[test]
992    fn test_aggregated_metrics_error_rate_calculation() {
993        let results = vec![TargetResult {
994            target_url: "http://api1.com".to_string(),
995            target_index: 0,
996            results: K6Results {
997                total_requests: 1000,
998                failed_requests: 50,
999                avg_duration_ms: 100.0,
1000                p95_duration_ms: 200.0,
1001                p99_duration_ms: 300.0,
1002                ..Default::default()
1003            },
1004            output_dir: PathBuf::from("output1"),
1005            success: true,
1006            error: None,
1007        }];
1008
1009        let metrics = AggregatedMetrics::from_results(&results);
1010        assert_eq!(metrics.error_rate, 5.0); // 50/1000 * 100
1011    }
1012
1013    #[test]
1014    fn test_aggregated_metrics_p95_p99_calculation() {
1015        let results = vec![
1016            TargetResult {
1017                target_url: "http://api1.com".to_string(),
1018                target_index: 0,
1019                results: K6Results {
1020                    total_requests: 100,
1021                    failed_requests: 0,
1022                    avg_duration_ms: 100.0,
1023                    p95_duration_ms: 150.0,
1024                    p99_duration_ms: 200.0,
1025                    ..Default::default()
1026                },
1027                output_dir: PathBuf::from("output1"),
1028                success: true,
1029                error: None,
1030            },
1031            TargetResult {
1032                target_url: "http://api2.com".to_string(),
1033                target_index: 1,
1034                results: K6Results {
1035                    total_requests: 100,
1036                    failed_requests: 0,
1037                    avg_duration_ms: 200.0,
1038                    p95_duration_ms: 250.0,
1039                    p99_duration_ms: 300.0,
1040                    ..Default::default()
1041                },
1042                output_dir: PathBuf::from("output2"),
1043                success: true,
1044                error: None,
1045            },
1046            TargetResult {
1047                target_url: "http://api3.com".to_string(),
1048                target_index: 2,
1049                results: K6Results {
1050                    total_requests: 100,
1051                    failed_requests: 0,
1052                    avg_duration_ms: 300.0,
1053                    p95_duration_ms: 350.0,
1054                    p99_duration_ms: 400.0,
1055                    ..Default::default()
1056                },
1057                output_dir: PathBuf::from("output3"),
1058                success: true,
1059                error: None,
1060            },
1061        ];
1062
1063        let metrics = AggregatedMetrics::from_results(&results);
1064        // p95 should be the 95th percentile of [150, 250, 350] = index 2 = 350
1065        // p99 should be the 99th percentile of [200, 300, 400] = index 2 = 400
1066        assert_eq!(metrics.p95_duration_ms, 350.0);
1067        assert_eq!(metrics.p99_duration_ms, 400.0);
1068    }
1069
1070    #[test]
1071    fn estimated_wall_clock_is_batches_times_duration() {
1072        // Srikanth: 64 IPs, --max-concurrency 10, -d 300 → 7 batches × 300s.
1073        let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
1074        assert_eq!(batches, 7);
1075        assert_eq!(wall, 2100);
1076        assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
1077        assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
1078        assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
1079        assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
1080        assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
1081    }
1082
1083    #[test]
1084    fn parallel_k6_spawn_sets_force_http1() {
1085        let src = include_str!("parallel_executor.rs");
1086        assert!(
1087            src.contains("with_force_http1(force_http1)"),
1088            "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
1089        );
1090        assert!(
1091            src.contains("One pass:") || src.contains("Estimated wall clock"),
1092            "multi-target start must print ceil(targets/concurrency)*duration"
1093        );
1094        assert!(
1095            src.contains("repeat_until") && src.contains("Campaign loop"),
1096            "multi-target must support --repeat-until campaign looping (#79)"
1097        );
1098        assert!(
1099            src.contains("with_per_op_metrics(per_op_metrics)"),
1100            "multi-target k6 spawn must apply Round-65 per-op metrics collapse (#79)"
1101        );
1102        assert!(
1103            src.contains("resolve_max_concurrency"),
1104            "multi-target must auto-cap concurrency for huge specs (#79)"
1105        );
1106    }
1107}