Skip to main content

tropel_engine/
engine.rs

1use crate::input::{resolve_input_or_driver, ResolvedInput};
2use crate::outputs::spawn_extension_output;
3use crate::pacing::parse_duration_str;
4use crate::summary::build_summary_data;
5use crate::vu_loop::{run_driver_vus, run_scenario_vus};
6use crate::worker::VUWorkerPool;
7
8/// One entry of the per-scenario config list threaded into `run_scenario_vus`.
9struct ScenarioConfigEntry {
10    name: String,
11    execution: ExecutionConfig,
12    env: HashMap<String, String>,
13    tags: HashMap<String, String>,
14    start_delay: Duration,
15    input_path: String,
16    exec: Option<String>,
17}
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::{Duration, Instant};
21use tokio::sync::broadcast;
22use tropel_core::config::{ExecutionConfig, JobConfig, OutputConfig, ScenarioConfig};
23use tropel_ext::registry::ExtensionRegistry;
24use tropel_metrics::collector::MetricsCollector;
25use tropel_metrics::thresholds::validate_thresholds;
26use tropel_report::{
27    create_reporter, InfluxdbOutput, JsonStreamOutput, OtlpOutput, PrometheusRemoteWriteOutput,
28    Reporter, StatsdOutput, StreamingStdoutOutput, TagPolicy,
29};
30use tropel_sdk::traits::Protocol;
31use tropel_sdk::types::Sample;
32use tropel_sdk::{Result, TropelError};
33
34/// Capacity of the streaming-output broadcast ring. Sized for ~2.5s of
35/// samples at ~100k samples/s so consumer stalls don't drop live data
36/// (see the ring construction in `Engine::run`).
37const SAMPLE_STREAM_CAPACITY: usize = 1 << 18; // 262_144
38
39/// The engine orchestrates a complete load test job.
40pub struct Engine {
41    extension_registry: ExtensionRegistry,
42    /// TR-309: optional periodic-snapshot sink. Distributed agents set this
43    /// so the controller sees progress mid-run instead of being blind until
44    /// the engine completes. `None` → the single-node path (no periodic
45    /// snapshot task spawned).
46    snapshot_tx: Option<tokio::sync::mpsc::Sender<tropel_metrics::collector::MetricsSnapshot>>,
47}
48
49impl Engine {
50    pub fn new(registry: ExtensionRegistry) -> Self {
51        Self {
52            extension_registry: registry,
53            snapshot_tx: None,
54        }
55    }
56
57    /// TR-309: enable periodic snapshots to the given channel. The engine
58    /// spawns a task that calls `metrics.snapshot()` on the aggregator every
59    /// 2 s (the single-node flush cadence) and forwards it.
60    pub fn with_snapshot_sink(
61        mut self,
62        tx: tokio::sync::mpsc::Sender<tropel_metrics::collector::MetricsSnapshot>,
63    ) -> Self {
64        self.snapshot_tx = Some(tx);
65        self
66    }
67
68    pub async fn run(&self, config: &JobConfig) -> Result<EngineResult> {
69        // Forget any unit declarations from a previous run in this process
70        // (backlog line 32) — the registry must never leak across runs.
71        tropel_metrics::time_metrics::clear();
72        tropel_metrics::OUTPUT_SAMPLES_DROPPED.store(0, std::sync::atomic::Ordering::Relaxed);
73        tropel_metrics::AGGREGATOR_SAMPLES_DROPPED.store(0, std::sync::atomic::Ordering::Relaxed);
74
75        let registry = Arc::new(self.extension_registry.clone());
76        let format_hint = config.input_type.clone();
77        let metrics = Arc::new(MetricsCollector::new());
78
79        // Latency histogram ceiling (None = auto-resize, no clipping). Applied
80        // before any samples are recorded so every MetricSet uses it.
81        metrics
82            .set_histogram_max(config.http.histogram_max_ms)
83            .await;
84
85        // TR-309: if the agent set a snapshot channel, spawn a periodic task
86        // that polls the aggregator every 2 s (the single-node flush cadence)
87        // and forwards the snapshot. The task runs on the current runtime.
88        if let Some(snapshot_tx) = self.snapshot_tx.clone() {
89            let metrics_clone = metrics.clone();
90            tokio::spawn(async move {
91                let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
92                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
93                loop {
94                    interval.tick().await;
95                    let snap = metrics_clone.snapshot().await;
96                    if snapshot_tx.send(snap).await.is_err() {
97                        // Receiver dropped (run finished) — stop.
98                        break;
99                    }
100                }
101            });
102        }
103
104        let num_workers = std::thread::available_parallelism()
105            .map(|n| n.get())
106            .unwrap_or(4);
107        let pool = Arc::new(VUWorkerPool::new(num_workers));
108        tracing::info!(
109            "VU worker pool: {} threads (available cores: {})",
110            num_workers,
111            num_workers
112        );
113
114        let mut http_config = config.http.clone();
115        let mut tls_config = config.tls.clone();
116        let mut thresholds = config.thresholds.clone();
117        // One shared read-only copy of the iteration dataset; every VU clones
118        // the Arc (not the Vec of rows), so memory is O(dataset) not
119        // O(VUs × dataset). Rows are only cloned individually when an
120        // iteration actually consumes one.
121        let data_rows = std::sync::Arc::new(config.iteration_data.clone());
122        let test_start = Instant::now();
123
124        // Script-declared options (k6 `export const options`). The safety
125        // controls — thresholds, DNS, hosts, blacklist, rps,
126        // discardResponseBodies, summaryTrendStats — are merged ALWAYS, even
127        // when the load profile comes from the CLI (the standard CI shape:
128        // thresholds in the script, --vus/--duration on the CLI; the old gate
129        // skipped the whole block and every script safety control evaporated).
130        // Only the script's own load profile (execution/scenarios) is gated
131        // on execution_explicit so explicit CLI/config profiles win.
132        let script_load_profile_allowed = !config.execution_explicit && config.scenarios.is_empty();
133        let mut declared_scenarios: Option<HashMap<String, ScenarioConfig>> = None;
134        let mut declared_execution: Option<ExecutionConfig> = None;
135        let mut declared_trend_stats: Option<Vec<String>> = None;
136        // TR-313: read the script ONCE, share the bytes across the
137        // declared_options resolution AND the scenario-loop resolution
138        // (which used to re-read the file independently). The bytes are
139        // also cached for the scenario tasks.
140        let script_bytes = std::fs::read(&config.input).ok();
141        let script_bytes_ref = script_bytes.as_deref();
142        {
143            let input_path = std::path::Path::new(&config.input);
144            if let (Some(bytes), Ok(ResolvedInput::Driver(driver))) = (
145                script_bytes_ref,
146                resolve_input_or_driver(
147                    &config.input,
148                    config.input_type.as_deref(),
149                    &registry,
150                    &config.env,
151                    script_bytes_ref,
152                ),
153            ) {
154                // Backlog line 153: a script that DECLARES malformed options
155                // (e.g. a type mismatch in `stages`) aborts the run instead of
156                // silently falling back to the CLI profile — k6 hard-errors.
157                let declared = driver
158                    .declared_options(bytes, Some(input_path), &config.env)
159                    .await?;
160                if let Some(decl) = declared {
161                    // Script-declared global body handling (k6
162                    // `options.discardResponseBodies`) applies to the HTTP
163                    // client when the job didn't set one explicitly.
164                    if let Some(discard) = decl.discard_response_bodies {
165                        http_config.discard_response_bodies = discard;
166                        tracing::info!(
167                            "Script-declared discardResponseBodies={} applied to HTTP client",
168                            discard
169                        );
170                    }
171                    // Script-declared summary trend stats (k6
172                    // `options.summaryTrendStats`) configure the summary.
173                    if let Some(stats) = decl.summary_trend_stats {
174                        if !stats.is_empty() {
175                            tracing::info!(
176                                "Script-declared summaryTrendStats applied: {:?}",
177                                stats
178                            );
179                            declared_trend_stats = Some(stats);
180                        }
181                    }
182                    // Script-declared HTTP/DNS options (k6 `options.dns`,
183                    // `noConnectionReuse`, `rps`, `hosts`, `blacklistIPs`)
184                    // fold into the HTTP client config.
185                    if let Some(ttl) = decl.dns_ttl {
186                        http_config.dns_ttl = Some(ttl);
187                    }
188                    if let Some(sel) = decl.dns_select {
189                        http_config.dns_select = Some(sel);
190                    }
191                    if let Some(pol) = decl.dns_policy {
192                        http_config.dns_policy = Some(pol);
193                    }
194                    if let Some(no) = decl.no_connection_reuse {
195                        http_config.no_connection_reuse = no;
196                    }
197                    if let Some(no) = decl.no_vu_connection_reuse {
198                        http_config.no_vu_connection_reuse = no;
199                    }
200                    if let Some(rps) = decl.rps {
201                        http_config.rps = Some(rps);
202                    }
203                    if let Some(hosts) = decl.hosts {
204                        http_config.hosts = hosts;
205                    }
206                    if let Some(bl) = decl.blacklist_ips {
207                        http_config.blacklist_ips = bl;
208                    }
209                    // Script-declared TLS verification skip (k6
210                    // `options.insecureSkipTLSVerify`) — the most common
211                    // staging idiom. Applied to the HTTP client's TLS config;
212                    // like the sibling script-declared HTTP/DNS options, a
213                    // declared value takes precedence over the config-file
214                    // default (CLI has no --insecure flag).
215                    if let Some(skip) = decl.insecure_skip_tls_verify {
216                        tls_config.insecure_skip_verify = skip;
217                        tracing::info!(
218                            "Script-declared insecureSkipTLSVerify={} applied to TLS config",
219                            skip
220                        );
221                    }
222                    if http_config.dns_ttl.is_some()
223                        || http_config.dns_select.is_some()
224                        || http_config.dns_policy.is_some()
225                        || http_config.no_connection_reuse
226                        || http_config.rps.is_some()
227                        || !http_config.hosts.is_empty()
228                        || !http_config.blacklist_ips.is_empty()
229                    {
230                        tracing::info!("Script-declared DNS/HTTP options applied");
231                    }
232                    // Merge script-declared thresholds (CLI/config keys win on
233                    // collision — CLI keys are "threshold_N", so no clash).
234                    // Skip when --no-thresholds is set.
235                    if !config.no_thresholds {
236                        for (k, v) in &decl.thresholds {
237                            thresholds.entry(k.clone()).or_insert_with(|| v.clone());
238                        }
239                    }
240                    if script_load_profile_allowed {
241                        if let Some(scs) = decl.scenarios {
242                            if !scs.is_empty() {
243                                tracing::info!(
244                                    "Using script-declared scenarios: {}",
245                                    scs.keys().cloned().collect::<Vec<_>>().join(", ")
246                                );
247                                declared_scenarios = Some(scs);
248                            }
249                        } else if let Some(exec) = decl.execution {
250                            tracing::info!("Using script-declared execution: {:?}", exec);
251                            declared_execution = Some(exec);
252                        }
253                    } else if decl.scenarios.is_some() || decl.execution.is_some() {
254                        // Explicit CLI/config profile wins; the script's own
255                        // load profile is intentionally ignored (its safety
256                        // options above are still applied).
257                        tracing::debug!(
258                            "CLI/config load profile wins — script-declared load profile ignored"
259                        );
260                    }
261                }
262            }
263        }
264
265        // Fail closed at startup (k6 behavior): a malformed threshold
266        // expression must abort the run with a clear config error BEFORE any
267        // load is generated — never silently pass at the end (the old
268        // evaluator returned `(true, …)` for unparseable input, so a typo'd
269        // metric or a bogus operator reported green).
270        validate_thresholds(&thresholds).map_err(TropelError::Config)?;
271
272        // Global RPS limiter (k6 `options.rps`): created ONCE per run and
273        // shared by every VU across every scenario, so the cap is global.
274        let rps_limiter: Option<Arc<tropel_http::RpsLimiter>> = http_config
275            .rps
276            .map(|r| Arc::new(tropel_http::RpsLimiter::new(r)));
277        if rps_limiter.is_some() {
278            tracing::info!(
279                "Global RPS cap: {} req/s (shared across all VUs)",
280                http_config.rps.unwrap_or(0.0)
281            );
282        }
283
284        // Streaming outputs. Size the broadcast ring to comfortably cover the
285        // expected sample rate: a 10k buffer held only ~100ms of samples at
286        // ~100k samples/s, so ANY consumer stall longer than that dropped
287        // live samples (streaming outputs were lossy by design). 2^18 slots
288        // hold ~2.5s of peak-rate samples — short consumer hiccups (GC, a
289        // flush that batches 10k) no longer lose data. Each slot is a small
290        // fixed-size struct (the tags HashMap is heap-allocated), so the
291        // preallocated ring is a few tens of MB worst case.
292        let mut output_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
293
294        // TR-313: allocate the ~2^18-slot broadcast ring ONLY when a
295        // streaming output will actually consume it. The old code allocated
296        // it unconditionally then set the sink to `None` when no output
297        // existed — the 24 MiB ring was wasted on every run with no output.
298        let has_stdout = config.output.reporters.iter().any(|r| r == "stdout");
299        let prometheus_via_extension = config.output.reporters.iter().any(|r| r == "prometheus")
300            && self
301                .extension_registry
302                .list_outputs()
303                .iter()
304                .any(|o| o == "prometheus");
305        let has_streaming_output = has_stdout
306            || (config.output.prometheus_remote_write_url.is_some() && !prometheus_via_extension)
307            || config.output.otlp_endpoint.is_some()
308            || config.output.json_stream.is_some()
309            || config.output.statsd_addr.is_some()
310            || config.output.influxdb_addr.is_some()
311            || config.output.reporters.iter().any(|r| {
312                create_reporter(r, None).is_none()
313                    && self.extension_registry.get_output(r).is_some()
314            });
315
316        let sample_tx = if has_streaming_output {
317            let (tx, _) = broadcast::channel::<Sample>(SAMPLE_STREAM_CAPACITY);
318            metrics.set_sample_sink(Some(tx.clone()));
319            Some(tx)
320        } else {
321            metrics.set_sample_sink(None);
322            None
323        };
324
325        // Planned wall-clock length of the run (incl. grace) for the live
326        // progress bar's 100% target. Resolved with the SAME precedence the
327        // scenario_configs below use (declared scenarios → config scenarios →
328        // declared/single execution), so the bar fills to the right total.
329        let progress_total: Option<Duration> = {
330            let consider = |exec: &ExecutionConfig, start: Duration| -> Option<Duration> {
331                exec.total_duration().map(|d| d + start)
332            };
333            // Note: `.flatten().max()` skips unbounded scenarios (None) — a
334            // run mixing a bounded and an externally-controlled scenario
335            // targets the longest bounded end while the unbounded one keeps
336            // running (the bar then just stays at 100% elapsed-only).
337            if let Some(scs) = &declared_scenarios {
338                scs.values()
339                    .filter_map(|sc| {
340                        let start = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
341                        consider(&sc.execution, start)
342                    })
343                    .max()
344            } else if !config.scenarios.is_empty() {
345                config
346                    .scenarios
347                    .values()
348                    .filter_map(|sc| {
349                        let start = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
350                        consider(&sc.execution, start)
351                    })
352                    .max()
353            } else {
354                consider(
355                    declared_execution.as_ref().unwrap_or(&config.execution),
356                    Duration::ZERO,
357                )
358            }
359        };
360
361        let has_stdout = config.output.reporters.iter().any(|r| r == "stdout");
362        if has_stdout {
363            let rx = sample_tx
364                .as_ref()
365                .expect("sample_tx exists when stdout is enabled")
366                .subscribe();
367            let handle = StreamingStdoutOutput::spawn(rx, progress_total);
368            output_handles.push(handle);
369        }
370        // Shared tag-forwarding policy for the network outputs: bounds label
371        // cardinality at the backend (allowlist + per-sample cap).
372        let tag_policy = TagPolicy {
373            allowlist: config.output.tag_allowlist.clone(),
374            max_tags: config.output.max_tags_per_sample,
375        };
376
377        // Prometheus remote-write and OTLP outputs (streaming, best-effort).
378        // When the user drives prometheus through the extension output
379        // (`--reporter prometheus`), the extension handles it — skip the
380        // built-in path so samples are not pushed twice. Only skip when the
381        // extension output is actually registered: a custom binary built
382        // without tropel-x-prometheus must not silently lose its stream.
383        if let Some(url) = &config.output.prometheus_remote_write_url {
384            if !prometheus_via_extension {
385                let rx = sample_tx
386                    .as_ref()
387                    .expect("sample_tx exists when prometheus is enabled")
388                    .subscribe();
389                let handle =
390                    PrometheusRemoteWriteOutput::spawn(rx, url.clone(), tag_policy.clone());
391                output_handles.push(handle);
392            }
393        }
394        if let Some(endpoint) = &config.output.otlp_endpoint {
395            let rx = sample_tx
396                .as_ref()
397                .expect("sample_tx exists when otlp is enabled")
398                .subscribe();
399            let handle = OtlpOutput::spawn(rx, endpoint.clone(), tag_policy.clone());
400            output_handles.push(handle);
401        }
402        // JSON-stream (NDJSON file) output.
403        if let Some(path) = &config.output.json_stream {
404            let rx = sample_tx
405                .as_ref()
406                .expect("sample_tx exists when json-stream is enabled")
407                .subscribe();
408            let handle = JsonStreamOutput::spawn(rx, path.clone());
409            output_handles.push(handle);
410        }
411        // StatsD / Datadog output (UDP datagrams).
412        if let Some(addr) = &config.output.statsd_addr {
413            let rx = sample_tx
414                .as_ref()
415                .expect("sample_tx exists when statsd is enabled")
416                .subscribe();
417            let handle = StatsdOutput::spawn(rx, addr.clone(), tag_policy.clone());
418            output_handles.push(handle);
419        }
420        // InfluxDB output (line protocol over UDP).
421        if let Some(addr) = &config.output.influxdb_addr {
422            let rx = sample_tx
423                .as_ref()
424                .expect("sample_tx exists when influxdb is enabled")
425                .subscribe();
426            let handle = InfluxdbOutput::spawn(rx, addr.clone(), tag_policy.clone());
427            output_handles.push(handle);
428        }
429        // Registered extension outputs: any configured reporter name that
430        // resolves to an extension output (e.g. the `prometheus` reference
431        // extension) is driven from the sample stream — emit() per batch,
432        // flush() when the stream closes. This replaces the old
433        // "extension reporter not supported" dead end.
434        for name in &config.output.reporters {
435            if create_reporter(name, None).is_some() {
436                continue; // built-in reporters handled above / at the end
437            }
438            if let Some(mut ext) = self.extension_registry.get_output(name) {
439                ext.configure(&config.output);
440                let rx = sample_tx
441                    .as_ref()
442                    .expect("sample_tx exists when an extension output is enabled")
443                    .subscribe();
444                let handle = spawn_extension_output(rx, ext);
445                output_handles.push(handle);
446            }
447        }
448        // Build scenario configs. Script-declared scenarios/execution (from a
449        // k6 `export const options`) take precedence over the default profile
450        // but not over explicit user config (execution_explicit check above).
451        let scenario_configs: Vec<ScenarioConfigEntry> = if let Some(scs) = declared_scenarios {
452            scs.iter()
453                .map(|(name, sc)| {
454                    let start_delay = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
455                    let input_path = sc.input.clone().unwrap_or_else(|| config.input.clone());
456                    ScenarioConfigEntry {
457                        name: name.clone(),
458                        execution: sc.execution.clone(),
459                        env: sc.env.clone(),
460                        tags: sc.tags.clone(),
461                        start_delay,
462                        input_path,
463                        exec: sc.exec.clone(),
464                    }
465                })
466                .collect()
467        } else if !config.scenarios.is_empty() {
468            config
469                .scenarios
470                .iter()
471                .map(|(name, sc)| {
472                    let start_delay = parse_duration_str(&sc.start_time).unwrap_or(Duration::ZERO);
473                    let input_path = sc.input.clone().unwrap_or_else(|| config.input.clone());
474                    ScenarioConfigEntry {
475                        name: name.clone(),
476                        execution: sc.execution.clone(),
477                        env: sc.env.clone(),
478                        tags: sc.tags.clone(),
479                        start_delay,
480                        input_path,
481                        exec: sc.exec.clone(),
482                    }
483                })
484                .collect()
485        } else {
486            let exec = declared_execution.unwrap_or_else(|| config.execution.clone());
487            vec![ScenarioConfigEntry {
488                name: "default".to_string(),
489                execution: exec,
490                env: HashMap::new(),
491                tags: HashMap::new(),
492                start_delay: Duration::ZERO,
493                input_path: config.input.clone(),
494                exec: None,
495            }]
496        };
497
498        // Apply the execution segment (k6 executionSegment /
499        // executionSegmentSequence): each scenario's workload is scaled
500        // deterministically to this node's share [from, to). An invalid
501        // segment spec is a hard config error — better to fail before any
502        // VU starts than to run the full workload on every node.
503        let segment = match &config.execution_segment {
504            Some(spec) => match tropel_core::segment::ExecutionSegment::parse(
505                spec,
506                config.execution_segment_sequence.as_deref(),
507            ) {
508                Ok(seg) => {
509                    tracing::info!(
510                        "Execution segment [{:.3}, {:.3}) — running {:.1}% of the workload",
511                        seg.from(),
512                        seg.to(),
513                        seg.fraction() * 100.0
514                    );
515                    Some(seg)
516                }
517                Err(e) => return Err(e),
518            },
519            None => None,
520        };
521
522        // TR-221: express this node's share of an ARRIVAL-RATE workload by
523        // striping the global iteration sequence (k6's model) instead of by
524        // scaling the rate. Scaling gives every node its own independent rate
525        // from its own t=0, so N nodes' arrivals BUNCH — three nodes at
526        // 50/25/25 all fire together every 200 ms instead of filling a 50 ms
527        // cadence. Striping skips the global ticks this segment doesn't own,
528        // so the merged sequence is indistinguishable from one node at the
529        // full rate (`constant_arrival_rate.go:316-330`).
530        //
531        // This needs the FULL sequence: a lone segment cannot be striped
532        // consistently, because each node would fill the missing pieces
533        // differently and two nodes would claim the same global ticks. k6 has
534        // the same requirement — that is what `executionSegmentSequence` is
535        // for. Without it we fall back to rate scaling and say so.
536        let mut arrival_stripe: Option<tropel_core::segment::ArrivalStripe> = None;
537        let mut rate_mode = tropel_core::segment::RateSegmentation::ScaleRate;
538        if let Some(seg) = &segment {
539            match config.execution_segment_sequence.as_deref() {
540                Some(seq_spec) => {
541                    let seq = tropel_core::segment::ExecutionSegmentSequence::parse(seq_spec)?;
542                    // `ExecutionSegment::parse` already rejected a segment
543                    // that is not a consecutive pair of this sequence, so the
544                    // lookup cannot miss — but a silent mis-index would put
545                    // the node on another node's stripe, so it errors.
546                    let idx = seq.index_of(seg).ok_or_else(|| {
547                        tropel_sdk::TropelError::Config(format!(
548                            "execution segment [{:.6}, {:.6}) is not a segment of \
549                             execution_segment_sequence '{seq_spec}'",
550                            seg.from(),
551                            seg.to()
552                        ))
553                    })?;
554                    let wrapper = tropel_core::segment::ExecutionSegmentSequenceWrapper::new(&seq)?;
555                    let (start, offsets, lcd) = wrapper.get_striped_offsets(idx);
556                    tracing::info!(
557                        "TR-221: arrival-rate striping active — segment {} of {} owns global \
558                         tick {start}, then offsets {offsets:?}, repeating every {lcd} ticks. \
559                         Arrival rates stay GLOBAL; the stripe is the segment share.",
560                        idx + 1,
561                        seq.0.len()
562                    );
563                    arrival_stripe = Some(wrapper.arrival_stripe(idx));
564                    rate_mode = tropel_core::segment::RateSegmentation::GlobalRate;
565                }
566                None => {
567                    tracing::warn!(
568                        "execution_segment was given without execution_segment_sequence — \
569                         arrival-rate scenarios fall back to per-node rate scaling, so arrivals \
570                         BUNCH instead of interleaving across nodes (TR-221). Pass the same \
571                         --execution-segment-sequence to every node to interleave."
572                    );
573                }
574            }
575        }
576
577        let scenario_configs: Vec<ScenarioConfigEntry> = scenario_configs
578            .into_iter()
579            .map(|mut sc| {
580                if let Some(seg) = &segment {
581                    sc.execution = seg.apply_with(&sc.execution, rate_mode);
582                }
583                sc
584            })
585            .collect();
586
587        // Apply summary presentation config (trend stats + effective
588        // thresholds) to the collector BEFORE the VU loop starts. The 2 s
589        // abort coordinator polls `results()` mid-run (backlog line 59a);
590        // when the config only arrived after the loop, `retain_histograms`
591        // was false for EVERY mid-run `abortOnFail` evaluation, so a
592        // non-tracked `p(75)` fell back to the nearest tracked bucket (p90)
593        // and healthy runs were aborted. Setting it here means the exact
594        // histograms are retained for the whole run.
595        let summary_trend_stats =
596            declared_trend_stats.unwrap_or_else(tropel_metrics::collector::k6_default_trend_stats);
597        metrics
598            .set_summary_config(summary_trend_stats, thresholds.clone())
599            .await;
600
601        tracing::info!(
602            "Starting Tropel load test: {} scenario(s)",
603            scenario_configs.len()
604        );
605
606        // TR-505: effective VU reporting — warn at startup when the MAX_WORKERS cap
607        // (10 000) or cgroup pids limit will reduce concurrency.
608        let peak_requested: u64 = {
609            fn peak_for_exec(exec: &ExecutionConfig) -> u64 {
610                match exec {
611                    ExecutionConfig::ConstantVus { vus, .. } => *vus as u64,
612                    ExecutionConfig::RampingVus {
613                        stages, start_vus, ..
614                    } => {
615                        let max_stage = stages.iter().map(|s| s.target as u64).max().unwrap_or(0);
616                        (*start_vus as u64).max(max_stage)
617                    }
618                    ExecutionConfig::ConstantArrivalRate { max_vus, .. } => *max_vus as u64,
619                    ExecutionConfig::RampingArrivalRate { max_vus, .. } => *max_vus as u64,
620                    ExecutionConfig::SharedIterations { vus, .. } => *vus as u64,
621                    ExecutionConfig::PerVUIterations { vus, .. } => *vus as u64,
622                    ExecutionConfig::ExternallyControlled { max_vus, .. } => *max_vus as u64,
623                }
624            }
625            // Concurrent scenarios run together — worst-case is sum of peaks.
626            // Sequential scenarios (staggered start_time) would over-estimate,
627            // but a startup warning that is slightly eager is safer than one
628            // that misses a real cap.
629            scenario_configs
630                .iter()
631                .map(|sc| peak_for_exec(&sc.execution))
632                .sum()
633        };
634        let effective = VUWorkerPool::effective_concurrency(peak_requested);
635        // Computed ONCE here and carried to the reporters on `MetricsResult`
636        // (`effective_vus_reason`). `tropel-report` sits below this crate and
637        // cannot read `VUWorkerPool::MAX_WORKERS`, so any cap named there is a
638        // second copy that goes stale — it already did, printing
639        // `MAX_WORKERS=4096` for the whole life of the 10 000 worker pool.
640        let mut effective_reason: Option<String> = None;
641        if peak_requested > effective {
642            let pids = VUWorkerPool::pids_limit();
643            let reason = if pids
644                .is_some_and(|lim| lim < peak_requested && lim < VUWorkerPool::MAX_WORKERS as u64)
645            {
646                format!(
647                    "cgroup pids.max={} (Docker --pids-limit / Kubernetes pids.max)",
648                    pids.unwrap()
649                )
650            } else {
651                format!("MAX_WORKERS={}", VUWorkerPool::MAX_WORKERS)
652            };
653            effective_reason = Some(reason.clone());
654            tracing::warn!(
655                "TR-505: requested {} VUs but only {} effective ({}). Co-located VUs share a single-threaded runtime and block each other — throughput will be that of {} VUs.",
656                peak_requested, effective, reason, effective
657            );
658        } else if peak_requested > 0 {
659            tracing::info!(
660                "TR-505: requested {} VUs, effective {} (MAX_WORKERS={}, pids={})",
661                peak_requested,
662                effective,
663                VUWorkerPool::MAX_WORKERS,
664                VUWorkerPool::pids_limit()
665                    .map(|v| v.to_string())
666                    .unwrap_or_else(|| "unlimited".to_string())
667            );
668        }
669
670        let mut scenario_handles = Vec::new();
671
672        for sc in &scenario_configs {
673            let sc_name = sc.name.clone();
674            let exec_cfg = sc.execution.clone();
675            let sc_env = sc.env.clone();
676            let sc_tags = sc.tags.clone();
677            let input_path = sc.input_path.clone();
678            let sc_exec = sc.exec.clone();
679            let start_delay = sc.start_delay;
680            let metrics = metrics.clone();
681            let pool = pool.clone();
682            let http_cfg = http_config.clone();
683            let tls_cfg = tls_config.clone();
684            let thresholds = thresholds.clone();
685            let data_rows = data_rows.clone();
686            let base_env = config.env.clone();
687            let registry_sc = registry.clone();
688            let fmt_hint = format_hint.clone();
689            let control_port = config.control_port;
690            let rps_limiter_sc = rps_limiter.clone();
691            // TR-313: the scenario task reuses the bytes read at startup
692            // instead of re-reading the script file.
693            let script_bytes_sc = script_bytes.clone();
694            // TR-221: each scenario's arrival-rate executor walks its OWN
695            // cursor over the global sequence (k6 gives every executor its
696            // own SegmentedIndex), so clone rather than share.
697            let arrival_stripe_sc = arrival_stripe.clone();
698
699            let handle = tokio::spawn(async move {
700                let resolved = resolve_input_or_driver(
701                    &input_path,
702                    fmt_hint.as_deref(),
703                    &registry_sc,
704                    &base_env,
705                    script_bytes_sc.as_deref(),
706                );
707                let resolved = match resolved {
708                    Ok(r) => r,
709                    Err(e) => {
710                        tracing::error!(
711                            "Scenario '{}': failed to resolve input '{}': {}",
712                            sc_name,
713                            input_path,
714                            e
715                        );
716                        return (0, 0, None);
717                    }
718                };
719
720                let sc_name_log = sc_name.clone();
721                // Instantiate every registered protocol once per scenario and
722                // share the scheme-keyed map across VUs so ANY non-HTTP URL
723                // scheme (`grpc://`, `ws://`, or a third-party one) dispatches
724                // to its registered protocol (the runner's scheme lookup).
725                let protocols: Arc<HashMap<String, Arc<dyn Protocol>>> =
726                    Arc::new(registry_sc.instantiate_protocols());
727                let (init_failures, script_failures, abort_message) = match resolved {
728                    ResolvedInput::Scenario(scenario, format_id) => {
729                        run_scenario_vus(
730                            sc_name,
731                            start_delay,
732                            sc_env,
733                            sc_tags,
734                            base_env,
735                            exec_cfg,
736                            scenario,
737                            metrics,
738                            pool,
739                            http_cfg,
740                            tls_cfg,
741                            thresholds,
742                            data_rows,
743                            test_start,
744                            protocols,
745                            control_port,
746                            rps_limiter_sc,
747                            &input_path,
748                            // TR-501: the resolving adapter's id decides
749                            // which shims each VU materialises.
750                            &format_id,
751                            arrival_stripe_sc,
752                        )
753                        .await
754                    }
755                    ResolvedInput::Driver(driver) => {
756                        run_driver_vus(
757                            sc_name,
758                            start_delay,
759                            sc_env,
760                            sc_tags,
761                            base_env,
762                            exec_cfg,
763                            sc_exec,
764                            driver,
765                            metrics,
766                            pool,
767                            http_cfg,
768                            tls_cfg,
769                            thresholds,
770                            data_rows,
771                            test_start,
772                            &input_path,
773                            registry_sc,
774                            control_port,
775                            rps_limiter_sc,
776                            // Backlog line 230: the driver path now gets the
777                            // same registry-instantiated protocol map as the
778                            // declarative path (clone — the Scenario arm
779                            // moves the original).
780                            protocols.clone(),
781                            arrival_stripe_sc,
782                        )
783                        .await
784                    }
785                };
786
787                tracing::info!("Scenario '{}': completed", sc_name_log);
788                (init_failures, script_failures, abort_message)
789            });
790
791            scenario_handles.push(handle);
792        }
793
794        // Any VU that failed to START (e.g. WASM driver pool exhaustion)
795        // means the requested load was not delivered. Count them so the CLI
796        // can fail the run loudly AFTER the summary/teardown run — returning
797        // Err here would skip the end-of-run reporting (and the output
798        // teardown) exactly when the user most needs to see what DID run.
799        // Script failures (backlog line 98) are counted the same way: a run
800        // where every script throws must exit non-zero.
801        let mut total_vu_init_failures = 0u32;
802        let mut total_script_failures = 0u64;
803        let mut total_abort_message: Option<String> = None;
804        for handle in scenario_handles {
805            match handle.await {
806                Ok((init_failures, script_failures, abort_message)) => {
807                    total_vu_init_failures += init_failures;
808                    total_script_failures += script_failures;
809                    // First VU to abort wins; later scenarios are idempotent.
810                    if abort_message.is_some() {
811                        total_abort_message = total_abort_message.or(abort_message);
812                    }
813                }
814                // P0 (backlog): a PANICKED scenario task (e.g. worker-pool
815                // thread/runtime creation failing under fd exhaustion) used
816                // to be silently discarded by `if let Ok` — the run printed
817                // a green summary from partial data and exited 0. Count it
818                // as a VU init failure so the run fails loudly instead.
819                Err(join_err) => {
820                    tracing::error!("Scenario task panicked: {join_err}");
821                    total_vu_init_failures = total_vu_init_failures.saturating_add(1);
822                }
823            }
824        }
825
826        // The broadcast channel only closes when ALL senders drop. The
827        // collector drops its clone in `set_sample_sink(None)`, but our
828        // local `sample_tx` was alive until the end of this function — so
829        // the streaming output task never saw `RecvError::Closed` and the
830        // `handle.await` below hung, in EVERY run (the 0-req runs merely
831        // exposed it most visibly). Dropping it here terminates the
832        // output tasks and lets the run finish.
833        metrics.set_sample_sink(None);
834        drop(sample_tx);
835        for handle in output_handles {
836            match handle.await {
837                Ok(()) => {}
838                Err(e) => {
839                    if e.is_panic() {
840                        tracing::error!("Output task panicked: {e} — some samples may be lost");
841                    }
842                }
843            }
844        }
845
846        // Raw snapshot (build_results now clones the summary config into
847        // every result, so ordering no longer matters — captured here only
848        // for the lossless distributed merge). Distributed agents ship this
849        // to a controller; single-node runs never consume it, so skip the
850        // serialize cost.
851        let snapshot = if config.distributed_worker {
852            metrics.snapshot().await
853        } else {
854            tropel_metrics::collector::MetricsSnapshot::default()
855        };
856        let mut results = metrics.results().await;
857        // Stamp the wall-clock run duration so reporters can emit k6-style
858        // per-second rates (`http_reqs: 136 13.56/s`) — k6's Counter summary
859        // carries `rate` = count / elapsed seconds (backlog line 154).
860        results.run_duration = test_start.elapsed();
861        // TR-105: stamp the failure counters onto the result BEFORE any
862        // reporter renders, so the stdout banner's PASS/FAIL verdict uses the
863        // same numbers the CLI exit code will (vu_init_failures / script_
864        // failures were only known to the CLI — the banner printed PASS on
865        // runs that exited 1).
866        results.vu_init_failures = total_vu_init_failures;
867        results.script_failures = total_script_failures;
868        // TR-505: stamp requested vs effective so the summary can report the gap
869        // between what was asked and what the worker pool can deliver (4096 cap /
870        // pids.max). This is the cheapest honesty win — a run requesting 10k
871        // must not print "10 000 VUs" when it delivered 4 096.
872        results.requested_vus = peak_requested;
873        results.effective_vus = effective;
874        results.effective_vus_reason = effective_reason.clone();
875
876        // Distributed workers (`tropel-agent`) skip ALL end-of-run output —
877        // the controller owns the summary, handleSummary, and reporters —
878        // and only ship their raw snapshot back for central merging.
879        if !config.distributed_worker {
880            let reporters = self.create_reporters(&config.output);
881            for reporter in &reporters {
882                if let Err(e) = reporter.report(&results).await {
883                    tracing::error!("Reporter '{}' failed: {} — continuing with remaining reporters and handleSummary", reporter.name(), e);
884                }
885            }
886
887            // k6 `handleSummary(data)`: let the script emit custom summaries
888            // (JSON/HTML/JUnit). Runs after the run with the aggregated data;
889            // returned files are written (`stdout` prints). Falls back to
890            // `--summary-export` when the script declares no handleSummary.
891            emit_handle_summary(config, &registry, &results, &thresholds, test_start).await;
892        }
893
894        Ok(EngineResult {
895            metrics: results,
896            snapshot,
897            // The effective threshold set — CLI/config thresholds merged with
898            // any script-declared (k6 options) thresholds. The CLI reports
899            // against THIS set so k6 SLOs appear in the end-of-run summary,
900            // not just mid-run abort checks.
901            effective_thresholds: thresholds,
902            // Number of VUs that failed to START (driver init / HTTP client
903            // creation). Non-zero means the requested load was NOT delivered;
904            // the CLI treats this as a hard failure (non-zero exit) after the
905            // summary prints.
906            vu_init_failures: total_vu_init_failures,
907            // Number of script executions (prerequest/test/driver iteration)
908            // that errored during the run. Non-zero means scripts kept
909            // failing; the CLI exits non-zero (backlog line 98).
910            script_failures: total_script_failures,
911            // TR-244: `exec.test.abort(msg?)` message, if any — the CLI maps
912            // it to k6's exit code 108.
913            abort_message: total_abort_message,
914        })
915    }
916
917    fn create_reporters(&self, config: &OutputConfig) -> Vec<Box<dyn Reporter>> {
918        let mut reporters: Vec<Box<dyn Reporter>> = Vec::new();
919        for name in &config.reporters {
920            if let Some(reporter) = create_reporter(name, config.output_file.as_deref()) {
921                reporters.push(reporter);
922            } else if self
923                .extension_registry
924                .list_outputs()
925                .iter()
926                .any(|o| o == name)
927            {
928                // Extension outputs are driven from the sample stream during
929                // the run (see Engine::run) — they are not end-of-run
930                // reporters, so there is nothing to create here.
931                tracing::debug!("Extension output '{}' driven as a streaming output", name);
932            } else {
933                tracing::warn!("Unknown reporter: {}", name);
934            }
935        }
936        reporters
937    }
938
939    pub fn extension_registry(&self) -> &ExtensionRegistry {
940        &self.extension_registry
941    }
942}
943
944impl Default for Engine {
945    fn default() -> Self {
946        Self::new(ExtensionRegistry::new())
947    }
948}
949
950// handleSummary(data) — script-emitted custom summaries
951
952/// Invoke the script's `handleSummary(data)` (k6) after the run and
953/// write the returned files (`stdout` key prints to stdout). When the
954/// script declares no handleSummary, honor `--summary-export` by
955/// writing the default summary data object as JSON. Best-effort — a
956/// failing script summary never fails the run.
957///
958/// Public so the distributed controller can emit the same summaries from
959/// its MERGED result (previously `report_and_thresholds` only ran
960/// stdout/json/csv and silently dropped summary_export and handleSummary).
961pub async fn emit_handle_summary(
962    config: &JobConfig,
963    registry: &ExtensionRegistry,
964    results: &tropel_metrics::collector::MetricsResult,
965    thresholds: &HashMap<String, tropel_core::config::ThresholdConfig>,
966    test_start: Instant,
967) {
968    let summary_value = build_summary_data(results, thresholds, test_start);
969    let summary_json = serde_json::to_string(&summary_value).unwrap_or_default();
970
971    // Resolve a driver for the input (k6 scripts declare handleSummary).
972    // If no driver resolves (e.g. a Postman/HAR declarative collection),
973    // there is no script to call — fall through to --summary-export.
974    let input_path = std::path::Path::new(&config.input);
975    let bytes = std::fs::read(&config.input).ok();
976    let driver = bytes.as_ref().and_then(|b| {
977        if let Some(fmt) = &config.input_type {
978            registry.resolve_driver_by_id(fmt)
979        } else {
980            registry.resolve_driver(b)
981        }
982    });
983
984    let mut handled = false;
985    if let (Some(driver), Some(bytes)) = (driver, bytes.as_deref()) {
986        if let Some(files) = driver
987            .handle_summary(bytes, Some(input_path), &summary_json, &config.env)
988            .await
989        {
990            // k6 semantics: a script-defined handleSummary REPLACES the
991            // default summary entirely — even a stdout-only map suppresses
992            // the --summary-export fallback.
993            handled = true;
994            for (name, content) in files {
995                if name == "stdout" {
996                    println!("{content}");
997                } else if let Err(e) = std::fs::write(&name, content) {
998                    tracing::warn!("handleSummary failed to write '{name}': {e}");
999                } else {
1000                    tracing::info!("handleSummary wrote '{name}'");
1001                }
1002            }
1003        }
1004    }
1005
1006    // Fallback: --summary-export writes the default JSON summary when no
1007    // script handleSummary produced any output.
1008    if !handled {
1009        if let Some(path) = &config.output.summary_export {
1010            let pretty = serde_json::to_string_pretty(&summary_value).unwrap_or_default();
1011            if let Err(e) = std::fs::write(path, pretty) {
1012                tracing::warn!("Failed to write summary export to '{:?}': {}", path, e);
1013            } else {
1014                tracing::info!("Summary exported to '{:?}'", path);
1015            }
1016        }
1017    }
1018}
1019// Result type
1020
1021#[derive(Debug)]
1022pub struct EngineResult {
1023    pub metrics: tropel_metrics::collector::MetricsResult,
1024    /// Raw serializable snapshot of the aggregated series (hdr-histogram V2
1025    /// bytes for Trend metrics). Distributed agents ship this to a
1026    /// controller; single-node runs can ignore it.
1027    pub snapshot: tropel_metrics::collector::MetricsSnapshot,
1028    /// The thresholds actually applied to the run: the job's own thresholds
1029    /// merged with any script-declared thresholds (e.g. from a k6
1030    /// `export const options`). Consumers should report/evaluate against this
1031    /// set rather than the raw `JobConfig.thresholds`.
1032    pub effective_thresholds: HashMap<String, tropel_core::config::ThresholdConfig>,
1033    /// Number of VUs that failed to START (driver init / HTTP client
1034    /// creation). Non-zero means the requested load was NOT delivered — the
1035    /// CLI exits non-zero after printing the summary.
1036    pub vu_init_failures: u32,
1037    /// Number of script executions (prerequest/test scripts, driver
1038    /// iterations) that errored during the run. Non-zero means scripts kept
1039    /// failing — the CLI exits non-zero after printing the summary (backlog
1040    /// line 98: script failures used to be swallowed — warn only, no failed
1041    /// check, exit 0).
1042    pub script_failures: u64,
1043    /// Message from `exec.test.abort(msg?)`, if any. Set when a VU called
1044    /// abort during the run. The CLI uses this to exit with k6's exit code
1045    /// 108 (ScriptAborted) instead of a generic non-zero (TR-244).
1046    pub abort_message: Option<String>,
1047}