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, wall_secs) =
432            Self::estimated_wall_clock(total_targets, max_concurrency, duration_secs_val);
433        TerminalReporter::print_progress(&format!(
434            "Estimated wall clock: {batches} batch(es) × {duration_secs_val}s ≈ {} ({wall_secs}s). --vus and --rps are per target, not shared.",
435            Self::format_wall_clock(wall_secs),
436        ));
437
438        let security_testing_enabled_val = self.base_command.security_testing_enabled();
439
440        if crate::request_gen::should_force_k6_http1(verbatim, &templates, &base_headers) {
441            TerminalReporter::print_progress(
442                "Forcing HTTP/1.1 (GODEBUG=http2client=0): Connection headers are hop-by-hop and HTTP/2 rejects them. The header stays on the wire.",
443            );
444        }
445
446        // Pre-compute enhancement code once (same for all targets)
447        let has_advanced_features = self.base_command.data_file.is_some()
448            || self.base_command.error_rate.is_some()
449            || self.base_command.security_testing_enabled()
450            || self.base_command.parallel_create.is_some();
451
452        let enhancement_code = if has_advanced_features {
453            let dummy_script = "export const options = {};";
454            let enhanced = self.base_command.generate_enhanced_script(dummy_script)?;
455            if let Some(pos) = enhanced.find("export const options") {
456                enhanced[..pos].to_string()
457            } else {
458                String::new()
459            }
460        } else {
461            String::new()
462        };
463
464        // Create semaphore for concurrency control
465        let semaphore = Arc::new(Semaphore::new(max_concurrency));
466        let multi_progress = MultiProgress::new();
467
468        // Create progress bars for each target
469        let progress_bars: Vec<ProgressBar> = (0..total_targets)
470            .map(|i| {
471                let pb = multi_progress.add(ProgressBar::new(1));
472                pb.set_style(
473                    ProgressStyle::default_bar()
474                        .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}")
475                        .unwrap(),
476                );
477                pb.set_message(format!("Target {}", i + 1));
478                pb
479            })
480            .collect();
481
482        // Spawn tasks for each target
483        let mut handles: Vec<JoinHandle<Result<TargetResult>>> = Vec::new();
484
485        for (index, target) in self.targets.iter().enumerate() {
486            let target = target.clone();
487            // Clone necessary fields from base_command instead of passing reference
488            let duration = self.base_command.duration.clone();
489            let vus = self.base_command.vus;
490            let scenario_str = self.base_command.scenario.clone();
491            let operations = self.base_command.operations.clone();
492            let auth = self.base_command.auth.clone();
493            let headers = self.base_command.headers.clone();
494            let threshold_percentile = self.base_command.threshold_percentile.clone();
495            let threshold_ms = self.base_command.threshold_ms;
496            let max_error_rate = self.base_command.max_error_rate;
497            // Issue #79 r62 (Srikanth on 0.3.209): the round-60 abort valve
498            // stopped his WAF stress runs at ~2min because a legitimate high
499            // rejection rate crossed 0.95. Thread the opt-out/threshold through
500            // the per-target path so `--no-abort-on-error` reaches the k6 script.
501            let abort_on_error = self.base_command.abort_on_error;
502            let abort_on_error_rate = self.base_command.abort_on_error_rate;
503            let verbose = self.base_command.verbose;
504            let skip_tls_verify = self.base_command.skip_tls_verify;
505            let chunked_request_bodies = self.base_command.chunked_request_bodies;
506            let target_rps = self.base_command.target_rps;
507            let no_keep_alive = self.base_command.no_keep_alive;
508            // Issue #79 r54 (Srikanth on 0.3.200): `--source-ip` was silently
509            // dropped in multi-target (`--targets-file`) mode because this
510            // executor built `K6Executor::new()` without `.with_local_ips` and
511            // the per-target BenchCommand clone zeroed `source_ips`. Thread the
512            // source IPs (as the k6 `--local-ips` list) and geo config through
513            // to each target's run.
514            let local_ips = self.base_command.source_ips.join(",");
515            let dns_policy = self.base_command.dns_policy.clone().unwrap_or_default();
516            let geo_source_ips = self.base_command.geo_source_ips.clone();
517            let geo_source_headers = self.base_command.geo_source_headers.clone();
518
519            // Select per-target templates/base_path if this target has a custom spec.
520            // Verbatim templates stay the traffic file's requests even when a
521            // target lists its own spec.
522            let (templates, base_path) = if verbatim {
523                (templates.clone(), base_path.clone())
524            } else if let Some(spec_path) = &target.spec {
525                if let Some((t, bp)) = per_target_data.get(spec_path) {
526                    (t.clone(), bp.clone())
527                } else {
528                    (templates.clone(), base_path.clone())
529                }
530            } else {
531                (templates.clone(), base_path.clone())
532            };
533
534            let base_headers = base_headers.clone();
535            let scenario = scenario.clone();
536            let duration_secs = duration_secs_val;
537            let base_output = self.base_output.clone();
538            let semaphore = semaphore.clone();
539            let progress_bar = progress_bars[index].clone();
540            let target_index = index;
541            let security_testing_enabled = security_testing_enabled_val;
542            let enhancement_code = enhancement_code.clone();
543
544            let handle = tokio::spawn(async move {
545                // Acquire semaphore permit
546                let _permit = semaphore.acquire().await.map_err(|e| {
547                    BenchError::Other(format!("Failed to acquire semaphore: {}", e))
548                })?;
549
550                progress_bar.set_message(format!("Testing {}", target.url));
551
552                // Execute test for this target
553                let result = Self::execute_single_target_internal(
554                    &duration,
555                    vus,
556                    &scenario_str,
557                    &operations,
558                    &auth,
559                    &headers,
560                    &threshold_percentile,
561                    threshold_ms,
562                    max_error_rate,
563                    abort_on_error,
564                    abort_on_error_rate,
565                    per_op_metrics,
566                    verbose,
567                    skip_tls_verify,
568                    base_path.as_ref(),
569                    &target,
570                    target_index,
571                    &templates,
572                    &base_headers,
573                    &scenario,
574                    duration_secs,
575                    &base_output,
576                    security_testing_enabled,
577                    chunked_request_bodies,
578                    target_rps,
579                    no_keep_alive,
580                    &enhancement_code,
581                    &local_ips,
582                    &dns_policy,
583                    &geo_source_ips,
584                    &geo_source_headers,
585                    verbatim,
586                )
587                .await;
588
589                progress_bar.inc(1);
590                progress_bar.finish_with_message(format!("Completed {}", target.url));
591
592                result
593            });
594
595            handles.push(handle);
596        }
597
598        // Wait for all tasks to complete and collect results
599        let mut target_results = Vec::new();
600        for (index, handle) in handles.into_iter().enumerate() {
601            match handle.await {
602                Ok(Ok(result)) => {
603                    target_results.push(result);
604                }
605                Ok(Err(e)) => {
606                    // Create error result
607                    let target_url = self.targets[index].url.clone();
608                    target_results.push(TargetResult {
609                        target_url: target_url.clone(),
610                        target_index: index,
611                        results: K6Results::default(),
612                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
613                        success: false,
614                        error: Some(e.to_string()),
615                    });
616                }
617                Err(e) => {
618                    // Join error
619                    let target_url = self.targets[index].url.clone();
620                    target_results.push(TargetResult {
621                        target_url: target_url.clone(),
622                        target_index: index,
623                        results: K6Results::default(),
624                        output_dir: self.base_output.join(format!("target_{}", index + 1)),
625                        success: false,
626                        error: Some(format!("Task join error: {}", e)),
627                    });
628                }
629            }
630        }
631
632        // Sort results by target index
633        target_results.sort_by_key(|r| r.target_index);
634
635        // Calculate aggregated metrics
636        let aggregated_metrics = AggregatedMetrics::from_results(&target_results);
637
638        let successful_targets = target_results.iter().filter(|r| r.success).count();
639        let failed_targets = total_targets - successful_targets;
640
641        Ok(AggregatedResults {
642            target_results,
643            total_targets,
644            successful_targets,
645            failed_targets,
646            aggregated_metrics,
647        })
648    }
649
650    /// Resolve the effective base path for API endpoints
651    fn resolve_base_path(&self, parser: &SpecParser) -> Option<String> {
652        // CLI option takes priority (including empty string to disable)
653        if let Some(cli_base_path) = &self.base_command.base_path {
654            if cli_base_path.is_empty() {
655                return None;
656            }
657            return Some(cli_base_path.clone());
658        }
659        // Fall back to spec's base path
660        parser.get_base_path()
661    }
662
663    /// Execute a single target test (internal method that doesn't require BenchCommand)
664    #[allow(clippy::too_many_arguments)]
665    async fn execute_single_target_internal(
666        _duration: &str,
667        vus: u32,
668        _scenario_str: &str,
669        _operations: &Option<String>,
670        auth: &Option<String>,
671        _headers: &[String],
672        threshold_percentile: &str,
673        threshold_ms: u64,
674        max_error_rate: f64,
675        abort_on_error: bool,
676        abort_on_error_rate: f64,
677        per_op_metrics: bool,
678        verbose: bool,
679        skip_tls_verify: bool,
680        base_path: Option<&String>,
681        target: &TargetConfig,
682        target_index: usize,
683        templates: &[crate::request_gen::RequestTemplate],
684        base_headers: &HashMap<String, String>,
685        scenario: &LoadScenario,
686        duration_secs: u64,
687        base_output: &Path,
688        security_testing_enabled: bool,
689        chunked_request_bodies: bool,
690        target_rps: Option<u32>,
691        no_keep_alive: bool,
692        enhancement_code: &str,
693        local_ips: &str,
694        dns_policy: &str,
695        geo_source_ips: &[String],
696        geo_source_headers: &[String],
697        wafbench_verbatim: bool,
698    ) -> Result<TargetResult> {
699        // Merge target-specific headers with base headers
700        let mut custom_headers = base_headers.clone();
701        if let Some(target_headers) = &target.headers {
702            custom_headers.extend(target_headers.clone());
703        }
704
705        // Round 63 (#79): keep Connection on the wire; force HTTP/1.1.
706        let force_http1 = crate::request_gen::should_force_k6_http1(
707            wafbench_verbatim,
708            templates,
709            &custom_headers,
710        );
711
712        // Use target-specific auth if provided, otherwise use base auth
713        let auth_header = target.auth.as_ref().or(auth.as_ref()).cloned();
714
715        // Create k6 config for this target
716        let k6_config = K6Config {
717            target_url: target.url.clone(),
718            base_path: base_path.cloned(),
719            scenario: scenario.clone(),
720            duration_secs,
721            max_vus: vus,
722            threshold_percentile: threshold_percentile.to_string(),
723            threshold_ms,
724            max_error_rate,
725            auth_header,
726            custom_headers,
727            skip_tls_verify,
728            security_testing_enabled,
729            chunked_request_bodies,
730            target_rps,
731            no_keep_alive,
732            geo_source_ips: geo_source_ips.to_vec(),
733            geo_source_headers: geo_source_headers.to_vec(),
734        };
735
736        // Generate k6 script
737        let generator = K6ScriptGenerator::new(k6_config, templates.to_vec())
738            .with_abort_valve(abort_on_error, abort_on_error_rate)
739            .with_force_http1(force_http1)
740            .with_per_op_metrics(per_op_metrics);
741        let mut script = generator.generate()?;
742
743        // Apply pre-computed enhancement code (security definitions, etc.)
744        if !enhancement_code.is_empty() {
745            if let Some(pos) = script.find("export const options") {
746                script.insert_str(pos, enhancement_code);
747            }
748        }
749
750        // Validate script
751        let validation_errors = K6ScriptGenerator::validate_script(&script);
752        if !validation_errors.is_empty() {
753            return Err(BenchError::Other(format!(
754                "Script validation failed for target {}: {}",
755                target.url,
756                validation_errors.join(", ")
757            )));
758        }
759
760        // Create output directory for this target
761        let output_dir = base_output.join(format!("target_{}", target_index + 1));
762        std::fs::create_dir_all(&output_dir)?;
763
764        // Write script to file
765        let script_path = output_dir.join("k6-script.js");
766        std::fs::write(&script_path, script)?;
767
768        // Execute k6 with its own REST API server port per target. k6 defaults
769        // to localhost:6565 and parallel instances collide on it.
770        //
771        // Issue #79 r58 — Srikanth on 0.3.205: every one of 10 targets failed
772        // with `exit status: 106` (k6 `CannotStartRESTAPI`) and 0 requests, so
773        // NO load ran at all (which read as "--discard-response-bodies isn't
774        // helping"). The old scheme pinned each k6 to a FIXED port
775        // (6565 + index → 6566, 6567, ...), which collides whenever those
776        // ports are already taken — e.g. orphaned k6 processes left behind by
777        // a previous run that was OOM-killed (his r56/r57 SIGKILL), a second
778        // concurrent bench, or any local service. Bind an OS-assigned
779        // ephemeral port (`:0`) instead: k6 asks the kernel for a free port at
780        // start, so it can never be "already in use". We never query the REST
781        // API (results come from summary.json on disk), so a random port is
782        // fine. Verified: two concurrent k6 with `--address localhost:0` both
783        // start cleanly where the fixed port yields exit 106.
784        let api_port = 0; // ephemeral: kernel picks a free port, no collision
785                          // Issue #79 r54 — forward `--source-ip` (as k6 `--local-ips`) so each
786                          // target's VUs rotate through the configured source IP pool. Without
787                          // this, `--source-ip` was a silent no-op in multi-target mode.
788                          // Issue #79 r56 — plain multi-target load only checks status codes, so
789                          // discard response bodies to keep k6's RSS bounded on long, high-VU runs.
790                          // Without this, 10+ targets * 10 VUs * 70 min buffered every body and the
791                          // kernel OOM-killer sent k6 `signal: 9 (SIGKILL)`.
792        let executor = K6Executor::new()?
793            .with_local_ips(local_ips.to_string())
794            .with_dns_policy(dns_policy.to_string())
795            .with_discard_response_bodies(true)
796            .with_force_http1(force_http1);
797        let results = executor
798            .execute_with_port(&script_path, Some(&output_dir), verbose, Some(api_port))
799            .await;
800
801        match results {
802            Ok(k6_results) => Ok(TargetResult {
803                target_url: target.url.clone(),
804                target_index,
805                results: k6_results,
806                output_dir,
807                success: true,
808                error: None,
809            }),
810            Err(e) => Ok(TargetResult {
811                target_url: target.url.clone(),
812                target_index,
813                results: K6Results::default(),
814                output_dir,
815                success: false,
816                error: Some(e.to_string()),
817            }),
818        }
819    }
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825
826    #[test]
827    fn test_aggregated_metrics_from_results() {
828        let results = vec![
829            TargetResult {
830                target_url: "http://api1.com".to_string(),
831                target_index: 0,
832                results: K6Results {
833                    total_requests: 100,
834                    failed_requests: 5,
835                    avg_duration_ms: 100.0,
836                    p95_duration_ms: 200.0,
837                    p99_duration_ms: 300.0,
838                    ..Default::default()
839                },
840                output_dir: PathBuf::from("output1"),
841                success: true,
842                error: None,
843            },
844            TargetResult {
845                target_url: "http://api2.com".to_string(),
846                target_index: 1,
847                results: K6Results {
848                    total_requests: 200,
849                    failed_requests: 10,
850                    avg_duration_ms: 150.0,
851                    p95_duration_ms: 250.0,
852                    p99_duration_ms: 350.0,
853                    ..Default::default()
854                },
855                output_dir: PathBuf::from("output2"),
856                success: true,
857                error: None,
858            },
859        ];
860
861        let metrics = AggregatedMetrics::from_results(&results);
862        assert_eq!(metrics.total_requests, 300);
863        assert_eq!(metrics.total_failed_requests, 15);
864        assert_eq!(metrics.avg_duration_ms, 125.0); // (100 + 150) / 2
865    }
866
867    #[test]
868    fn test_aggregated_metrics_with_failed_targets() {
869        let results = vec![
870            TargetResult {
871                target_url: "http://api1.com".to_string(),
872                target_index: 0,
873                results: K6Results {
874                    total_requests: 100,
875                    failed_requests: 5,
876                    avg_duration_ms: 100.0,
877                    p95_duration_ms: 200.0,
878                    p99_duration_ms: 300.0,
879                    ..Default::default()
880                },
881                output_dir: PathBuf::from("output1"),
882                success: true,
883                error: None,
884            },
885            TargetResult {
886                target_url: "http://api2.com".to_string(),
887                target_index: 1,
888                results: K6Results::default(),
889                output_dir: PathBuf::from("output2"),
890                success: false,
891                error: Some("Network error".to_string()),
892            },
893        ];
894
895        let metrics = AggregatedMetrics::from_results(&results);
896        // Only successful target should be counted
897        assert_eq!(metrics.total_requests, 100);
898        assert_eq!(metrics.total_failed_requests, 5);
899        assert_eq!(metrics.avg_duration_ms, 100.0);
900    }
901
902    #[test]
903    fn test_aggregated_metrics_empty_results() {
904        let results = vec![];
905        let metrics = AggregatedMetrics::from_results(&results);
906        assert_eq!(metrics.total_requests, 0);
907        assert_eq!(metrics.total_failed_requests, 0);
908        assert_eq!(metrics.avg_duration_ms, 0.0);
909        assert_eq!(metrics.error_rate, 0.0);
910    }
911
912    #[test]
913    fn test_aggregated_metrics_error_rate_calculation() {
914        let results = vec![TargetResult {
915            target_url: "http://api1.com".to_string(),
916            target_index: 0,
917            results: K6Results {
918                total_requests: 1000,
919                failed_requests: 50,
920                avg_duration_ms: 100.0,
921                p95_duration_ms: 200.0,
922                p99_duration_ms: 300.0,
923                ..Default::default()
924            },
925            output_dir: PathBuf::from("output1"),
926            success: true,
927            error: None,
928        }];
929
930        let metrics = AggregatedMetrics::from_results(&results);
931        assert_eq!(metrics.error_rate, 5.0); // 50/1000 * 100
932    }
933
934    #[test]
935    fn test_aggregated_metrics_p95_p99_calculation() {
936        let results = vec![
937            TargetResult {
938                target_url: "http://api1.com".to_string(),
939                target_index: 0,
940                results: K6Results {
941                    total_requests: 100,
942                    failed_requests: 0,
943                    avg_duration_ms: 100.0,
944                    p95_duration_ms: 150.0,
945                    p99_duration_ms: 200.0,
946                    ..Default::default()
947                },
948                output_dir: PathBuf::from("output1"),
949                success: true,
950                error: None,
951            },
952            TargetResult {
953                target_url: "http://api2.com".to_string(),
954                target_index: 1,
955                results: K6Results {
956                    total_requests: 100,
957                    failed_requests: 0,
958                    avg_duration_ms: 200.0,
959                    p95_duration_ms: 250.0,
960                    p99_duration_ms: 300.0,
961                    ..Default::default()
962                },
963                output_dir: PathBuf::from("output2"),
964                success: true,
965                error: None,
966            },
967            TargetResult {
968                target_url: "http://api3.com".to_string(),
969                target_index: 2,
970                results: K6Results {
971                    total_requests: 100,
972                    failed_requests: 0,
973                    avg_duration_ms: 300.0,
974                    p95_duration_ms: 350.0,
975                    p99_duration_ms: 400.0,
976                    ..Default::default()
977                },
978                output_dir: PathBuf::from("output3"),
979                success: true,
980                error: None,
981            },
982        ];
983
984        let metrics = AggregatedMetrics::from_results(&results);
985        // p95 should be the 95th percentile of [150, 250, 350] = index 2 = 350
986        // p99 should be the 99th percentile of [200, 300, 400] = index 2 = 400
987        assert_eq!(metrics.p95_duration_ms, 350.0);
988        assert_eq!(metrics.p99_duration_ms, 400.0);
989    }
990
991    #[test]
992    fn estimated_wall_clock_is_batches_times_duration() {
993        // Srikanth: 64 IPs, --max-concurrency 10, -d 300 → 7 batches × 300s.
994        let (batches, wall) = ParallelExecutor::estimated_wall_clock(64, 10, 300);
995        assert_eq!(batches, 7);
996        assert_eq!(wall, 2100);
997        assert_eq!(ParallelExecutor::format_wall_clock(2100), "35m00s");
998        assert_eq!(ParallelExecutor::estimated_wall_clock(10, 10, 300), (1, 300));
999        assert_eq!(ParallelExecutor::estimated_wall_clock(0, 10, 300), (0, 0));
1000        assert_eq!(ParallelExecutor::format_wall_clock(45), "45s");
1001        assert_eq!(ParallelExecutor::format_wall_clock(3661), "1h01m01s");
1002    }
1003
1004    #[test]
1005    fn parallel_k6_spawn_sets_force_http1() {
1006        let src = include_str!("parallel_executor.rs");
1007        assert!(
1008            src.contains("with_force_http1(force_http1)"),
1009            "multi-target k6 spawn must pass GODEBUG=http2client=0 when Connection headers are present"
1010        );
1011        assert!(
1012            src.contains("Estimated wall clock"),
1013            "multi-target start must print ceil(targets/concurrency)*duration"
1014        );
1015        assert!(
1016            src.contains("with_per_op_metrics(per_op_metrics)"),
1017            "multi-target k6 spawn must apply Round-65 per-op metrics collapse (#79)"
1018        );
1019        assert!(
1020            src.contains("resolve_max_concurrency"),
1021            "multi-target must auto-cap concurrency for huge specs (#79)"
1022        );
1023    }
1024}