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    /// Maximum number of concurrent executions
170    max_concurrency: usize,
171    /// Base output directory
172    base_output: PathBuf,
173}
174
175impl ParallelExecutor {
176    /// Create a new parallel executor
177    pub fn new(
178        base_command: BenchCommand,
179        targets: Vec<TargetConfig>,
180        max_concurrency: usize,
181    ) -> Self {
182        let base_output = base_command.output.clone();
183        Self {
184            base_command,
185            targets,
186            max_concurrency,
187            base_output,
188        }
189    }
190
191    /// Round 63 (#79): `--max-concurrency` is a semaphore, not a shared VU
192    /// pool. Each batch of up to `max_concurrency` targets runs the full
193    /// duration, then the next batch starts. Wall clock ≈
194    /// ceil(targets / concurrency) * duration.
195    pub(crate) fn estimated_wall_clock(
196        n_targets: usize,
197        max_concurrency: usize,
198        duration_secs: u64,
199    ) -> (usize, u64) {
200        let conc = max_concurrency.max(1);
201        let batches = if n_targets == 0 {
202            0
203        } else {
204            n_targets.div_ceil(conc)
205        };
206        (batches, (batches as u64).saturating_mul(duration_secs))
207    }
208
209    /// Human-readable duration for the wall-clock estimate (`35m00s`).
210    pub(crate) fn format_wall_clock(secs: u64) -> String {
211        let hours = secs / 3600;
212        let mins = (secs % 3600) / 60;
213        let rem = secs % 60;
214        if hours > 0 {
215            format!("{hours}h{mins:02}m{rem:02}s")
216        } else if mins > 0 {
217            format!("{mins}m{rem:02}s")
218        } else {
219            format!("{rem}s")
220        }
221    }
222
223    /// Execute tests against all targets in parallel
224    pub async fn execute_all(&self) -> Result<AggregatedResults> {
225        let total_targets = self.targets.len();
226        TerminalReporter::print_progress(&format!(
227            "Starting parallel execution for {} targets (max concurrency: {})",
228            total_targets, self.max_concurrency
229        ));
230
231        // Validate k6 installation
232        if !K6Executor::is_k6_installed() {
233            TerminalReporter::print_error("k6 is not installed");
234            TerminalReporter::print_warning(
235                "Install k6 from: https://k6.io/docs/get-started/installation/",
236            );
237            return Err(BenchError::K6NotFound);
238        }
239
240        // #79: --targets-file used to ignore --wafbench-verbatim. It always
241        // required a spec and built templates from spec operations, so the
242        // command either died with "No spec files provided" or fuzzed spec
243        // URLs instead of sending the traffic file as written.
244        let spec_supplied =
245            !self.base_command.spec.is_empty() || self.base_command.spec_dir.is_some();
246        let verbatim = self.base_command.wafbench_verbatim;
247
248        let (templates, parser) = if verbatim {
249            let verbatim_templates = self.base_command.load_verbatim_templates()?;
250            if verbatim_templates.is_empty() {
251                return Err(BenchError::Other(
252                    "--wafbench-verbatim was set but no traffic cases were loaded. Check \
253                     --wafbench-dir points at a file, directory or glob containing cases with \
254                     a `request.uri`."
255                        .to_string(),
256                ));
257            }
258            TerminalReporter::print_success(&format!(
259                "Verbatim mode: {} request(s) will be sent exactly as written (spec endpoints not used)",
260                verbatim_templates.len()
261            ));
262            let parser = if spec_supplied {
263                TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
264                let merged_spec = self.base_command.load_and_merge_specs().await?;
265                TerminalReporter::print_success("Specification(s) loaded (base path only)");
266                SpecParser::from_spec(merged_spec)
267            } else {
268                SpecParser::from_spec(OpenApiSpec {
269                    spec: Default::default(),
270                    file_path: None,
271                    raw_document: None,
272                })
273            };
274            (verbatim_templates, parser)
275        } else {
276            TerminalReporter::print_progress("Loading OpenAPI specification(s)...");
277            let merged_spec = self.base_command.load_and_merge_specs().await?;
278            let parser = SpecParser::from_spec(merged_spec);
279            TerminalReporter::print_success("Specification(s) loaded");
280
281            let operations = if let Some(filter) = &self.base_command.operations {
282                parser.filter_operations(filter)?
283            } else {
284                parser.get_operations()
285            };
286
287            if operations.is_empty() {
288                return Err(BenchError::Other("No operations found in spec".to_string()));
289            }
290
291            TerminalReporter::print_success(&format!("Found {} operations", operations.len()));
292
293            TerminalReporter::print_progress("Generating request templates...");
294            let templates: Vec<_> = operations
295                .iter()
296                .map(RequestGenerator::generate_template)
297                .collect::<Result<Vec<_>>>()?;
298            TerminalReporter::print_success("Request templates generated");
299            (templates, parser)
300        };
301
302        // Pre-load per-target specs. In verbatim mode a per-target spec may
303        // still resolve --base-path, but it must not replace the traffic-file
304        // templates. That swap is how --targets-file kept fuzzing spec URLs
305        // after --wafbench-verbatim was added (#79).
306        let mut per_target_data: HashMap<
307            PathBuf,
308            (Vec<crate::request_gen::RequestTemplate>, Option<String>),
309        > = HashMap::new();
310        if !verbatim {
311            let mut unique_specs: Vec<PathBuf> = Vec::new();
312            for t in &self.targets {
313                if let Some(spec_path) = &t.spec {
314                    if !unique_specs.contains(spec_path) {
315                        unique_specs.push(spec_path.clone());
316                    }
317                }
318            }
319            for spec_path in &unique_specs {
320                TerminalReporter::print_progress(&format!(
321                    "Loading per-target spec: {}",
322                    spec_path.display()
323                ));
324                match SpecParser::from_file(spec_path).await {
325                    Ok(target_parser) => {
326                        let target_ops = if let Some(filter) = &self.base_command.operations {
327                            match target_parser.filter_operations(filter) {
328                                Ok(ops) => ops,
329                                Err(e) => {
330                                    TerminalReporter::print_warning(&format!(
331                                        "Failed to filter operations from {}: {}. Using shared spec.",
332                                        spec_path.display(),
333                                        e
334                                    ));
335                                    continue;
336                                }
337                            }
338                        } else {
339                            target_parser.get_operations()
340                        };
341                        let target_templates: Vec<_> = match target_ops
342                            .iter()
343                            .map(RequestGenerator::generate_template)
344                            .collect::<Result<Vec<_>>>()
345                        {
346                            Ok(t) => t,
347                            Err(e) => {
348                                TerminalReporter::print_warning(&format!(
349                                    "Failed to generate templates from {}: {}. Using shared spec.",
350                                    spec_path.display(),
351                                    e
352                                ));
353                                continue;
354                            }
355                        };
356                        let target_base_path = if let Some(cli_bp) = &self.base_command.base_path {
357                            if cli_bp.is_empty() {
358                                None
359                            } else {
360                                Some(cli_bp.clone())
361                            }
362                        } else {
363                            target_parser.get_base_path()
364                        };
365                        TerminalReporter::print_success(&format!(
366                            "Loaded {} operations from {}",
367                            target_templates.len(),
368                            spec_path.display()
369                        ));
370                        per_target_data
371                            .insert(spec_path.clone(), (target_templates, target_base_path));
372                    }
373                    Err(e) => {
374                        TerminalReporter::print_warning(&format!(
375                            "Failed to load per-target spec {}: {}. Targets using this spec will use the shared spec.",
376                            spec_path.display(),
377                            e
378                        ));
379                    }
380                }
381            }
382        }
383
384        // Parse base headers
385        let base_headers = self.base_command.parse_headers()?;
386
387        // Resolve base path (CLI option takes priority over spec's servers URL)
388        let base_path = self.resolve_base_path(&parser);
389        if let Some(ref bp) = base_path {
390            TerminalReporter::print_progress(&format!("Using base path: {}", bp));
391        }
392
393        // Parse scenario
394        let scenario = LoadScenario::from_str(&self.base_command.scenario)
395            .map_err(BenchError::InvalidScenario)?;
396
397        let duration_secs_val = BenchCommand::parse_duration(&self.base_command.duration)?;
398
399        // Round 63 (#79): batches of `--max-concurrency` each run the full
400        // `--duration`. Wall clock is ceil(targets / concurrency) * duration,
401        // not duration alone. --vus and --rps are per target, not shared.
402        let (batches, wall_secs) =
403            Self::estimated_wall_clock(total_targets, self.max_concurrency, duration_secs_val);
404        TerminalReporter::print_progress(&format!(
405            "Estimated wall clock: {batches} batch(es) × {duration_secs_val}s ≈ {} ({wall_secs}s). --vus and --rps are per target, not shared.",
406            Self::format_wall_clock(wall_secs),
407        ));
408
409        let security_testing_enabled_val = self.base_command.security_testing_enabled();
410
411        if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
412            TerminalReporter::print_progress(
413                "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
414            );
415        }
416
417        // Pre-compute enhancement code once (same for all targets)
418        let has_advanced_features = self.base_command.data_file.is_some()
419            || self.base_command.error_rate.is_some()
420            || self.base_command.security_testing_enabled()
421            || self.base_command.parallel_create.is_some();
422
423        let enhancement_code = if has_advanced_features {
424            let dummy_script = "export const options = {};";
425            let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
426            if let Some(pos) = enhanced.find("export const options") {
427                enhanced[..pos].to_string()
428            } else {
429                String::new()
430            }
431        } else {
432            String::new()
433        };
434
435        // Create semaphore for concurrency control
436        let semaphore = Arc::new(Semaphore::new(self.max_concurrency));
437        let multi_progress = MultiProgress::new();
438
439        // Create progress bars for each target
440        let progress_bars: Vec<ProgressBar> = (0..total_targets)
441            .map(|i| {
442                let pb = multi_progress.add(ProgressBar::new(1));
443                pb.set_style(
444                    ProgressStyle::default_bar()
445                        .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
446                        .unwrap(),
447                );
448                pb.set_message(format!("Target {}", i + 1));
449                pb
450            })
451            .collect();
452
453        // Spawn tasks for each target
454        let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
455
456        for (index, target) in self.targets.iter().enumerate() {
457            let target = target.clone();
458            // Clone necessary fields from base_command instead of passing reference
459            let duration = self.base_command.duration.clone();
460            let vus = self.base_command.vus;
461            let scenario_str = self.base_command.scenario.clone();
462            let operations = self.base_command.operations.clone();
463            let auth = self.base_command.auth.clone();
464            let headers = self.base_command.headers.clone();
465            let threshold_percentile = self.base_command.threshold_percentile.clone();
466            let threshold_ms = self.base_command.threshold_ms;
467            let max_error_rate = self.base_command.max_error_rate;
468            // Issue #79 r62 (Srikanth on 0.3.209): the round-60 abort valve
469            // stopped his WAF stress runs at ~2min because a legitimate high
470            // rejection rate crossed 0.95. Thread the opt-out/threshold through
471            // the per-target path so `--no-abort-on-error` reaches the k6 script.
472            let abort_on_error = self.base_command.abort_on_error;
473            let abort_on_error_rate = self.base_command.abort_on_error_rate;
474            let verbose = self.base_command.verbose;
475            let skip_tls_verify = self.base_command.skip_tls_verify;
476            let chunked_request_bodies = self.base_command.chunked_request_bodies;
477            let target_rps = self.base_command.target_rps;
478            let no_keep_alive = self.base_command.no_keep_alive;
479            // Issue #79 r54 (Srikanth on 0.3.200): `--source-ip` was silently
480            // dropped in multi-target (`--targets-file`) mode because this
481            // executor built `K6Executor::new()` without `.with_local_ips` and
482            // the per-target BenchCommand clone zeroed `source_ips`. Thread the
483            // source IPs (as the k6 `--local-ips` list) and geo config through
484            // to each target's run.
485            let local_ips = self.base_command.source_ips.join(",");
486            let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
487            let geo_source_ips = self.base_command.geo_source_ips.clone();
488            let geo_source_headers = self.base_command.geo_source_headers.clone();
489
490            // Select per-target templates/base_path if this target has a custom spec.
491            // Verbatim templates stay the traffic file's requests even when a
492            // target lists its own spec.
493            let (templates, base_path) = if verbatim {
494                (templates.clone(), base_path.clone())
495            } else if let Some(spec_path) = &target.spec {
496                if let Some((t, bp)) = per_target_data.get(spec_path) {
497                    (t.clone(), bp.clone())
498                } else {
499                    (templates.clone(), base_path.clone())
500                }
501            } else {
502                (templates.clone(), base_path.clone())
503            };
504
505            let base_headers = base_headers.clone();
506            let scenario = scenario.clone();
507            let duration_secs = duration_secs_val;
508            let base_output = self.base_output.clone();
509            let semaphore = semaphore.clone();
510            let progress_bar = progress_bars[index].clone();
511            let target_index = index;
512            let security_testing_enabled = security_testing_enabled_val;
513            let enhancement_code = enhancement_code.clone();
514
515            let handle = tokio::spawn(async move {
516                // Acquire semaphore permit
517                let _permit = semaphore.acquire().await.map_err(|e| {
518                    BenchError::Other(format!("Failed to acquire semaphore: {}", e))
519                })?;
520
521                progress_bar.set_message(format!("Testing {}", target.url));
522
523                // Execute test for this target
524                let result = Self::execute_single_target_internal(
525                    &duration,
526                    vus,
527                    &scenario_str,
528                    &operations,
529                    &auth,
530                    &headers,
531                    &threshold_percentile,
532                    threshold_ms,
533                    max_error_rate,
534                    abort_on_error,
535                    abort_on_error_rate,
536                    verbose,
537                    skip_tls_verify,
538                    base_path.as_ref(),
539                    &target,
540                    target_index,
541                    &templates,
542                    &base_headers,
543                    &scenario,
544                    duration_secs,
545                    &base_output,
546                    security_testing_enabled,
547                    chunked_request_bodies,
548                    target_rps,
549                    no_keep_alive,
550                    &enhancement_code,
551                    &local_ips,
552                    &dns_policy,
553                    &geo_source_ips,
554                    &geo_source_headers,
555                    verbatim,
556                )
557                .await;
558
559                progress_bar.inc(1);
560                progress_bar.finish_with_message(format!("Completed {}", target.url));
561
562                result
563            });
564
565            handles.push(handle);
566        }
567
568        // Wait for all tasks to complete and collect results
569        let mut target_results = Vec::new();
570        for (index, handle) in handles.into_iter().enumerate() {
571            match handle.await {
572                Ok(Ok(result)) => {
573                    target_results.push(result);
574                }
575                Ok(Err(e)) => {
576                    // Create error result
577                    let target_url = self.targets[index].url.clone();
578                    target_results.push(TargetResult {
579                        target_url: target_url.clone(),
580                        target_index: index,
581                        results: K6Results::default(),
582                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
583                        success: false,
584                        error: Some(e.to_string()),
585                    });
586                }
587                Err(e) => {
588                    // Join error
589                    let target_url = self.targets[index].url.clone();
590                    target_results.push(TargetResult {
591                        target_url: target_url.clone(),
592                        target_index: index,
593                        results: K6Results::default(),
594                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
595                        success: false,
596                        error: Some(format!("Task join error: {}", e)),
597                    });
598                }
599            }
600        }
601
602        // Sort results by target index
603        target_results.sort_by_key(|r| r.target_index);
604
605        // Calculate aggregated metrics
606        let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
607
608        let successful_targets = target_results.iter().filter(|r| r.success).count();
609        let failed_targets = total_targets - successful_targets;
610
611        Ok(AggregatedResults {
612            target_results,
613            total_targets,
614            successful_targets,
615            failed_targets,
616            aggregated_metrics,
617        })
618    }
619
620    /// Resolve the effective base path for API endpoints
621    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
622        // CLI option takes priority (including empty string to disable)
623        if let Some(cli_base_path) = &self.base_command.base_path {
624            if cli_base_path.is_empty() {
625                return None;
626            }
627            return Some(cli_base_path.clone());
628        }
629        // Fall back to spec's base path
630        parser.get_base_path()
631    }
632
633    /// Execute a single target test (internal method that doesn't require BenchCommand)
634    #[allow(clippy::too_many_arguments)]
635    async fn execute_single_target_internal(
636        _duration: &str,
637        vus: u32,
638        _scenario_str: &str,
639        _operations: &Option<String>,
640        auth: &Option<String>,
641        _headers: &[String],
642        threshold_percentile: &str,
643        threshold_ms: u64,
644        max_error_rate: f64,
645        abort_on_error: bool,
646        abort_on_error_rate: f64,
647        verbose: bool,
648        skip_tls_verify: bool,
649        base_path: Option<&String>,
650        target: &TargetConfig,
651        target_index: usize,
652        templates: &[crate::request_gen::RequestTemplate],
653        base_headers: &HashMap<String, String>,
654        scenario: &LoadScenario,
655        duration_secs: u64,
656        base_output: &Path,
657        security_testing_enabled: bool,
658        chunked_request_bodies: bool,
659        target_rps: Option<u32>,
660        no_keep_alive: bool,
661        enhancement_code: &str,
662        local_ips: &str,
663        dns_policy: &str,
664        geo_source_ips: &[String],
665        geo_source_headers: &[String],
666        wafbench_verbatim: bool,
667    ) -> Result<TargetResult> {
668        // Merge target-specific headers with base headers
669        let mut custom_headers = base_headers.clone();
670        if let Some(target_headers) = &target.headers {
671            custom_headers.extend(target_headers.clone());
672        }
673
674        // Round 63 (#79): keep Connection on the wire; force HTTP/1.1.
675        let force_http1 = crate::request_gen::should_force_k6_http1(
676            wafbench_verbatim,
677            templates,
678            &custom_headers,
679        );
680
681        // Use target-specific auth if provided, otherwise use base auth
682        let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
683
684        // Create k6 config for this target
685        let k6_config = K6Config {
686            target_url: target.url.clone(),
687            base_path: base_path.cloned(),
688            scenario: scenario.clone(),
689            duration_secs,
690            max_vus: vus,
691            threshold_percentile: threshold_percentile.to_string(),
692            threshold_ms,
693            max_error_rate,
694            auth_header,
695            custom_headers,
696            skip_tls_verify,
697            security_testing_enabled,
698            chunked_request_bodies,
699            target_rps,
700            no_keep_alive,
701            geo_source_ips: geo_source_ips.to_vec(),
702            geo_source_headers: geo_source_headers.to_vec(),
703        };
704
705        // Generate k6 script
706        let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
707            .with_abort_valve(abort_on_error, abort_on_error_rate)
708            .with_force_http1(force_http1);
709        let mut script = generator.generate()?;
710
711        // Apply pre-computed enhancement code (security definitions, etc.)
712        if !enhancement_code.is_empty() {
713            if let Some(pos) = script.find("export const options") {
714                script.insert_str(pos, enhancement_code);
715            }
716        }
717
718        // Validate script
719        let validation_errors = K6ScriptGenerator::validate_script(&script);
720        if !validation_errors.is_empty() {
721            return Err(BenchError::Other(format!(
722                "Script validation failed for target {}: {}",
723                target.url,
724                validation_errors.join(", ")
725            )));
726        }
727
728        // Create output directory for this target
729        let output_dir = base_output.join(format!("target_{}", target_index + 1));
730        std::fs::create_dir_all(&output_dir)?;
731
732        // Write script to file
733        let script_path = output_dir.join("k6-script.js");
734        std::fs::write(&script_path, script)?;
735
736        // Execute k6 with its own REST API server port per target. k6 defaults
737        // to localhost:6565 and parallel instances collide on it.
738        //
739        // Issue #79 r58 — Srikanth on 0.3.205: every one of 10 targets failed
740        // with `exit status: 106` (k6 `CannotStartRESTAPI`) and 0 requests, so
741        // NO load ran at all (which read as "--discard-response-bodies isn't
742        // helping"). The old scheme pinned each k6 to a FIXED port
743        // (6565 + index → 6566, 6567, ...), which collides whenever those
744        // ports are already taken — e.g. orphaned k6 processes left behind by
745        // a previous run that was OOM-killed (his r56/r57 SIGKILL), a second
746        // concurrent bench, or any local service. Bind an OS-assigned
747        // ephemeral port (`:0`) instead: k6 asks the kernel for a free port at
748        // start, so it can never be "already in use". We never query the REST
749        // API (results come from summary.json on disk), so a random port is
750        // fine. Verified: two concurrent k6 with `--address localhost:0` both
751        // start cleanly where the fixed port yields exit 106.
752        let api_port = 0; // ephemeral: kernel picks a free port, no collision
753                          // Issue #79 r54 — forward `--source-ip` (as k6 `--local-ips`) so each
754                          // target's VUs rotate through the configured source IP pool. Without
755                          // this, `--source-ip` was a silent no-op in multi-target mode.
756                          // Issue #79 r56 — plain multi-target load only checks status codes, so
757                          // discard response bodies to keep k6's RSS bounded on long, high-VU runs.
758                          // Without this, 10+ targets * 10 VUs * 70 min buffered every body and the
759                          // kernel OOM-killer sent k6 `signal: 9 (SIGKILL)`.
760        let executor = K6Executor::new()?
761            .with_local_ips(local_ips.to_string())
762            .with_dns_policy(dns_policy.to_string())
763            .with_discard_response_bodies(true)
764            .with_force_http1(force_http1);
765        let results = executor
766            .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
767            .await;
768
769        match results {
770            Ok(k6_results) => Ok(TargetResult {
771                target_url: target.url.clone(),
772                target_index,
773                results: k6_results,
774                output_dir,
775                success: true,
776                error: None,
777            }),
778            Err(e) => Ok(TargetResult {
779                target_url: target.url.clone(),
780                target_index,
781                results: K6Results::default(),
782                output_dir,
783                success: false,
784                error: Some(e.to_string()),
785            }),
786        }
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    #[test]
795    fn test_aggregated_metrics_from_results() {
796        let results = vec![
797            TargetResult {
798                target_url: "http://api1.com".to_string(),
799                target_index: 0,
800                results: K6Results {
801                    total_requests: 100,
802                    failed_requests: 5,
803                    avg_duration_ms: 100.0,
804                    p95_duration_ms: 200.0,
805                    p99_duration_ms: 300.0,
806                    ..Default::default()
807                },
808                output_dir: PathBuf::from("output1"),
809                success: true,
810                error: None,
811            },
812            TargetResult {
813                target_url: "http://api2.com".to_string(),
814                target_index: 1,
815                results: K6Results {
816                    total_requests: 200,
817                    failed_requests: 10,
818                    avg_duration_ms: 150.0,
819                    p95_duration_ms: 250.0,
820                    p99_duration_ms: 350.0,
821                    ..Default::default()
822                },
823                output_dir: PathBuf::from("output2"),
824                success: true,
825                error: None,
826            },
827        ];
828
829        let metrics = AggregatedMetrics::from_results(&results);
830        assert_eq!(metrics.total_requests, 300);
831        assert_eq!(metrics.total_failed_requests, 15);
832        assert_eq!(metrics.avg_duration_ms, 125.0); // (100 + 150) / 2
833    }
834
835    #[test]
836    fn test_aggregated_metrics_with_failed_targets() {
837        let results = vec![
838            TargetResult {
839                target_url: "http://api1.com".to_string(),
840                target_index: 0,
841                results: K6Results {
842                    total_requests: 100,
843                    failed_requests: 5,
844                    avg_duration_ms: 100.0,
845                    p95_duration_ms: 200.0,
846                    p99_duration_ms: 300.0,
847                    ..Default::default()
848                },
849                output_dir: PathBuf::from("output1"),
850                success: true,
851                error: None,
852            },
853            TargetResult {
854                target_url: "http://api2.com".to_string(),
855                target_index: 1,
856                results: K6Results::default(),
857                output_dir: PathBuf::from("output2"),
858                success: false,
859                error: Some("Network error".to_string()),
860            },
861        ];
862
863        let metrics = AggregatedMetrics::from_results(&results);
864        // Only successful target should be counted
865        assert_eq!(metrics.total_requests, 100);
866        assert_eq!(metrics.total_failed_requests, 5);
867        assert_eq!(metrics.avg_duration_ms, 100.0);
868    }
869
870    #[test]
871    fn test_aggregated_metrics_empty_results() {
872        let results = vec![];
873        let metrics = AggregatedMetrics::from_results(&results);
874        assert_eq!(metrics.total_requests, 0);
875        assert_eq!(metrics.total_failed_requests, 0);
876        assert_eq!(metrics.avg_duration_ms, 0.0);
877        assert_eq!(metrics.error_rate, 0.0);
878    }
879
880    #[test]
881    fn test_aggregated_metrics_error_rate_calculation() {
882        let results = vec![TargetResult {
883            target_url: "http://api1.com".to_string(),
884            target_index: 0,
885            results: K6Results {
886                total_requests: 1000,
887                failed_requests: 50,
888                avg_duration_ms: 100.0,
889                p95_duration_ms: 200.0,
890                p99_duration_ms: 300.0,
891                ..Default::default()
892            },
893            output_dir: PathBuf::from("output1"),
894            success: true,
895            error: None,
896        }];
897
898        let metrics = AggregatedMetrics::from_results(&results);
899        assert_eq!(metrics.error_rate, 5.0); // 50/1000 * 100
900    }
901
902    #[test]
903    fn test_aggregated_metrics_p95_p99_calculation() {
904        let results = vec![
905            TargetResult {
906                target_url: "http://api1.com".to_string(),
907                target_index: 0,
908                results: K6Results {
909                    total_requests: 100,
910                    failed_requests: 0,
911                    avg_duration_ms: 100.0,
912                    p95_duration_ms: 150.0,
913                    p99_duration_ms: 200.0,
914                    ..Default::default()
915                },
916                output_dir: PathBuf::from("output1"),
917                success: true,
918                error: None,
919            },
920            TargetResult {
921                target_url: "http://api2.com".to_string(),
922                target_index: 1,
923                results: K6Results {
924                    total_requests: 100,
925                    failed_requests: 0,
926                    avg_duration_ms: 200.0,
927                    p95_duration_ms: 250.0,
928                    p99_duration_ms: 300.0,
929                    ..Default::default()
930                },
931                output_dir: PathBuf::from("output2"),
932                success: true,
933                error: None,
934            },
935            TargetResult {
936                target_url: "http://api3.com".to_string(),
937                target_index: 2,
938                results: K6Results {
939                    total_requests: 100,
940                    failed_requests: 0,
941                    avg_duration_ms: 300.0,
942                    p95_duration_ms: 350.0,
943                    p99_duration_ms: 400.0,
944                    ..Default::default()
945                },
946                output_dir: PathBuf::from("output3"),
947                success: true,
948                error: None,
949            },
950        ];
951
952        let metrics = AggregatedMetrics::from_results(&results);
953        // p95 should be the 95th percentile of [150, 250, 350] = index 2 = 350
954        // p99 should be the 99th percentile of [200, 300, 400] = index 2 = 400
955        assert_eq!(metrics.p95_duration_ms, 350.0);
956        assert_eq!(metrics.p99_duration_ms, 400.0);
957    }
958
959    #[test]
960    fn estimated_wall_clock_is_batches_times_duration() {
961        // Srikanth: 64 IPs, --max-concurrency 10, -d 300 → 7 batches × 300s.
962        let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
963        assert_eq!(batches, 7);
964        assert_eq!(wall, 2100);
965        assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
966        assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
967        assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
968        assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
969        assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
970    }
971
972    #[test]
973    fn parallel_k6_spawn_sets_force_http1() {
974        let src = include_str!("parallel_executor.rs");
975        assert!(
976            src.contains("with_force_http1(force_http1)"),
977            "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
978        );
979        assert!(
980            src.contains("Estimated wall clock"),
981            "multi-target start must print ceil(targets/concurrency)*duration"
982        );
983    }
984}