use crate::js_bootstrap::{create_vu_js_context, ShimBundle};
use crate::pacing::{apply_think_time, extract_think_time};
use crate::vu_sources::{DriverVuSource, ScenarioVuSource};
use crate::worker::VUWorkerPool;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tropel_core::config::{
ExecutionConfig, HttpConfig, ThinkTimeConfig, ThresholdConfig, TlsConfig,
};
use tropel_ext::registry::ExtensionRegistry;
use tropel_http::client::{HttpClient, VuCookieClient};
use tropel_metrics::collector::MetricsCollector;
use tropel_metrics::thresholds::evaluate_thresholds;
use tropel_runtime::ScenarioRunner;
use tropel_sandbox::config::SandboxConfig;
use tropel_scheduler::{VUScheduler, VuLease};
use tropel_sdk::scenario::{Scenario, ScenarioItem};
use tropel_sdk::traits::{Driver, DriverHttpClient, Protocol};
use tropel_sdk::types::{Request, Response, Sample, TagMap};
use tropel_sdk::Result;
pub(crate) struct VuIterationOutcome {
pub(crate) samples: Vec<Sample>,
pub(crate) abort_message: Option<String>,
pub(crate) script_failures: u64,
}
#[async_trait]
pub(crate) trait VuIterationSource: Send {
async fn run_iteration(
&mut self,
iteration_index: u64,
data_row: Option<HashMap<String, serde_json::Value>>,
vu_env: &HashMap<String, String>,
) -> VuIterationOutcome;
}
async fn run_vu_loop(
sched: Arc<VUScheduler>,
shared: &VuRunShared,
vu_id: u32,
source: &mut dyn VuIterationSource,
) {
let mut exit_guard = sched.control_spawn_guard();
let mut iteration_index = 0u64;
loop {
if sched.is_force_stop_requested() || sched.is_stop_requested() {
break;
}
{
let active = sched.active_vus().await;
if sched.try_claim_ramp_down(active).await {
exit_guard.mark_claimed();
break;
}
}
let notify = sched.control_notify();
let stop = sched.stop_signal();
let mut notify_ready = std::pin::pin!(notify.notified());
let mut stop_ready = std::pin::pin!(stop.notified());
while sched.is_paused() && !sched.is_stop_requested() && !sched.is_force_stop_requested() {
tokio::select! {
_ = &mut notify_ready => {
notify_ready.set(notify.notified());
}
_ = &mut stop_ready => {
stop_ready.set(stop.notified());
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
}
}
if sched.is_stop_requested() || sched.is_force_stop_requested() {
break;
}
if !shared.is_per_vu_iterations
&& shared.total_iterations != u64::MAX
&& !sched.try_claim_shared_iteration(shared.total_iterations)
{
break;
}
let iter_start = Instant::now();
if sched.is_arrival_rate() {
let _idle_guard = sched.idle_guard();
let arrival_notify = sched.arrival_notify();
let stop = sched.stop_signal();
let mut got_token = false;
let mut arrival_ready = std::pin::pin!(arrival_notify.notified());
let mut stop_ready = std::pin::pin!(stop.notified());
loop {
if sched.is_stop_requested() || sched.is_force_stop_requested() {
break;
}
if sched.try_acquire_arrival_token() {
got_token = true;
break;
}
tokio::select! {
_ = &mut arrival_ready => {
arrival_ready.set(arrival_notify.notified());
}
_ = &mut stop_ready => {
stop_ready.set(stop.notified());
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
}
}
if !got_token {
break;
}
}
{
let data_row = if shared.data_rows.is_empty() {
None
} else {
Some(
shared.data_rows
[(iteration_index as usize + vu_id as usize) % shared.data_rows.len()]
.clone(),
)
};
let iter_start_time = Instant::now();
let outcome = source
.run_iteration(iteration_index, data_row, &shared.vu_env)
.await;
let iter_dur = iter_start_time.elapsed();
if outcome.script_failures > 0 {
shared
.script_failures
.fetch_add(outcome.script_failures, Ordering::SeqCst);
}
let now = std::time::SystemTime::now();
let base_tags = if shared.sc_tags.is_empty() {
Arc::new(TagMap::new())
} else {
Arc::new(TagMap::from_pairs(
shared.sc_tags.iter().map(|(k, v)| (k.as_str(), v.as_str())),
))
};
let mut iter_samples = outcome.samples;
iter_samples.push(Sample {
metric: "iterations".into(),
value: 1.0,
tags: Arc::clone(&base_tags),
timestamp: now,
sample_type: tropel_sdk::types::SampleType::Counter,
});
iter_samples.push(Sample {
metric: "iteration_duration".into(),
value: iter_dur.as_secs_f64() * 1000.0,
tags: base_tags,
timestamp: now,
sample_type: tropel_sdk::types::SampleType::Trend,
});
shared.metrics.record_batch(&iter_samples).await;
if let Some(msg) = outcome.abort_message {
tracing::warn!("test.abort(): {} — stopping", msg);
{
let mut slot = shared.abort_message.lock().unwrap();
if slot.is_none() {
*slot = Some(msg);
}
}
sched.request_stop();
}
sched.increment_iterations().await;
}
iteration_index += 1;
if !sched.is_arrival_rate()
&& !sched.is_stop_requested()
&& !sched.is_force_stop_requested()
{
apply_think_time(
&shared.think_time,
Some(iter_start.elapsed()),
Some(&sched.stop_signal()),
)
.await;
}
if shared.total_iterations != u64::MAX
&& shared.is_per_vu_iterations
&& iteration_index >= shared.total_iterations
{
break;
}
}
}
#[derive(Clone)]
struct VuRunShared {
metrics: Arc<MetricsCollector>,
sc_tags: HashMap<String, String>,
vu_env: HashMap<String, String>,
data_rows: std::sync::Arc<Vec<HashMap<String, serde_json::Value>>>,
total_iterations: u64,
is_per_vu_iterations: bool,
think_time: ThinkTimeConfig,
executor_name: String,
vu_init_failures: Arc<AtomicU32>,
script_failures: Arc<AtomicU64>,
abort_message: Arc<std::sync::Mutex<Option<String>>>,
}
struct StopOnDrop(Arc<VUScheduler>);
impl Drop for StopOnDrop {
fn drop(&mut self) {
self.0.request_stop();
}
}
#[allow(clippy::too_many_arguments)]
async fn run_vus<F>(
sc_name: String,
start_delay: Duration,
sc_env: HashMap<String, String>,
sc_tags: HashMap<String, String>,
base_env: HashMap<String, String>,
exec_cfg: ExecutionConfig,
metrics: Arc<MetricsCollector>,
thresholds: HashMap<String, ThresholdConfig>,
data_rows: std::sync::Arc<Vec<HashMap<String, serde_json::Value>>>,
test_start: Instant,
control_port: Option<u16>,
setup_data: Option<String>,
arrival_stripe: Option<tropel_core::segment::ArrivalStripe>,
run_vu: F,
) -> (u32, u64, Option<String>)
where
F: Fn(Arc<VUScheduler>, u32, &VuRunShared) -> tokio::task::JoinHandle<()>
+ Send
+ Sync
+ 'static,
{
if start_delay > Duration::ZERO {
tokio::time::sleep(start_delay).await;
tracing::info!(
"Scenario '{}' started after {:?} delay",
sc_name,
start_delay
);
}
let mut vu_env = base_env;
vu_env.extend(sc_env);
let executor = match arrival_stripe {
Some(stripe) => VUScheduler::new(&exec_cfg).with_arrival_stripe(stripe),
None => VUScheduler::new(&exec_cfg),
};
let _stop_on_drop = StopOnDrop(executor.control_handle());
let signal_handle = executor.control_handle();
let signal_handle2 = executor.control_handle();
let _signal_guard = tokio::spawn(async move {
let ctrl_c = tokio::signal::ctrl_c();
tokio::pin!(ctrl_c);
#[cfg(unix)]
{
let mut sigterm =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to register SIGTERM handler");
tokio::select! {
_ = &mut ctrl_c => {
tracing::warn!("Received SIGINT — shutting down gracefully");
}
_ = sigterm.recv() => {
tracing::warn!("Received SIGTERM — shutting down gracefully");
}
}
}
#[cfg(not(unix))]
{
ctrl_c.await.ok();
tracing::warn!("Received Ctrl-C — shutting down gracefully");
}
signal_handle.request_stop();
#[cfg(unix)]
{
let ctrl_c2 = tokio::signal::ctrl_c();
tokio::pin!(ctrl_c2);
let mut sigterm2 =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to register SIGTERM handler");
tokio::select! {
_ = &mut ctrl_c2 => {
tracing::warn!("Received second SIGINT — forcing stop");
}
_ = sigterm2.recv() => {
tracing::warn!("Received second SIGTERM — forcing stop");
}
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c().await.ok();
tracing::warn!("Received second Ctrl-C — forcing stop");
}
signal_handle2.request_force_stop();
});
let control_server = control_port.map(|port| {
let state = crate::control_api::ControlApiState {
scheduler: executor.control_handle(),
metrics: metrics.clone(),
setup_data: std::sync::Arc::new(std::sync::Mutex::new(
setup_data.as_ref().map(|s| s.as_bytes().to_vec()),
)),
scenario_name: sc_name.clone(),
};
tokio::spawn(crate::control_api::serve_control_api(port, state))
});
let total_iterations = match &exec_cfg {
ExecutionConfig::SharedIterations { iterations, .. } => *iterations,
ExecutionConfig::PerVUIterations { iterations, .. } => *iterations,
_ => u64::MAX,
};
const MAX_ITERATIONS: u64 = 10_000_000_000;
let total_iterations = if total_iterations > MAX_ITERATIONS {
tracing::warn!(
"iterations {} capped to {} (would exceed memory budget)",
total_iterations,
MAX_ITERATIONS
);
MAX_ITERATIONS
} else {
total_iterations
};
let is_per_vu_iterations = matches!(exec_cfg, ExecutionConfig::PerVUIterations { .. });
let think_time_cfg = extract_think_time(&exec_cfg);
let executor_name = exec_cfg.executor_name().to_string();
let abort_monitor = spawn_abort_coordinator(
metrics.clone(),
executor.control_handle(),
thresholds.clone(),
test_start,
);
let vu_init_failures = Arc::new(AtomicU32::new(0));
let script_failures = Arc::new(AtomicU64::new(0));
let abort_message: Arc<std::sync::Mutex<Option<String>>> =
Arc::new(std::sync::Mutex::new(None));
let last_active_vus = Arc::new(AtomicU32::new(0));
let vus_sampler = tokio::spawn(vus_sampler_task(
executor.control_handle(),
metrics.clone(),
last_active_vus.clone(),
sc_tags.clone(),
));
let dropped_sampler = tokio::spawn(dropped_sampler_task(
executor.control_handle(),
metrics.clone(),
sc_tags.clone(),
));
let shared = VuRunShared {
metrics: metrics.clone(),
sc_tags: sc_tags.clone(),
vu_env: vu_env.clone(),
data_rows,
total_iterations,
is_per_vu_iterations,
think_time: think_time_cfg,
executor_name,
vu_init_failures: vu_init_failures.clone(),
script_failures: script_failures.clone(),
abort_message: abort_message.clone(),
};
let abort_message_handle = shared.abort_message.clone();
{
let (target_vus, ramp_secs) = match &exec_cfg {
ExecutionConfig::ConstantVus { vus, .. } => (*vus, None),
ExecutionConfig::RampingVus {
stages, start_vus, ..
} => {
let target = stages.iter().map(|s| s.target).max().unwrap_or(*start_vus);
let ramp_secs: f64 = stages
.iter()
.map(|s| {
crate::pacing::parse_duration_str(&s.duration)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
})
.sum();
(target, Some(ramp_secs))
}
ExecutionConfig::ConstantArrivalRate { pre_alloc_vus, .. }
| ExecutionConfig::RampingArrivalRate { pre_alloc_vus, .. } => (*pre_alloc_vus, None),
ExecutionConfig::SharedIterations { vus, .. } => (*vus, None),
ExecutionConfig::PerVUIterations { vus, .. } => (*vus, None),
ExecutionConfig::ExternallyControlled { .. } => (0, None),
};
if target_vus > 100 {
let estimated_cpu_secs = target_vus as f64 * 0.001;
let cores = std::thread::available_parallelism()
.map(|n| n.get() as f64)
.unwrap_or(4.0);
let wall_secs = estimated_cpu_secs / cores;
if let Some(ramp_secs) = ramp_secs {
if ramp_secs > 0.0 && wall_secs > ramp_secs * 1.5 {
tracing::warn!(
"Scenario '{}': per-VU JS init estimated at {:.1}s CPU \
({:.2}s wall on {:.0} cores) but the ramp is only {:.1}s. \
A 0→{} ramp cannot complete in {:.1}s — expect {:.0}s+ to reach target. \
Consider a slower ramp or pre-warming the VU pool.",
sc_name,
estimated_cpu_secs,
wall_secs,
cores,
ramp_secs,
target_vus,
ramp_secs,
wall_secs
);
}
} else {
tracing::info!(
"Scenario '{}': {} VUs — per-VU JS init estimated at {:.2}s wall on {:.0} cores",
sc_name, target_vus, wall_secs, cores
);
}
}
}
if let Err(e) = executor
.run(move |sched, vu_id| run_vu(sched, vu_id, &shared))
.await
{
tracing::error!("Scenario '{}': executor rejected the run: {}", sc_name, e);
vu_init_failures.fetch_add(1, Ordering::SeqCst);
}
let init_failures = vu_init_failures.load(Ordering::SeqCst);
if init_failures > 0 {
tracing::error!(
"Scenario '{}': {} VU(s) failed to start — run did not deliver the requested load",
sc_name,
init_failures
);
}
if let Some(monitor) = abort_monitor {
monitor.abort();
}
vus_sampler.abort();
dropped_sampler.abort();
let final_active = last_active_vus.load(std::sync::atomic::Ordering::SeqCst);
utils_emit_vus_metrics(&metrics, final_active, executor.peak_vus(), &sc_tags).await;
{
let dropped = executor.take_dropped_iterations();
if dropped > 0 {
let mut dropped_tags = TagMap::new();
for (k, v) in &sc_tags {
dropped_tags.insert(k.clone(), v.clone());
}
metrics
.record(&Sample {
metric: "dropped_iterations".into(),
value: dropped as f64,
tags: Arc::new(dropped_tags),
timestamp: std::time::SystemTime::now(),
sample_type: tropel_sdk::types::SampleType::Counter,
})
.await;
}
}
let drain_deadline = tokio::time::Instant::now() + Duration::from_secs(30);
loop {
let active = executor.active_vus().await;
if active == 0 {
break;
}
if tokio::time::Instant::now() >= drain_deadline {
tracing::warn!(
"VU drain timed out after 30s ({} VU(s) still active) — proceeding to shutdown",
active
);
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
if let Some(handle) = control_server {
handle.abort();
}
let abort_message = abort_message_handle.lock().unwrap().clone();
(
init_failures,
script_failures.load(Ordering::SeqCst),
abort_message,
)
}
pub(crate) struct DriverHttpClientImpl {
pub(crate) client: VuCookieClient,
}
impl DriverHttpClientImpl {
pub(crate) fn new_arc(client: VuCookieClient) -> Arc<Self> {
let jar = client.jar();
let arc = Arc::new(Self { client });
tropel_http::vu_jar::register_vu_jar(tropel_http::vu_jar::client_key(&arc), jar);
arc
}
}
impl Drop for DriverHttpClientImpl {
fn drop(&mut self) {
tropel_http::vu_jar::unregister_vu_jar(self as *const Self as usize);
}
}
#[async_trait]
impl DriverHttpClient for DriverHttpClientImpl {
async fn execute(&self, req: &Request) -> Result<Response> {
let signer = match req.auth.as_ref() {
Some(a) => self.client.get_signer_ref(a)?,
None => None,
};
let http_resp = self.client.execute(req, signer).await?;
Ok(Response::from(http_resp))
}
}
fn vu_http_client(
shared: &Arc<HttpClient>,
http_cfg: &HttpConfig,
tls_cfg: &TlsConfig,
rps_limiter: &Option<Arc<tropel_http::RpsLimiter>>,
) -> Arc<HttpClient> {
if !http_cfg.no_vu_connection_reuse {
return shared.clone();
}
match HttpClient::with_tls_and_rps(http_cfg, tls_cfg, rps_limiter.clone()) {
Ok(c) => Arc::new(c),
Err(e) => {
tracing::error!(
"noVUConnectionReuse: failed to build a per-VU client ({}); \
falling back to the shared client",
e
);
shared.clone()
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_scenario_vus(
sc_name: String,
start_delay: Duration,
sc_env: HashMap<String, String>,
sc_tags: HashMap<String, String>,
base_env: HashMap<String, String>,
exec_cfg: ExecutionConfig,
scenario: Arc<Scenario>,
metrics: Arc<MetricsCollector>,
pool: Arc<VUWorkerPool>,
http_cfg: HttpConfig,
tls_cfg: TlsConfig,
thresholds: HashMap<String, ThresholdConfig>,
data_rows: std::sync::Arc<Vec<HashMap<String, serde_json::Value>>>,
test_start: Instant,
protocols: Arc<HashMap<String, Arc<dyn Protocol>>>,
control_port: Option<u16>,
rps_limiter: Option<Arc<tropel_http::RpsLimiter>>,
input_path: &str,
format_id: &str,
arrival_stripe: Option<tropel_core::segment::ArrivalStripe>,
) -> (u32, u64, Option<String>) {
let expected_statuses_c = http_cfg.expected_statuses.clone();
let scenario_c = scenario.clone();
let protocols_c = protocols.clone();
let pool_c = pool.clone();
let sc_name_c = sc_name.clone();
let lane_count = http_cfg.http2_connections.max(1);
let mut lanes: Vec<Arc<HttpClient>> = Vec::with_capacity(lane_count);
for lane_idx in 0..lane_count {
match HttpClient::with_tls_and_rps(&http_cfg, &tls_cfg, rps_limiter.clone()) {
Ok(c) => lanes.push(Arc::new(c)),
Err(e) => {
tracing::error!(
"Scenario '{}': Failed to create HTTP lane {}/{}: {}",
sc_name,
lane_idx + 1,
lane_count,
e
);
return (1, 0, None);
}
}
}
if lane_count > 1 {
tracing::info!(
"Scenario '{}': {} HTTP connection lanes (VUs assigned round-robin by vu_id % {})",
sc_name,
lane_count,
lane_count
);
}
let flattened_c: Arc<Vec<ScenarioItem>> =
Arc::new(tropel_runtime::flatten_execution_items(&scenario.items));
let names_c: Arc<Vec<String>> =
Arc::new(flattened_c.iter().map(|item| item.name.clone()).collect());
let shim = Arc::new(ShimBundle::for_format_path(
format_id,
std::path::Path::new(input_path),
));
tracing::debug!(
"Scenario '{}': input format '{}' → shim bundle [{}]",
sc_name,
format_id,
shim.0.iter().map(|e| e.0).collect::<Vec<_>>().join("+")
);
{
let warm_urls: Vec<String> = flattened_c
.iter()
.filter_map(|item| item.request.as_ref().map(|r| r.url.clone()))
.collect();
if !warm_urls.is_empty() {
lanes[0].pre_warm(&warm_urls).await;
}
}
run_vus(
sc_name,
start_delay,
sc_env,
sc_tags,
base_env,
exec_cfg,
metrics,
thresholds,
data_rows,
test_start,
control_port,
None, arrival_stripe,
move |sched, vu_id, shared| {
let shared = shared.clone();
let lane_idx = vu_id as usize % lanes.len();
let http_client_vu =
vu_http_client(&lanes[lane_idx], &http_cfg, &tls_cfg, &rps_limiter);
let scenario = scenario_c.clone();
let protocols_vu = protocols_c.clone();
let pool = pool_c.clone();
let sc_name_vu = sc_name_c.clone();
let executor_name = shared.executor_name.clone();
let flattened_vu = flattened_c.clone();
let names_vu = names_c.clone();
let expected_statuses_vu = expected_statuses_c.clone();
let shim_vu = shim.clone();
let handle = pool.spawn_vu(vu_id, async move {
let _lease = VuLease::acquire(&sched);
let vu_client = VuCookieClient::new(http_client_vu.as_ref().clone());
let bridge_client: Arc<dyn DriverHttpClient> =
DriverHttpClientImpl::new_arc(vu_client.clone_with_shared_jar());
let http_client_handle: Arc<dyn DriverHttpClient> =
DriverHttpClientImpl::new_arc(vu_client);
let mut runner = ScenarioRunner::new(
scenario,
flattened_vu,
names_vu,
http_client_handle,
vu_id,
sc_name_vu.clone(),
)
.with_expected_statuses(expected_statuses_vu)
.with_protocols(protocols_vu.clone())
.with_exec_context(
executor_name,
sched.active_vus_handle(),
sched.total_iterations_handle(),
)
.with_force_stop_flag(sched.force_stop_flag())
.with_scenario_tags(shared.sc_tags.clone());
let pm_state = runner.state_handle();
let js_ctx = create_vu_js_context(
vu_id,
&pm_state,
&bridge_client,
&shim_vu,
&SandboxConfig::default(),
sched.force_stop_flag(),
)
.await;
if let Some(ctx) = js_ctx {
runner = runner.with_js_context(Box::new(ctx));
}
let mut source = ScenarioVuSource { runner, pm_state };
run_vu_loop(sched, &shared, vu_id, &mut source).await;
});
handle
},
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_driver_vus(
sc_name: String,
start_delay: Duration,
sc_env: HashMap<String, String>,
sc_tags: HashMap<String, String>,
base_env: HashMap<String, String>,
exec_cfg: ExecutionConfig,
sc_exec: Option<String>,
driver: Box<dyn Driver>,
metrics: Arc<MetricsCollector>,
pool: Arc<VUWorkerPool>,
http_cfg: HttpConfig,
tls_cfg: TlsConfig,
thresholds: HashMap<String, ThresholdConfig>,
data_rows: std::sync::Arc<Vec<HashMap<String, serde_json::Value>>>,
test_start: Instant,
input_path: &str,
registry: Arc<ExtensionRegistry>,
control_port: Option<u16>,
rps_limiter: Option<Arc<tropel_http::RpsLimiter>>,
protocols: Arc<HashMap<String, Arc<dyn Protocol>>>,
arrival_stripe: Option<tropel_core::segment::ArrivalStripe>,
) -> (u32, u64, Option<String>) {
let driver_id = driver.id().to_string();
let input_bytes = match std::fs::read(input_path) {
Ok(b) => Arc::new(b),
Err(e) => {
tracing::error!("Scenario '{}': failed to read input: {}", sc_name, e);
return (0, 0, None);
}
};
let input_p = std::path::Path::new(input_path).to_path_buf();
let driver_id_c = driver_id.clone();
let input_bytes_c = input_bytes.clone();
let input_p_c = input_p.clone();
let registry_c = registry.clone();
let sc_exec_c = sc_exec.clone();
let pool_c = pool.clone();
let sc_name_c = sc_name.clone();
let protocols_c = protocols.clone();
let lane_count = http_cfg.http2_connections.max(1);
let mut lanes: Vec<Arc<HttpClient>> = Vec::with_capacity(lane_count);
for lane_idx in 0..lane_count {
match HttpClient::with_tls_and_rps(&http_cfg, &tls_cfg, rps_limiter.clone()) {
Ok(c) => lanes.push(Arc::new(c)),
Err(e) => {
tracing::error!(
"Scenario '{}': Failed to create HTTP lane {}/{}: {}",
sc_name,
lane_idx + 1,
lane_count,
e
);
return (1, 0, None);
}
}
}
if lane_count > 1 {
tracing::info!(
"Scenario '{}': {} HTTP connection lanes (VUs assigned round-robin by vu_id % {})",
sc_name,
lane_count,
lane_count
);
}
if !http_cfg.hosts.is_empty() {
let warm_urls: Vec<String> = http_cfg
.hosts
.keys()
.map(|host| format!("http://{host}"))
.collect();
lanes[0].pre_warm(&warm_urls).await;
}
let mut setup_env = base_env.clone();
setup_env.extend(sc_env.clone());
let lifecycle_client: Arc<dyn DriverHttpClient + Send + Sync> =
DriverHttpClientImpl::new_arc(VuCookieClient::new(lanes[0].as_ref().clone()));
let setup_sink: Arc<Mutex<Vec<Sample>>> = Arc::new(Mutex::new(Vec::new()));
let setup_data = driver
.setup(
&input_bytes,
Some(&input_p),
&setup_env,
lifecycle_client.clone(),
setup_sink.clone(),
)
.await;
let setup_samples = std::mem::take(&mut *setup_sink.lock().unwrap());
if !setup_samples.is_empty() {
metrics.record_batch(&setup_samples).await;
}
let setup_data_c = setup_data.clone();
let metrics_after_run = metrics.clone();
let run_vus_result = run_vus(
sc_name,
start_delay,
sc_env,
sc_tags,
base_env,
exec_cfg,
metrics,
thresholds,
data_rows,
test_start,
control_port,
setup_data_c.clone(),
arrival_stripe,
move |sched, vu_id, shared| {
let shared = shared.clone();
let driver_id = driver_id_c.clone();
let input_bytes = input_bytes_c.clone();
let input_p = input_p_c.clone();
let registry = registry_c.clone();
let sc_exec = sc_exec_c.clone();
let lane_idx = vu_id as usize % lanes.len();
let http_client_vu =
vu_http_client(&lanes[lane_idx], &http_cfg, &tls_cfg, &rps_limiter);
let pool = pool_c.clone();
let sc_name_vu = sc_name_c.clone();
let executor_name = shared.executor_name.clone();
let setup_data_vu = setup_data_c.clone();
let protocols_vu = protocols_c.clone();
let handle = pool.spawn_vu(vu_id, async move {
let _lease = VuLease::acquire(&sched);
let driver = match registry.resolve_driver_by_id(&driver_id) {
Some(d) => d,
None => {
tracing::error!(
"VU {}: Driver '{}' not found in registry",
vu_id,
driver_id
);
shared.vu_init_failures.fetch_add(1, Ordering::SeqCst);
sched.vu_exited();
return;
}
};
let driver_instance = match driver
.init(&input_bytes, Some(&input_p), sc_exec.as_deref())
.await
{
Ok(mut inst) => {
inst.set_force_stop_flag(sched.force_stop_flag());
inst
}
Err(e) => {
tracing::error!(
"Scenario '{}' VU {}: Driver '{}' init failed: {}",
sc_name_vu,
vu_id,
driver_id,
e
);
shared.vu_init_failures.fetch_add(1, Ordering::SeqCst);
sched.vu_exited();
return;
}
};
let client = VuCookieClient::new(http_client_vu.as_ref().clone());
let http_client_handle: Arc<dyn DriverHttpClient + Send + Sync> =
DriverHttpClientImpl::new_arc(client);
let mut source = DriverVuSource {
instance: driver_instance,
http_client: http_client_handle,
executor_name,
driver_id,
vu_id,
sc_name: sc_name_vu,
sched: sched.clone(),
env: shared.vu_env.clone(),
env_attached: false,
setup_data: setup_data_vu,
protocols: protocols_vu,
};
run_vu_loop(sched, &shared, vu_id, &mut source).await;
});
handle
},
)
.await;
let teardown_sink: Arc<Mutex<Vec<Sample>>> = Arc::new(Mutex::new(Vec::new()));
driver
.teardown(
&input_bytes,
Some(&input_p),
setup_data.as_deref(),
&setup_env,
lifecycle_client,
teardown_sink.clone(),
)
.await;
let teardown_samples = std::mem::take(&mut *teardown_sink.lock().unwrap());
if !teardown_samples.is_empty() {
metrics_after_run.record_batch(&teardown_samples).await;
}
let (init_failures, script_failures, abort_message) = run_vus_result;
(init_failures, script_failures, abort_message)
}
fn spawn_abort_coordinator(
metrics: Arc<MetricsCollector>,
sched: Arc<VUScheduler>,
thresholds: HashMap<String, tropel_core::config::ThresholdConfig>,
test_start: Instant,
) -> Option<tokio::task::JoinHandle<()>> {
if thresholds.is_empty() {
return None;
}
Some(tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(2));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
loop {
ticker.tick().await;
if sched.is_stop_requested() || sched.is_force_stop_requested() {
break;
}
let elapsed = test_start.elapsed();
if elapsed > Duration::from_secs(1) {
let mut results = metrics.results().await;
results.run_duration = elapsed;
let mut should_abort = false;
for tr in evaluate_thresholds(&thresholds, &results) {
if !tr.passed {
sched.set_tainted();
if tr.abort_on_fail {
let in_grace = tr.delay_abort_eval.is_some_and(|grace| elapsed < grace);
if !in_grace {
tracing::error!(
"Threshold '{}' ({}) breached with abortOnFail -- aborting test",
tr.name, tr.expression
);
should_abort = true;
}
}
}
}
if should_abort {
sched.request_stop();
break;
}
}
}
}))
}
async fn vus_sampler_task(
sched: Arc<VUScheduler>,
metrics: Arc<MetricsCollector>,
last_active_vus: Arc<AtomicU32>,
sc_tags: HashMap<String, String>,
) {
let mut ticker = tokio::time::interval(Duration::from_secs(1));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let active = sched.active_vus().await;
let peak = sched.peak_vus();
last_active_vus.store(active, std::sync::atomic::Ordering::Relaxed);
utils_emit_vus_metrics(&metrics, active, peak, &sc_tags).await;
}
}
async fn dropped_sampler_task(
sched: Arc<VUScheduler>,
metrics: Arc<MetricsCollector>,
sc_tags: HashMap<String, String>,
) {
let mut ticker = tokio::time::interval(Duration::from_secs(2));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let delta = sched.take_dropped_iterations();
if delta > 0 {
let now = std::time::SystemTime::now();
let mut tags = TagMap::new();
for (k, v) in &sc_tags {
tags.insert(k.clone(), v.clone());
}
metrics
.record(&Sample {
metric: "dropped_iterations".into(),
value: delta as f64,
tags: Arc::new(tags),
timestamp: now,
sample_type: tropel_sdk::types::SampleType::Counter,
})
.await;
}
}
}
async fn utils_emit_vus_metrics(
metrics: &MetricsCollector,
active: u32,
peak: u32,
sc_tags: &HashMap<String, String>,
) {
let now = std::time::SystemTime::now();
let mut vus_tags = TagMap::new();
for (k, v) in sc_tags {
vus_tags.insert(k.clone(), v.clone());
}
let vus_tags = Arc::new(vus_tags);
metrics
.record_batch(&[
Sample {
metric: "vus".into(),
value: active as f64,
tags: vus_tags.clone(),
timestamp: now,
sample_type: tropel_sdk::types::SampleType::Point,
},
Sample {
metric: "vus_max".into(),
value: peak as f64,
tags: vus_tags,
timestamp: now,
sample_type: tropel_sdk::types::SampleType::Point,
},
])
.await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_on_drop_requests_stop_even_on_drop_without_run() {
let executor = VUScheduler::new(&ExecutionConfig::SharedIterations {
iterations: 5,
max_duration: None,
vus: 10,
graceful_stop: None,
think_time: Default::default(),
});
assert!(!executor.is_stop_requested());
{
let _guard = StopOnDrop(executor.control_handle());
assert!(!executor.is_stop_requested());
}
assert!(
executor.is_stop_requested(),
"StopOnDrop must request stop on drop"
);
}
#[tokio::test]
async fn vus_sampler_emits_bounded_cadence_not_per_vu() {
let metrics = Arc::new(MetricsCollector::new());
let sched = Arc::new(VUScheduler::new(&ExecutionConfig::ConstantVus {
vus: 1000,
duration: "10s".to_string(),
graceful_stop: None,
think_time: Default::default(),
}));
let last_active = Arc::new(AtomicU32::new(0));
let tags = HashMap::new();
let task = tokio::spawn(vus_sampler_task(
sched,
metrics.clone(),
last_active.clone(),
tags,
));
tokio::time::sleep(Duration::from_millis(2300)).await;
task.abort();
let _ = task.await;
let results = metrics.results().await;
let vus = results
.metrics
.iter()
.find(|m| m.key == "vus")
.map(|m| m.count)
.unwrap_or(0);
assert!(vus >= 1, "sampler must emit the t=0 sample, got {vus}");
assert!(
vus <= 4,
"vus sampler storm: {vus} samples in 2.3s — must be ~2, not per-VU"
);
let vus_max = results
.metrics
.iter()
.find(|m| m.key == "vus_max")
.map(|m| m.count)
.unwrap_or(0);
assert!(
vus_max <= 4,
"vus_max sampler storm: {vus_max} samples in 2.3s"
);
}
}