From be9fa0b4d8c53a9345e763a017d1c5dc76f3eac7 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 14 Feb 2026 14:21:53 +0100 Subject: [PATCH 1/4] Finalize phase 7 hexagonal migration and tighten boundaries --- CHANGELOG.md | 5 + .../ard/ARCHITECTURE_BASELINE_METRICS.md | 26 +++ .../architecture/ard/ARCHITECTURE_OVERVIEW.md | 39 ++++ .../ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md | 20 ++ scripts/check_architecture.sh | 27 +++ src/adapters/cli/mapper.rs | 37 ++- src/app/runner/core/mod.rs | 174 +++++++++++--- src/application/commands.rs | 66 ++---- src/application/distributed_run.rs | 159 ++++++++++++- src/application/local_run.rs | 220 +++++++++++------- src/entry/plan/build.rs | 182 ++++++++++++++- src/entry/plan/execute.rs | 26 +-- src/entry/plan/types.rs | 23 +- src/error/script.rs | 3 +- 14 files changed, 774 insertions(+), 233 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b200b..2a50e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ The format is based on Keep a Changelog, and this project follows SemVer. ## Unreleased +- Completed Phase 7 of the hexagonal migration by removing direct `TesterArgs` coupling from the application layer (`application::commands`, `application::local_run`, `application::distributed_run`) and tightening adapter-boundary mapping in entry planning. +- Added architecture guardrails that fail checks when `src/application` reintroduces `TesterArgs` or `crate::args` imports. +- Added migration validation coverage for all run-plan feature routes (local, distributed controller/agent, replay, compare, cleanup, dump-urls, service) and distributed/local application dispatch seams. +- Updated architecture docs with Phase 7 implementation artifacts, current coupling metrics snapshot, and Mermaid before/after boundary diagrams. + ## 0.1.9 Released: 2026-02-13 diff --git a/docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md b/docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md index 74a27ad..de3fee8 100644 --- a/docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md +++ b/docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md @@ -28,3 +28,29 @@ This baseline is used to track migration progress toward vertical slices with he - `app -> args` (`12`) - `config -> args` (`11`) - `protocol -> args` (`10`) + +## Phase 7 Snapshot + +_Snapshot date: 2026-02-14_ + +- `non_test_rust_files`: `220` +- `files_referencing_crate_args`: `70` +- `files_referencing_tester_args`: `65` + +## Phase 7 Top Cross-Module Edges (Top 10) + +- `distributed -> args` (`22`) +- `distributed -> error` (`19`) +- `app -> error` (`17`) +- `charts -> error` (`16`) +- `app -> metrics` (`15`) +- `config -> error` (`13`) +- `charts -> metrics` (`13`) +- `app -> args` (`12`) +- `config -> args` (`11`) +- `protocol -> domain` (`7`) + +## Interpretation + +- `files_referencing_crate_args` decreased from baseline (`71` -> `70`). +- `files_referencing_tester_args` increased from the original baseline due expanded migration tests in non-test module files; Phase 7 guardrails now hard-fail application-layer `TesterArgs` coupling to keep new architecture seams clean while remaining infra modules continue incremental migration. diff --git a/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md b/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md index 7b5b911..4546f62 100644 --- a/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md +++ b/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md @@ -2,6 +2,8 @@ _Generated from `src/**/*.rs` on 2026-02-13 13:05:45 UTC_ +_Phase 7 migration annotations updated on 2026-02-14._ + ## Scope - Module inventory includes all source modules under `src/` (including test modules inside `src`). - Dependency graph edges are derived from `crate::...` references in non-test source files only. @@ -40,6 +42,43 @@ flowchart TD agentSession --> wire ``` +## Hexagonal Migration Snapshot (Phase 7) + +### Before (Legacy Coupling Path) +```mermaid +flowchart LR + cli["CLI parse (`TesterArgs`)"] + plan["entry::plan::build_plan"] + commands["application::commands\n(owned `TesterArgs`)"] + local["application::local_run\n(direct `TesterArgs`)"] + dist["application::distributed_run\n(direct `TesterArgs`)"] + runtime["Runtime adapters + infra"] + + cli --> plan --> commands + commands --> local --> runtime + commands --> dist --> runtime +``` + +### After (Phase 7 Boundary) +```mermaid +flowchart LR + cli["CLI/config adapters (`TesterArgs`)"] + mapper["adapters::cli::mapper"] + plan["entry::plan\n(command + adapter payload)"] + appcmd["application::commands\n(run metadata only)"] + applocal["application::local_run\n(generic ports + `LocalRunSettings`)"] + appdist["application::distributed_run\n(generic dispatch)"] + runtime["Runtime adapters + infra (`TesterArgs`)"] + + cli --> mapper --> plan + plan --> appcmd + appcmd --> applocal + appcmd --> appdist + plan --> runtime + applocal --> runtime + appdist --> runtime +``` + ## Top-Level Dependency Graph ```mermaid flowchart LR diff --git a/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md b/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md index 314faf3..e1b3855 100644 --- a/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md +++ b/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md @@ -340,6 +340,22 @@ Phase 6 artifacts (implemented): Exit criteria: - `TesterArgs` references constrained to CLI/config adapter composition layer. +Phase 7 artifacts (implemented): +- Application command model no longer stores CLI structs: `src/application/commands.rs` +- Local/distributed application seams now accept adapter payloads generically while keeping typed run settings in application: `src/application/local_run.rs`, `src/application/distributed_run.rs` +- Entry planning now carries typed commands plus adapter payloads, instead of embedding CLI structs in application commands: `src/entry/plan/types.rs`, `src/entry/plan/build.rs`, `src/entry/plan/execute.rs` +- Runtime adapter composition maps `TesterArgs -> LocalRunSettings` at adapter boundary and owns WASM plugin lifecycle there: `src/app/runner/core/mod.rs` +- Architecture guardrails now fail on application-layer `TesterArgs` / `crate::args` coupling: `scripts/check_architecture.sh` +- Added migration validation tests for all entry routing modes and distributed/local application dispatch seams: `src/entry/plan/build.rs`, `src/application/distributed_run.rs`, `src/application/local_run.rs` + +Phase 7 validation snapshot (2026-02-14): +- `cargo make architecture-check` passes. +- `src/application` contains no `TesterArgs` references and no `crate::args` imports. +- Coupling counters at snapshot time: + - `non_test_rust_files`: `220` + - `files_referencing_crate_args`: `70` + - `files_referencing_tester_args`: `65` + ## Recommended First Backlog (Concrete) 1. Create `src/domain/run.rs` and move `Protocol`, `LoadMode`, `Scenario`, `ScenarioStep` there. @@ -356,6 +372,10 @@ Track these per PR/sprint: - Number of use cases executable with mocked ports and no terminal/network dependencies. - Time-to-add-new-protocol/new-output-sink (should fall as adapters isolate infra concerns). +Current trend note (2026-02-14): +- `files_referencing_crate_args` dropped from baseline (`71` -> `70`). +- `files_referencing_tester_args` is above the historical baseline because migration test coverage now lives in non-test module files; next cleanup should move CLI-heavy fixtures into excluded test directories and continue adapter-only narrowing in infra modules. + ## Risks During Migration 1. Behavior drift from precedence changes (`CLI vs config`) during mapping extraction. diff --git a/scripts/check_architecture.sh b/scripts/check_architecture.sh index ded8b90..06014dd 100755 --- a/scripts/check_architecture.sh +++ b/scripts/check_architecture.sh @@ -77,6 +77,31 @@ check_forbidden_crates_in_layer() { done } +check_forbidden_pattern_in_layer() { + local layer_dir="$1" + local description="$2" + local regex="$3" + + if [[ ! -d "$layer_dir" ]]; then + echo "skip: ${layer_dir} not present" + return 0 + fi + + local matches + if [[ "$HAS_RG" -eq 1 ]]; then + matches="$(rg -n --glob '*.rs' "$regex" "$layer_dir" || true)" + else + matches="$(grep -R -n -E --include='*.rs' "$regex" "$layer_dir" || true)" + fi + if [[ -n "$matches" ]]; then + echo "error: forbidden ${description} detected in ${layer_dir}" + printf '%s\n' "$matches" + FAILED=1 + else + echo "ok: ${layer_dir} has no ${description}" + fi +} + print_top_module_edges() { local edge_tmp edge_tmp="$(mktemp)" @@ -126,6 +151,8 @@ find_use_refs() { echo "Architecture boundary checks" check_forbidden_crates_in_layer "src/domain" "clap" "reqwest" "tokio" "ratatui" "crossterm" check_forbidden_crates_in_layer "src/application" "clap" +check_forbidden_pattern_in_layer "src/application" "'TesterArgs' references" "\\bTesterArgs\\b" +check_forbidden_pattern_in_layer "src/application" "'crate::args' imports" "crate::args::" echo echo "Coupling baseline metrics" diff --git a/src/adapters/cli/mapper.rs b/src/adapters/cli/mapper.rs index c19fa91..86dafe2 100644 --- a/src/adapters/cli/mapper.rs +++ b/src/adapters/cli/mapper.rs @@ -1,8 +1,6 @@ use std::collections::BTreeMap; -use crate::application::commands::{ - DistributedRunCommand, LocalRunCommand, ReplayRunCommand, ServiceCommand, -}; +use crate::application::commands::{DistributedRunCommand, LocalRunCommand, ReplayRunCommand}; use crate::args::{ LoadMode as CliLoadMode, Protocol as CliProtocol, Scenario as CliScenario, TesterArgs, }; @@ -10,36 +8,31 @@ use crate::config::types::ScenarioConfig; use crate::domain::run::{LoadMode, ProtocolKind, RunConfig, Scenario}; use crate::error::{AppError, AppResult, ValidationError}; -pub(crate) fn to_local_run_command(mut args: TesterArgs) -> AppResult { +pub(crate) fn to_local_run_command(args: &TesterArgs) -> AppResult { if args.url.is_none() && args.scenario.is_none() { tracing::error!("Missing URL (set --url or provide in config)."); return Err(AppError::validation(ValidationError::MissingUrl)); } - args.distributed_stream_summaries = false; - let run_config = to_run_config(&args); - Ok(LocalRunCommand::new(run_config, args)) -} - -pub(crate) fn to_replay_run_command(args: TesterArgs) -> ReplayRunCommand { - let run_config = to_run_config(&args); - ReplayRunCommand::new(run_config, args) + let run_config = to_run_config(args); + Ok(LocalRunCommand::new(run_config, args.no_color)) } -pub(crate) const fn to_service_command(args: TesterArgs) -> ServiceCommand { - ServiceCommand::new(args) +pub(crate) fn to_replay_run_command(args: &TesterArgs) -> ReplayRunCommand { + let run_config = to_run_config(args); + ReplayRunCommand::new(run_config, args.no_color) } pub(crate) fn to_controller_run_command( - args: TesterArgs, + args: &TesterArgs, scenarios: Option>, ) -> DistributedRunCommand { - let run_config = to_run_config(&args); - DistributedRunCommand::new_controller(run_config, args, scenarios) + let run_config = to_run_config(args); + DistributedRunCommand::new_controller(run_config, args.no_color, scenarios) } -pub(crate) fn to_agent_run_command(args: TesterArgs) -> DistributedRunCommand { - let run_config = to_run_config(&args); - DistributedRunCommand::new_agent(run_config, args) +pub(crate) fn to_agent_run_command(args: &TesterArgs) -> DistributedRunCommand { + let run_config = to_run_config(args); + DistributedRunCommand::new_agent(run_config, args.no_color) } fn to_run_config(args: &TesterArgs) -> RunConfig { @@ -90,7 +83,7 @@ mod tests { return; }; - let mapped = to_local_run_command(args); + let mapped = to_local_run_command(&args); assert!( mapped.is_err(), "Expected local command mapper to reject missing URL and scenario" @@ -127,7 +120,7 @@ mod tests { return; }; - let mapped = to_local_run_command(args); + let mapped = to_local_run_command(&args); assert!( mapped.is_ok(), "Expected local command mapping to succeed for valid arguments" diff --git a/src/app/runner/core/mod.rs b/src/app/runner/core/mod.rs index 38a0c57..0147131 100644 --- a/src/app/runner/core/mod.rs +++ b/src/app/runner/core/mod.rs @@ -1,17 +1,19 @@ mod finalize; use std::io::IsTerminal; +#[cfg(feature = "wasm")] +use std::sync::Mutex; use std::time::Duration; use crate::{ app::{logs, progress}, application::local_run::{ - self, FinalizeRunInput, LocalRunExecutionCommand, MetricsCollectorInput, MetricsPort, - OutputPort, ShutdownPort, TrafficPort, + self, FinalizeRunInput, LocalRunExecutionCommand, LocalRunSettings, MetricsCollectorInput, + MetricsPort, OutputPort, ShutdownPort, TrafficPort, }, args::TesterArgs, domain::run::ProtocolKind, - error::AppResult, + error::{AppError, AppResult}, metrics::{self, Metrics}, protocol, shutdown::{ShutdownReceiver, ShutdownSender}, @@ -22,6 +24,11 @@ use async_trait::async_trait; use tokio::sync::{mpsc, watch}; use tokio::time::Instant; +#[cfg(not(feature = "wasm"))] +use crate::error::ScriptError; +#[cfg(feature = "wasm")] +use crate::wasm_plugins::WasmPluginHost; + use super::alloc::{setup_alloc_profiler_dump_task, setup_alloc_profiler_task}; use super::rss::setup_rss_log_task; use finalize::{FinalizeContext, finalize_run as finalize_local_run}; @@ -34,11 +41,13 @@ pub(crate) async fn run_local( external_shutdown: Option>, ) -> AppResult { let protocol = args.protocol.to_domain(); - let command = LocalRunExecutionCommand::new(protocol, args, stream_tx, external_shutdown); + let settings = local_run_settings(&args); + let command = + LocalRunExecutionCommand::new(protocol, settings, args, stream_tx, external_shutdown); let shutdown_adapter = RuntimeShutdownAdapter; let traffic_adapter = RuntimeTrafficAdapter; let metrics_adapter = RuntimeMetricsAdapter; - let output_adapter = RuntimeOutputAdapter; + let output_adapter = RuntimeOutputAdapter::new(); local_run::execute( command, @@ -50,6 +59,28 @@ pub(crate) async fn run_local( .await } +fn local_run_settings(args: &TesterArgs) -> LocalRunSettings { + LocalRunSettings { + no_color: args.no_color, + no_ui: args.no_ui, + no_splash: args.no_splash, + no_charts: args.no_charts, + summary: args.summary, + show_selections: args.show_selections, + verbose: args.verbose, + target_duration_secs: args.target_duration.get(), + ui_window_ms: args.ui_window_ms.get(), + rss_log_ms: args.rss_log_ms.as_ref().map(|value| value.get()), + alloc_profiler_ms: args.alloc_profiler_ms.as_ref().map(|value| value.get()), + alloc_profiler_dump_ms: args + .alloc_profiler_dump_ms + .as_ref() + .map(|value| value.get()), + alloc_profiler_dump_path: args.alloc_profiler_dump_path.clone(), + metrics_max: args.metrics_max.get(), + } +} + struct RuntimeShutdownAdapter; impl ShutdownPort for RuntimeShutdownAdapter { @@ -95,51 +126,95 @@ impl ShutdownPort for RuntimeShutdownAdapter { struct RuntimeTrafficAdapter; -impl TrafficPort for RuntimeTrafficAdapter { +impl TrafficPort for RuntimeTrafficAdapter { fn setup_request_sender( &self, protocol: ProtocolKind, - args: &TesterArgs, + adapter_args: &TesterArgs, shutdown_tx: &ShutdownSender, metrics_tx: &mpsc::Sender, log_sink: Option<&std::sync::Arc>, ) -> AppResult> { - protocol::setup_request_sender(protocol, args, shutdown_tx, metrics_tx, log_sink) + protocol::setup_request_sender(protocol, adapter_args, shutdown_tx, metrics_tx, log_sink) } } struct RuntimeMetricsAdapter; -impl MetricsPort for RuntimeMetricsAdapter { +impl MetricsPort for RuntimeMetricsAdapter { fn setup_metrics_collector( &self, - input: MetricsCollectorInput<'_>, + input: MetricsCollectorInput<'_, TesterArgs>, ) -> tokio::task::JoinHandle { let MetricsCollectorInput { - args, + adapter_args, run_start, shutdown_tx, metrics_rx, ui_tx, stream_tx, + .. } = input; - metrics::setup_metrics_collector(args, run_start, shutdown_tx, metrics_rx, ui_tx, stream_tx) + metrics::setup_metrics_collector( + adapter_args, + run_start, + shutdown_tx, + metrics_rx, + ui_tx, + stream_tx, + ) } } -struct RuntimeOutputAdapter; +struct RuntimeOutputAdapter { + #[cfg(feature = "wasm")] + plugin_host: Mutex>, +} + +impl RuntimeOutputAdapter { + const fn new() -> Self { + Self { + #[cfg(feature = "wasm")] + plugin_host: Mutex::new(None), + } + } +} #[async_trait] -impl OutputPort for RuntimeOutputAdapter { +impl OutputPort for RuntimeOutputAdapter { + fn prepare_run(&self, adapter_args: &TesterArgs) -> AppResult<()> { + #[cfg(feature = "wasm")] + { + let mut plugin_host = WasmPluginHost::from_paths(&adapter_args.plugin)?; + if let Some(host) = plugin_host.as_mut() { + host.on_run_start(adapter_args)?; + } + let mut guard = self.plugin_host.lock().map_err(|err| { + AppError::from(std::io::Error::other(format!( + "WASM plugin state lock poisoned: {}", + err + ))) + })?; + *guard = plugin_host; + } + + #[cfg(not(feature = "wasm"))] + if !adapter_args.plugin.is_empty() { + return Err(AppError::script(ScriptError::WasmFeatureDisabled)); + } + + Ok(()) + } + fn stdout_is_terminal(&self) -> bool { std::io::stdout().is_terminal() } - fn setup_ui_channel(&self, args: &TesterArgs) -> watch::Sender { + fn setup_ui_channel(&self, settings: &LocalRunSettings) -> watch::Sender { let initial_ui = UiData { - target_duration: Duration::from_secs(args.target_duration.get()), - ui_window_ms: args.ui_window_ms.get(), - no_color: args.no_color, + target_duration: Duration::from_secs(settings.target_duration_secs), + ui_window_ms: settings.ui_window_ms, + no_color: settings.no_color, ..UiData::default() }; let (ui_tx, _) = watch::channel(initial_ui); @@ -154,36 +229,39 @@ impl OutputPort for RuntimeOutputAdapter { &self, shutdown_tx: &ShutdownSender, no_ui: bool, - interval_ms: Option<&crate::args::PositiveU64>, + interval_ms: Option, ) -> tokio::task::JoinHandle<()> { - setup_rss_log_task(shutdown_tx, no_ui, interval_ms) + let interval = interval_ms.and_then(|value| crate::args::PositiveU64::try_from(value).ok()); + setup_rss_log_task(shutdown_tx, no_ui, interval.as_ref()) } fn setup_alloc_profiler_task( &self, shutdown_tx: &ShutdownSender, - interval_ms: Option<&crate::args::PositiveU64>, + interval_ms: Option, ) -> tokio::task::JoinHandle<()> { - setup_alloc_profiler_task(shutdown_tx, interval_ms) + let interval = interval_ms.and_then(|value| crate::args::PositiveU64::try_from(value).ok()); + setup_alloc_profiler_task(shutdown_tx, interval.as_ref()) } fn setup_alloc_profiler_dump_task( &self, shutdown_tx: &ShutdownSender, - interval_ms: Option<&crate::args::PositiveU64>, + interval_ms: Option, dump_path: &str, ) -> tokio::task::JoinHandle<()> { - setup_alloc_profiler_dump_task(shutdown_tx, interval_ms, dump_path) + let interval = interval_ms.and_then(|value| crate::args::PositiveU64::try_from(value).ok()); + setup_alloc_profiler_dump_task(shutdown_tx, interval.as_ref(), dump_path) } async fn setup_log_sinks( &self, - args: &TesterArgs, + adapter_args: &TesterArgs, run_start: Instant, charts_enabled: bool, summary_enabled: bool, ) -> AppResult { - logs::setup_log_sinks(args, run_start, charts_enabled, summary_enabled).await + logs::setup_log_sinks(adapter_args, run_start, charts_enabled, summary_enabled).await } fn setup_render_ui( @@ -196,16 +274,16 @@ impl OutputPort for RuntimeOutputAdapter { fn setup_progress_indicator( &self, - args: &TesterArgs, + adapter_args: &TesterArgs, run_start: Instant, shutdown_tx: &ShutdownSender, ) -> tokio::task::JoinHandle<()> { - progress::setup_progress_indicator(args, run_start, shutdown_tx) + progress::setup_progress_indicator(adapter_args, run_start, shutdown_tx) } - async fn finalize_run(&self, input: FinalizeRunInput<'_>) -> AppResult { + async fn finalize_run(&self, input: FinalizeRunInput<'_, TesterArgs>) -> AppResult { let FinalizeRunInput { - args, + adapter_args, charts_enabled, summary_enabled, metrics_max, @@ -213,11 +291,22 @@ impl OutputPort for RuntimeOutputAdapter { report, log_handles, log_paths, - #[cfg(feature = "wasm")] - plugin_host, + .. } = input; - finalize_local_run(FinalizeContext { - args, + + #[cfg(feature = "wasm")] + let mut plugin_host = { + let mut guard = self.plugin_host.lock().map_err(|err| { + AppError::from(std::io::Error::other(format!( + "WASM plugin state lock poisoned: {}", + err + ))) + })?; + guard.take() + }; + + let outcome = finalize_local_run(FinalizeContext { + args: adapter_args, charts_enabled, summary_enabled, metrics_max, @@ -226,8 +315,21 @@ impl OutputPort for RuntimeOutputAdapter { log_handles, log_paths, #[cfg(feature = "wasm")] - plugin_host, + plugin_host: plugin_host.as_mut(), }) - .await + .await; + + #[cfg(feature = "wasm")] + { + let mut guard = self.plugin_host.lock().map_err(|err| { + AppError::from(std::io::Error::other(format!( + "WASM plugin state lock poisoned: {}", + err + ))) + })?; + *guard = plugin_host; + } + + outcome } } diff --git a/src/application/commands.rs b/src/application/commands.rs index fecef71..e60b100 100644 --- a/src/application/commands.rs +++ b/src/application/commands.rs @@ -1,19 +1,21 @@ use std::collections::BTreeMap; -use crate::args::TesterArgs; use crate::config::types::ScenarioConfig; use crate::domain::run::RunConfig; #[derive(Debug)] pub(crate) struct LocalRunCommand { run_config: RunConfig, - args: TesterArgs, + no_color: bool, } impl LocalRunCommand { #[must_use] - pub(crate) const fn new(run_config: RunConfig, args: TesterArgs) -> Self { - Self { run_config, args } + pub(crate) const fn new(run_config: RunConfig, no_color: bool) -> Self { + Self { + run_config, + no_color, + } } #[must_use] @@ -23,25 +25,23 @@ impl LocalRunCommand { #[must_use] pub(crate) const fn no_color(&self) -> bool { - self.args.no_color - } - - #[must_use] - pub(crate) fn into_args(self) -> TesterArgs { - self.args + self.no_color } } #[derive(Debug)] pub(crate) struct ReplayRunCommand { run_config: RunConfig, - args: TesterArgs, + no_color: bool, } impl ReplayRunCommand { #[must_use] - pub(crate) const fn new(run_config: RunConfig, args: TesterArgs) -> Self { - Self { run_config, args } + pub(crate) const fn new(run_config: RunConfig, no_color: bool) -> Self { + Self { + run_config, + no_color, + } } #[must_use] @@ -51,29 +51,7 @@ impl ReplayRunCommand { #[must_use] pub(crate) const fn no_color(&self) -> bool { - self.args.no_color - } - - #[must_use] - pub(crate) const fn as_args(&self) -> &TesterArgs { - &self.args - } -} - -#[derive(Debug)] -pub(crate) struct ServiceCommand { - args: TesterArgs, -} - -impl ServiceCommand { - #[must_use] - pub(crate) const fn new(args: TesterArgs) -> Self { - Self { args } - } - - #[must_use] - pub(crate) const fn as_args(&self) -> &TesterArgs { - &self.args + self.no_color } } @@ -88,7 +66,7 @@ pub(crate) enum DistributedRunMode { #[derive(Debug)] pub(crate) struct DistributedRunCommand { run_config: RunConfig, - args: TesterArgs, + no_color: bool, mode: DistributedRunMode, } @@ -96,21 +74,21 @@ impl DistributedRunCommand { #[must_use] pub(crate) const fn new_controller( run_config: RunConfig, - args: TesterArgs, + no_color: bool, scenarios: Option>, ) -> Self { Self { run_config, - args, + no_color, mode: DistributedRunMode::Controller { scenarios }, } } #[must_use] - pub(crate) const fn new_agent(run_config: RunConfig, args: TesterArgs) -> Self { + pub(crate) const fn new_agent(run_config: RunConfig, no_color: bool) -> Self { Self { run_config, - args, + no_color, mode: DistributedRunMode::Agent, } } @@ -122,7 +100,7 @@ impl DistributedRunCommand { #[must_use] pub(crate) const fn no_color(&self) -> bool { - self.args.no_color + self.no_color } #[must_use] @@ -134,7 +112,7 @@ impl DistributedRunCommand { } #[must_use] - pub(crate) fn into_parts(self) -> (TesterArgs, DistributedRunMode) { - (self.args, self.mode) + pub(crate) fn into_mode(self) -> DistributedRunMode { + self.mode } } diff --git a/src/application/distributed_run.rs b/src/application/distributed_run.rs index f469d26..8d13526 100644 --- a/src/application/distributed_run.rs +++ b/src/application/distributed_run.rs @@ -3,33 +3,172 @@ use std::collections::BTreeMap; use async_trait::async_trait; use crate::application::commands::{DistributedRunCommand, DistributedRunMode}; -use crate::args::TesterArgs; use crate::config::types::ScenarioConfig; use crate::error::AppResult; #[async_trait] -pub(crate) trait DistributedRunPort { +pub(crate) trait DistributedRunPort { async fn run_controller( &self, - args: &TesterArgs, + adapter_args: &TAdapterArgs, scenarios: Option>, ) -> AppResult<()>; - async fn run_agent(&self, args: TesterArgs) -> AppResult<()>; + async fn run_agent(&self, adapter_args: &TAdapterArgs) -> AppResult<()>; } -pub(crate) async fn execute( +pub(crate) async fn execute( command: DistributedRunCommand, + adapter_args: TAdapterArgs, distributed_port: &TPort, ) -> AppResult<()> where - TPort: DistributedRunPort + Sync, + TPort: DistributedRunPort + Sync, { - let (args, mode) = command.into_parts(); - match mode { + match command.into_mode() { DistributedRunMode::Controller { scenarios } => { - distributed_port.run_controller(&args, scenarios).await + distributed_port + .run_controller(&adapter_args, scenarios) + .await } - DistributedRunMode::Agent => distributed_port.run_agent(args).await, + DistributedRunMode::Agent => distributed_port.run_agent(&adapter_args).await, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + use super::{DistributedRunPort, execute}; + use crate::application::commands::DistributedRunCommand; + use crate::domain::run::{LoadMode, ProtocolKind, RunConfig}; + use crate::error::AppResult; + + struct FakeDistributedPort { + controller_called: AtomicBool, + agent_called: AtomicBool, + seen_args: Arc>>, + seen_scenarios_len: Arc>>, + } + + #[async_trait::async_trait] + impl DistributedRunPort for FakeDistributedPort { + async fn run_controller( + &self, + adapter_args: &String, + scenarios: Option>, + ) -> AppResult<()> { + self.controller_called.store(true, Ordering::SeqCst); + if let Ok(mut seen) = self.seen_args.lock() { + seen.push(adapter_args.clone()); + } + if let Ok(mut seen_len) = self.seen_scenarios_len.lock() { + *seen_len = Some(scenarios.map(|items| items.len()).unwrap_or(0)); + } + Ok(()) + } + + async fn run_agent(&self, adapter_args: &String) -> AppResult<()> { + self.agent_called.store(true, Ordering::SeqCst); + if let Ok(mut seen) = self.seen_args.lock() { + seen.push(adapter_args.clone()); + } + Ok(()) + } + } + + fn run_config() -> RunConfig { + RunConfig { + protocol: ProtocolKind::Http, + load_mode: LoadMode::Arrival, + target_url: Some("http://localhost".to_owned()), + scenario: None, + } + } + + #[tokio::test(flavor = "current_thread")] + async fn execute_dispatches_controller_mode() -> AppResult<()> { + let mut scenarios = BTreeMap::new(); + scenarios.insert( + "default".to_owned(), + crate::config::types::ScenarioConfig::default(), + ); + let command = DistributedRunCommand::new_controller(run_config(), false, Some(scenarios)); + + let seen_args = Arc::new(Mutex::new(Vec::new())); + let seen_scenarios_len = Arc::new(Mutex::new(None)); + let port = FakeDistributedPort { + controller_called: AtomicBool::new(false), + agent_called: AtomicBool::new(false), + seen_args: seen_args.clone(), + seen_scenarios_len: seen_scenarios_len.clone(), + }; + + execute(command, "controller".to_owned(), &port).await?; + + if !port.controller_called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "expected controller mode to call controller port", + )); + } + if port.agent_called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "agent port should not be called for controller mode", + )); + } + if let Ok(seen) = seen_args.lock() + && seen.as_slice() != ["controller"] + { + return Err(crate::error::AppError::validation( + "expected controller args to be forwarded", + )); + } + if let Ok(seen_len) = seen_scenarios_len.lock() + && *seen_len != Some(1) + { + return Err(crate::error::AppError::validation( + "expected one controller scenario", + )); + } + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn execute_dispatches_agent_mode() -> AppResult<()> { + let command = DistributedRunCommand::new_agent(run_config(), false); + + let seen_args = Arc::new(Mutex::new(Vec::new())); + let seen_scenarios_len = Arc::new(Mutex::new(None)); + let port = FakeDistributedPort { + controller_called: AtomicBool::new(false), + agent_called: AtomicBool::new(false), + seen_args: seen_args.clone(), + seen_scenarios_len, + }; + + execute(command, "agent".to_owned(), &port).await?; + + if !port.agent_called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "expected agent mode to call agent port", + )); + } + if port.controller_called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "controller port should not be called for agent mode", + )); + } + if let Ok(seen) = seen_args.lock() + && seen.as_slice() != ["agent"] + { + return Err(crate::error::AppError::validation( + "expected agent args to be forwarded", + )); + } + + Ok(()) } } diff --git a/src/application/local_run.rs b/src/application/local_run.rs index d2a8da1..15ab38c 100644 --- a/src/application/local_run.rs +++ b/src/application/local_run.rs @@ -7,17 +7,29 @@ use tokio::time::Instant; use tracing::{info, warn}; use crate::app::logs; -use crate::args::TesterArgs; use crate::domain::run::ProtocolKind; use crate::error::{AppError, AppResult, ValidationError}; use crate::metrics::{self, Metrics}; use crate::shutdown::{ShutdownReceiver, ShutdownSender}; use crate::ui::model::UiData; -#[cfg(not(feature = "wasm"))] -use crate::error::ScriptError; -#[cfg(feature = "wasm")] -use crate::wasm_plugins::WasmPluginHost; +#[derive(Debug, Clone)] +pub(crate) struct LocalRunSettings { + pub no_color: bool, + pub no_ui: bool, + pub no_splash: bool, + pub no_charts: bool, + pub summary: bool, + pub show_selections: bool, + pub verbose: bool, + pub target_duration_secs: u64, + pub ui_window_ms: u64, + pub rss_log_ms: Option, + pub alloc_profiler_ms: Option, + pub alloc_profiler_dump_ms: Option, + pub alloc_profiler_dump_path: String, + pub metrics_max: usize, +} #[derive(Debug)] pub(crate) struct RunOutcome { @@ -29,24 +41,27 @@ pub(crate) struct RunOutcome { pub runtime_errors: Vec, } -pub(crate) struct LocalRunExecutionCommand { +pub(crate) struct LocalRunExecutionCommand { protocol: ProtocolKind, - args: TesterArgs, + settings: LocalRunSettings, + adapter_args: TAdapterArgs, stream_tx: Option>, external_shutdown: Option>, } -impl LocalRunExecutionCommand { +impl LocalRunExecutionCommand { #[must_use] pub(crate) const fn new( protocol: ProtocolKind, - args: TesterArgs, + settings: LocalRunSettings, + adapter_args: TAdapterArgs, stream_tx: Option>, external_shutdown: Option>, ) -> Self { Self { protocol, - args, + settings, + adapter_args, stream_tx, external_shutdown, } @@ -57,13 +72,15 @@ impl LocalRunExecutionCommand { self, ) -> ( ProtocolKind, - TesterArgs, + LocalRunSettings, + TAdapterArgs, Option>, Option>, ) { ( self.protocol, - self.args, + self.settings, + self.adapter_args, self.stream_tx, self.external_shutdown, ) @@ -87,26 +104,26 @@ pub(crate) trait ShutdownPort { ) -> tokio::task::JoinHandle<()>; } -pub(crate) trait TrafficPort { +pub(crate) trait TrafficPort { fn setup_request_sender( &self, protocol: ProtocolKind, - args: &TesterArgs, + adapter_args: &TAdapterArgs, shutdown_tx: &ShutdownSender, metrics_tx: &mpsc::Sender, log_sink: Option<&Arc>, ) -> AppResult>; } -pub(crate) trait MetricsPort { +pub(crate) trait MetricsPort { fn setup_metrics_collector( &self, - input: MetricsCollectorInput<'_>, + input: MetricsCollectorInput<'_, TAdapterArgs>, ) -> tokio::task::JoinHandle; } -pub(crate) struct FinalizeRunInput<'args> { - pub args: &'args TesterArgs, +pub(crate) struct FinalizeRunInput<'args, TAdapterArgs> { + pub adapter_args: &'args TAdapterArgs, pub charts_enabled: bool, pub summary_enabled: bool, pub metrics_max: usize, @@ -114,12 +131,10 @@ pub(crate) struct FinalizeRunInput<'args> { pub report: metrics::MetricsReport, pub log_handles: Vec>>, pub log_paths: Vec, - #[cfg(feature = "wasm")] - pub plugin_host: Option<&'args mut WasmPluginHost>, } -pub(crate) struct MetricsCollectorInput<'args> { - pub args: &'args TesterArgs, +pub(crate) struct MetricsCollectorInput<'args, TAdapterArgs> { + pub adapter_args: &'args TAdapterArgs, pub run_start: Instant, pub shutdown_tx: &'args ShutdownSender, pub metrics_rx: mpsc::Receiver, @@ -128,30 +143,31 @@ pub(crate) struct MetricsCollectorInput<'args> { } #[async_trait] -pub(crate) trait OutputPort { +pub(crate) trait OutputPort { + fn prepare_run(&self, adapter_args: &TAdapterArgs) -> AppResult<()>; fn stdout_is_terminal(&self) -> bool; - fn setup_ui_channel(&self, args: &TesterArgs) -> watch::Sender; + fn setup_ui_channel(&self, settings: &LocalRunSettings) -> watch::Sender; async fn run_splash_screen(&self, no_color: bool) -> AppResult; fn setup_rss_log_task( &self, shutdown_tx: &ShutdownSender, no_ui: bool, - interval_ms: Option<&crate::args::PositiveU64>, + interval_ms: Option, ) -> tokio::task::JoinHandle<()>; fn setup_alloc_profiler_task( &self, shutdown_tx: &ShutdownSender, - interval_ms: Option<&crate::args::PositiveU64>, + interval_ms: Option, ) -> tokio::task::JoinHandle<()>; fn setup_alloc_profiler_dump_task( &self, shutdown_tx: &ShutdownSender, - interval_ms: Option<&crate::args::PositiveU64>, + interval_ms: Option, dump_path: &str, ) -> tokio::task::JoinHandle<()>; async fn setup_log_sinks( &self, - args: &TesterArgs, + adapter_args: &TAdapterArgs, run_start: Instant, charts_enabled: bool, summary_enabled: bool, @@ -163,21 +179,24 @@ pub(crate) trait OutputPort { ) -> tokio::task::JoinHandle<()>; fn setup_progress_indicator( &self, - args: &TesterArgs, + adapter_args: &TAdapterArgs, run_start: Instant, shutdown_tx: &ShutdownSender, ) -> tokio::task::JoinHandle<()>; - async fn finalize_run(&self, input: FinalizeRunInput<'_>) -> AppResult; + async fn finalize_run( + &self, + input: FinalizeRunInput<'_, TAdapterArgs>, + ) -> AppResult; } /// Executes the local run use-case against injected ports. /// /// # Errors /// -/// Returns an error when plugin hooks fail, transport setup fails, or +/// Returns an error when adapter setup fails, transport setup fails, or /// downstream output finalization fails. -pub(crate) async fn execute( - command: LocalRunExecutionCommand, +pub(crate) async fn execute( + command: LocalRunExecutionCommand, shutdown_port: &TShutdown, traffic_port: &TTraffic, metrics_port: &TMetrics, @@ -185,23 +204,13 @@ pub(crate) async fn execute( ) -> AppResult where TShutdown: ShutdownPort, - TTraffic: TrafficPort, - TMetrics: MetricsPort, - TOutput: OutputPort + Sync, + TTraffic: TrafficPort, + TMetrics: MetricsPort, + TOutput: OutputPort + Sync, { - let (protocol, args, stream_tx, external_shutdown) = command.into_parts(); + let (protocol, settings, adapter_args, stream_tx, external_shutdown) = command.into_parts(); - #[cfg(feature = "wasm")] - let mut plugin_host = WasmPluginHost::from_paths(&args.plugin)?; - #[cfg(not(feature = "wasm"))] - if !args.plugin.is_empty() { - return Err(AppError::script(ScriptError::WasmFeatureDisabled)); - } - - #[cfg(feature = "wasm")] - if let Some(host) = plugin_host.as_mut() { - host.on_run_start(&args)?; - } + output_port.prepare_run(&adapter_args)?; let (shutdown_tx, _) = shutdown_port.shutdown_channel(); if let Some(external_shutdown) = external_shutdown { @@ -209,28 +218,28 @@ where shutdown_port.bridge_external_shutdown(&shutdown_tx, external_shutdown); } - let ui_tx = output_port.setup_ui_channel(&args); + let ui_tx = output_port.setup_ui_channel(&settings); let (metrics_tx, metrics_rx) = mpsc::channel::(10_000); - let ui_enabled = !args.no_ui && output_port.stdout_is_terminal(); - if !ui_enabled && !args.no_ui { + let ui_enabled = !settings.no_ui && output_port.stdout_is_terminal(); + if !ui_enabled && !settings.no_ui { info!("UI disabled because stdout is not a TTY."); } - let charts_enabled = !args.no_charts; - let summary_enabled = args.summary || args.show_selections; + let charts_enabled = !settings.no_charts; + let summary_enabled = settings.summary || settings.show_selections; let rss_handle = - output_port.setup_rss_log_task(&shutdown_tx, args.no_ui, args.rss_log_ms.as_ref()); + output_port.setup_rss_log_task(&shutdown_tx, settings.no_ui, settings.rss_log_ms); let alloc_handle = - output_port.setup_alloc_profiler_task(&shutdown_tx, args.alloc_profiler_ms.as_ref()); + output_port.setup_alloc_profiler_task(&shutdown_tx, settings.alloc_profiler_ms); let alloc_dump_handle = output_port.setup_alloc_profiler_dump_task( &shutdown_tx, - args.alloc_profiler_dump_ms.as_ref(), - &args.alloc_profiler_dump_path, + settings.alloc_profiler_dump_ms, + &settings.alloc_profiler_dump_path, ); - if ui_enabled && !args.no_splash { - match output_port.run_splash_screen(args.no_color).await { + if ui_enabled && !settings.no_splash { + match output_port.run_splash_screen(settings.no_color).await { Ok(true) => {} Ok(false) => { return Err(AppError::validation(ValidationError::RunCancelled)); @@ -247,12 +256,12 @@ where handles: log_handles, paths: log_paths, } = output_port - .setup_log_sinks(&args, run_start, charts_enabled, summary_enabled) + .setup_log_sinks(&adapter_args, run_start, charts_enabled, summary_enabled) .await?; let request_sender_handle = match traffic_port.setup_request_sender( protocol, - &args, + &adapter_args, &shutdown_tx, &metrics_tx, log_sink.as_ref(), @@ -276,20 +285,20 @@ where } else { tokio::spawn(async {}) }; - let progress_handle = if args.no_ui && !args.verbose { - output_port.setup_progress_indicator(&args, run_start, &shutdown_tx) + let progress_handle = if settings.no_ui && !settings.verbose { + output_port.setup_progress_indicator(&adapter_args, run_start, &shutdown_tx) } else { tokio::spawn(async {}) }; let metrics_handle = metrics_port.setup_metrics_collector(MetricsCollectorInput { - args: &args, + adapter_args: &adapter_args, run_start, shutdown_tx: &shutdown_tx, metrics_rx, ui_tx: &ui_tx, stream_tx, }); - let metrics_max = args.metrics_max.get(); + let metrics_max = settings.metrics_max; let (_, _, _, _, _, _, _, metrics_result, request_result) = tokio::join!( keyboard_shutdown_handle, signal_shutdown_handle, @@ -321,7 +330,7 @@ where output_port .finalize_run(FinalizeRunInput { - args: &args, + adapter_args: &adapter_args, charts_enabled, summary_enabled, metrics_max, @@ -329,8 +338,6 @@ where report, log_handles, log_paths, - #[cfg(feature = "wasm")] - plugin_host: plugin_host.as_mut(), }) .await } @@ -346,6 +353,9 @@ mod tests { use super::*; use crate::app::logs::LogSetup; + #[derive(Debug, Clone, Copy)] + struct FakeAdapterArgs; + struct FakeShutdownPort; impl ShutdownPort for FakeShutdownPort { @@ -378,11 +388,11 @@ mod tests { struct FakeTrafficPort; - impl TrafficPort for FakeTrafficPort { + impl TrafficPort for FakeTrafficPort { fn setup_request_sender( &self, _protocol: ProtocolKind, - _args: &TesterArgs, + _adapter_args: &FakeAdapterArgs, _shutdown_tx: &ShutdownSender, _metrics_tx: &mpsc::Sender, _log_sink: Option<&Arc>, @@ -393,10 +403,10 @@ mod tests { struct FakeMetricsPort; - impl MetricsPort for FakeMetricsPort { + impl MetricsPort for FakeMetricsPort { fn setup_metrics_collector( &self, - _input: MetricsCollectorInput<'_>, + _input: MetricsCollectorInput<'_, FakeAdapterArgs>, ) -> tokio::task::JoinHandle { tokio::spawn(async { metrics::MetricsReport { @@ -413,16 +423,20 @@ mod tests { } #[async_trait] - impl OutputPort for FakeOutputPort { + impl OutputPort for FakeOutputPort { + fn prepare_run(&self, _adapter_args: &FakeAdapterArgs) -> AppResult<()> { + Ok(()) + } + fn stdout_is_terminal(&self) -> bool { self.stdout_terminal } - fn setup_ui_channel(&self, args: &TesterArgs) -> watch::Sender { + fn setup_ui_channel(&self, settings: &LocalRunSettings) -> watch::Sender { let initial_ui = UiData { - target_duration: Duration::from_secs(args.target_duration.get()), - ui_window_ms: args.ui_window_ms.get(), - no_color: args.no_color, + target_duration: Duration::from_secs(settings.target_duration_secs), + ui_window_ms: settings.ui_window_ms, + no_color: settings.no_color, ..UiData::default() }; let (ui_tx, _) = watch::channel(initial_ui); @@ -441,7 +455,7 @@ mod tests { &self, _shutdown_tx: &ShutdownSender, _no_ui: bool, - _interval_ms: Option<&crate::args::PositiveU64>, + _interval_ms: Option, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async {}) } @@ -449,7 +463,7 @@ mod tests { fn setup_alloc_profiler_task( &self, _shutdown_tx: &ShutdownSender, - _interval_ms: Option<&crate::args::PositiveU64>, + _interval_ms: Option, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async {}) } @@ -457,7 +471,7 @@ mod tests { fn setup_alloc_profiler_dump_task( &self, _shutdown_tx: &ShutdownSender, - _interval_ms: Option<&crate::args::PositiveU64>, + _interval_ms: Option, _dump_path: &str, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async {}) @@ -465,7 +479,7 @@ mod tests { async fn setup_log_sinks( &self, - _args: &TesterArgs, + _adapter_args: &FakeAdapterArgs, _run_start: Instant, _charts_enabled: bool, _summary_enabled: bool, @@ -487,14 +501,17 @@ mod tests { fn setup_progress_indicator( &self, - _args: &TesterArgs, + _adapter_args: &FakeAdapterArgs, _run_start: Instant, _shutdown_tx: &ShutdownSender, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async {}) } - async fn finalize_run(&self, _input: FinalizeRunInput<'_>) -> AppResult { + async fn finalize_run( + &self, + _input: FinalizeRunInput<'_, FakeAdapterArgs>, + ) -> AppResult { self.finalize_called.store(true, Ordering::SeqCst); Ok(RunOutcome { summary: logs::empty_summary(), @@ -507,8 +524,23 @@ mod tests { } } - fn parse_args() -> AppResult { - crate::args::parse_test_args(["strest", "--url", "http://localhost"]) + fn default_settings() -> LocalRunSettings { + LocalRunSettings { + no_color: false, + no_ui: false, + no_splash: false, + no_charts: false, + summary: false, + show_selections: false, + verbose: false, + target_duration_secs: 30, + ui_window_ms: 10_000, + rss_log_ms: None, + alloc_profiler_ms: None, + alloc_profiler_dump_ms: None, + alloc_profiler_dump_path: "mem.prof".to_owned(), + metrics_max: 10_000, + } } #[tokio::test(flavor = "current_thread")] @@ -519,8 +551,13 @@ mod tests { splash_cancelled: false, finalize_called: finalize_called.clone(), }; - let args = parse_args()?; - let command = LocalRunExecutionCommand::new(args.protocol.to_domain(), args, None, None); + let command = LocalRunExecutionCommand::new( + ProtocolKind::Http, + default_settings(), + FakeAdapterArgs, + None, + None, + ); let outcome = execute( command, @@ -548,8 +585,13 @@ mod tests { splash_cancelled: true, finalize_called: finalize_called.clone(), }; - let args = parse_args()?; - let command = LocalRunExecutionCommand::new(args.protocol.to_domain(), args, None, None); + let command = LocalRunExecutionCommand::new( + ProtocolKind::Http, + default_settings(), + FakeAdapterArgs, + None, + None, + ); let result = execute( command, diff --git a/src/entry/plan/build.rs b/src/entry/plan/build.rs index 0af7f39..3f9a0ce 100644 --- a/src/entry/plan/build.rs +++ b/src/entry/plan/build.rs @@ -4,7 +4,6 @@ use clap::ArgMatches; use crate::adapters::cli::mapper::{ to_agent_run_command, to_controller_run_command, to_local_run_command, to_replay_run_command, - to_service_command, }; use crate::args::{Command, LoadMode, OutputFormat, TesterArgs}; use crate::config::types::ScenarioConfig; @@ -67,7 +66,8 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.replay { - return Ok(RunPlan::Replay(to_replay_run_command(args))); + let command = to_replay_run_command(&args); + return Ok(RunPlan::Replay { command, args }); } let (mut args, scenario_registry) = apply_config(args, matches)?; @@ -88,7 +88,7 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.install_service || args.uninstall_service { - return Ok(RunPlan::Service(to_service_command(args))); + return Ok(RunPlan::Service(args)); } if args.script.is_some() && args.scenario.is_some() { @@ -108,10 +108,8 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.controller_listen.is_some() { - return Ok(RunPlan::Distributed(to_controller_run_command( - args, - scenario_registry, - ))); + let command = to_controller_run_command(&args, scenario_registry); + return Ok(RunPlan::Distributed { command, args }); } if args.no_ua && !args.authorized { @@ -124,10 +122,13 @@ pub(crate) fn build_plan(mut args: TesterArgs, matches: &ArgMatches) -> AppResul } if args.agent_join.is_some() { - return Ok(RunPlan::Distributed(to_agent_run_command(args))); + let command = to_agent_run_command(&args); + return Ok(RunPlan::Distributed { command, args }); } - Ok(RunPlan::Local(to_local_run_command(args)?)) + args.distributed_stream_summaries = false; + let command = to_local_run_command(&args)?; + Ok(RunPlan::Local { command, args }) } fn apply_config( @@ -264,3 +265,166 @@ fn build_dump_urls_plan(args: &TesterArgs) -> AppResult { max_repeat, }) } + +#[cfg(test)] +mod tests { + use clap::{ArgMatches, CommandFactory, FromArgMatches}; + + use super::build_plan; + use crate::args::TesterArgs; + use crate::entry::plan::types::RunPlan; + use crate::error::AppResult; + + fn parse_args_and_matches(argv: &[&str]) -> AppResult<(TesterArgs, ArgMatches)> { + let cmd = TesterArgs::command(); + let matches = cmd.try_get_matches_from(argv)?; + let args = TesterArgs::from_arg_matches(&matches)?; + Ok((args, matches)) + } + + fn build_from(argv: &[&str]) -> AppResult { + let (args, matches) = parse_args_and_matches(argv)?; + build_plan(args, &matches) + } + + #[test] + fn routes_cleanup_subcommand() -> AppResult<()> { + let plan = build_from(&["strest", "cleanup", "--dry-run"])?; + if !matches!(plan, RunPlan::Cleanup(_)) { + return Err(crate::error::AppError::validation( + "expected cleanup plan for cleanup subcommand", + )); + } + Ok(()) + } + + #[test] + fn routes_compare_subcommand() -> AppResult<()> { + let plan = build_from(&["strest", "compare", "left.json", "right.json"])?; + if !matches!(plan, RunPlan::Compare(_)) { + return Err(crate::error::AppError::validation( + "expected compare plan for compare subcommand", + )); + } + Ok(()) + } + + #[test] + fn routes_replay_mode() -> AppResult<()> { + let plan = build_from(&["strest", "--url", "http://localhost", "--replay"])?; + if !matches!(plan, RunPlan::Replay { .. }) { + return Err(crate::error::AppError::validation( + "expected replay plan when --replay is set", + )); + } + Ok(()) + } + + #[test] + fn routes_dump_urls_mode() -> AppResult<()> { + let plan = build_from(&[ + "strest", + "--url", + "https://example.com/item/[a-z]{2}", + "--rand-regex-url", + "--dump-urls", + "2", + ])?; + if !matches!(plan, RunPlan::DumpUrls(_)) { + return Err(crate::error::AppError::validation( + "expected dump-urls plan when dump flags are set", + )); + } + Ok(()) + } + + #[test] + fn routes_service_mode() -> AppResult<()> { + let plan = build_from(&["strest", "--install-service"])?; + if let RunPlan::Service(args) = plan { + if !args.install_service { + return Err(crate::error::AppError::validation( + "expected install-service flag in service plan", + )); + } + } else { + return Err(crate::error::AppError::validation( + "expected service plan when service flags are set", + )); + } + Ok(()) + } + + #[test] + fn routes_distributed_controller_mode() -> AppResult<()> { + let plan = build_from(&[ + "strest", + "--url", + "http://localhost", + "--controller-listen", + "127.0.0.1:9009", + ])?; + if let RunPlan::Distributed { command, args } = plan { + if command.mode_name() != "controller" { + return Err(crate::error::AppError::validation( + "expected controller distributed command mode", + )); + } + if args.controller_listen.is_none() { + return Err(crate::error::AppError::validation( + "expected controller listen address to be preserved", + )); + } + } else { + return Err(crate::error::AppError::validation( + "expected distributed plan in controller mode", + )); + } + Ok(()) + } + + #[test] + fn routes_distributed_agent_mode() -> AppResult<()> { + let plan = build_from(&[ + "strest", + "--url", + "http://localhost", + "--agent-join", + "127.0.0.1:9009", + ])?; + if let RunPlan::Distributed { command, args } = plan { + if command.mode_name() != "agent" { + return Err(crate::error::AppError::validation( + "expected agent distributed command mode", + )); + } + if args.agent_join.is_none() { + return Err(crate::error::AppError::validation( + "expected agent join address to be preserved", + )); + } + } else { + return Err(crate::error::AppError::validation( + "expected distributed plan in agent mode", + )); + } + Ok(()) + } + + #[test] + fn routes_local_mode_and_disables_distributed_streaming() -> AppResult<()> { + let plan = build_from(&["strest", "--url", "http://localhost"])?; + if let RunPlan::Local { args, .. } = plan { + if args.distributed_stream_summaries { + return Err(crate::error::AppError::validation( + "local mode should disable distributed stream summaries", + )); + } + } else { + return Err(crate::error::AppError::validation( + "expected local plan for default run path", + )); + } + Ok(()) + } +} diff --git a/src/entry/plan/execute.rs b/src/entry/plan/execute.rs index 43e371d..ab48501 100644 --- a/src/entry/plan/execute.rs +++ b/src/entry/plan/execute.rs @@ -18,29 +18,29 @@ pub(crate) async fn execute_plan(plan: RunPlan) -> AppResult<()> { match plan { RunPlan::Cleanup(cleanup_args) => run_cleanup(&cleanup_args).await, RunPlan::Compare(compare_args) => run_compare(&compare_args).await, - RunPlan::Replay(command) => { + RunPlan::Replay { command, args } => { log_run_command("replay", command.run_config()); banner::print_cli_banner(command.no_color()); println!(); - run_replay(command.as_args()).await + run_replay(&args).await } RunPlan::DumpUrls(plan) => dump_urls(plan), - RunPlan::Service(command) => { - crate::service::handle_service_action(command.as_args())?; + RunPlan::Service(args) => { + crate::service::handle_service_action(&args)?; Ok(()) } - RunPlan::Distributed(command) => { + RunPlan::Distributed { command, args } => { log_run_command(command.mode_name(), command.run_config()); banner::print_cli_banner(command.no_color()); println!(); let distributed_port = RuntimeDistributedPort; - distributed_run::execute(command, &distributed_port).await + distributed_run::execute(command, args, &distributed_port).await } - RunPlan::Local(command) => { + RunPlan::Local { command, args } => { log_run_command("local", command.run_config()); banner::print_cli_banner(command.no_color()); println!(); - let outcome = match run_local(command.into_args(), None, None).await { + let outcome = match run_local(args, None, None).await { Ok(outcome) => outcome, Err(AppError::Validation(ValidationError::RunCancelled)) => return Ok(()), Err(err) => return Err(err), @@ -57,17 +57,17 @@ pub(crate) async fn execute_plan(plan: RunPlan) -> AppResult<()> { struct RuntimeDistributedPort; #[async_trait] -impl DistributedRunPort for RuntimeDistributedPort { +impl DistributedRunPort for RuntimeDistributedPort { async fn run_controller( &self, - args: &TesterArgs, + adapter_args: &TesterArgs, scenarios: Option>, ) -> AppResult<()> { - crate::distributed::run_controller(args, scenarios).await + crate::distributed::run_controller(adapter_args, scenarios).await } - async fn run_agent(&self, args: TesterArgs) -> AppResult<()> { - crate::distributed::run_agent(args).await + async fn run_agent(&self, adapter_args: &TesterArgs) -> AppResult<()> { + crate::distributed::run_agent(adapter_args.clone()).await } } diff --git a/src/entry/plan/types.rs b/src/entry/plan/types.rs index 90081b5..2e50d4f 100644 --- a/src/entry/plan/types.rs +++ b/src/entry/plan/types.rs @@ -1,7 +1,5 @@ -use crate::application::commands::{ - DistributedRunCommand, LocalRunCommand, ReplayRunCommand, ServiceCommand, -}; -use crate::args::{CleanupArgs, CompareArgs}; +use crate::application::commands::{DistributedRunCommand, LocalRunCommand, ReplayRunCommand}; +use crate::args::{CleanupArgs, CompareArgs, TesterArgs}; pub(in crate::entry) struct DumpUrlsPlan { pub(super) pattern: String, @@ -12,9 +10,18 @@ pub(in crate::entry) struct DumpUrlsPlan { pub(in crate::entry) enum RunPlan { Cleanup(CleanupArgs), Compare(CompareArgs), - Replay(ReplayRunCommand), + Replay { + command: ReplayRunCommand, + args: TesterArgs, + }, DumpUrls(DumpUrlsPlan), - Service(ServiceCommand), - Distributed(DistributedRunCommand), - Local(LocalRunCommand), + Service(TesterArgs), + Distributed { + command: DistributedRunCommand, + args: TesterArgs, + }, + Local { + command: LocalRunCommand, + args: TesterArgs, + }, } diff --git a/src/error/script.rs b/src/error/script.rs index 52039d4..0cc2cf2 100644 --- a/src/error/script.rs +++ b/src/error/script.rs @@ -21,6 +21,7 @@ pub enum ScriptError { #[from] source: ConfigError, }, + #[cfg(not(feature = "wasm"))] #[error("WASM scripting requires the 'wasm' feature.")] WasmFeatureDisabled, #[cfg(feature = "wasm")] @@ -52,8 +53,6 @@ pub enum WasmSection { Table, #[error("data")] Data, - #[error("function body")] - FunctionBody, } #[cfg(feature = "wasm")] From 6d8b4e87e476471b09b5ce2d3396d021e6cc566f Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 14 Feb 2026 17:42:37 +0100 Subject: [PATCH 2/4] feat: finalize phase7 boundaries and add architecture technical patterns --- AGENTS.md | 1 - CHANGELOG.md | 3 + docs/README.md | 5 +- docs/architecture/README.md | 2 - .../adr/ADR-0001-hexagonal-vertical-slices.md | 2 - .../ard/ARCHITECTURE_BASELINE_METRICS.md | 56 - .../architecture/ard/ARCHITECTURE_OVERVIEW.md | 1054 ++--------------- .../ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md | 391 ------ docs/architecture/ard/README.md | 2 - docs/architecture/patterns/README.md | 1 + .../type-safety-performance-concurrency.md | 97 ++ scripts/check_architecture.sh | 11 +- src/adapters/mod.rs | 1 + src/adapters/runtime/execution_ports.rs | 106 ++ src/adapters/runtime/mod.rs | 6 + src/app/compare.rs | 6 +- src/app/logs.rs | 4 - src/app/logs/merge.rs | 18 - src/app/mod.rs | 2 - src/app/replay/runner.rs | 6 +- src/app/replay/state.rs | 2 +- src/app/runner/core/mod.rs | 10 +- src/app/runtime_errors.rs | 6 - src/app/summary.rs | 2 +- src/app/summary/lines.rs | 118 +- src/application/local_run.rs | 41 +- src/application/mod.rs | 2 +- src/application/slice_execution.rs | 304 +++++ src/distributed/agent.rs | 11 +- src/distributed/agent/run_exec.rs | 31 +- src/distributed/agent/session.rs | 21 +- .../controller/tests/aggregation.rs | 123 +- src/distributed/controller/tests/events.rs | 203 ++++ src/distributed/controller/tests/mod.rs | 5 +- src/distributed/mod.rs | 4 +- src/distributed/summary.rs | 9 +- src/distributed/tests/mod.rs | 29 +- src/distributed/tests/stability.rs | 126 ++ src/entry/plan/execute.rs | 51 +- src/system/mod.rs | 4 + src/{application => system}/replay_compare.rs | 0 src/system/summary_output.rs | 115 ++ 42 files changed, 1348 insertions(+), 1643 deletions(-) delete mode 100644 docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md delete mode 100644 docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md create mode 100644 docs/architecture/patterns/type-safety-performance-concurrency.md create mode 100644 src/adapters/runtime/execution_ports.rs create mode 100644 src/adapters/runtime/mod.rs delete mode 100644 src/app/runtime_errors.rs create mode 100644 src/application/slice_execution.rs create mode 100644 src/distributed/controller/tests/events.rs create mode 100644 src/distributed/tests/stability.rs rename src/{application => system}/replay_compare.rs (100%) create mode 100644 src/system/summary_output.rs diff --git a/AGENTS.md b/AGENTS.md index a21f595..87d8e54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,6 @@ When making changes, follow this order: - `docs/guides/ADVANCED.md` - `docs/architecture/README.md` - `docs/architecture/ard/ARCHITECTURE_OVERVIEW.md` - - `docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md` ## Required Contribution Workflow 1. Create a scoped branch. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a50e2e..8a543f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,11 @@ The format is based on Keep a Changelog, and this project follows SemVer. - Completed Phase 7 of the hexagonal migration by removing direct `TesterArgs` coupling from the application layer (`application::commands`, `application::local_run`, `application::distributed_run`) and tightening adapter-boundary mapping in entry planning. - Added architecture guardrails that fail checks when `src/application` reintroduces `TesterArgs` or `crate::args` imports. +- Removed remaining non-test direct `distributed -> app` and `application -> app` imports by introducing explicit runtime ports and shared summary-output utilities, so orchestration flows now route via application/adapters seams. - Added migration validation coverage for all run-plan feature routes (local, distributed controller/agent, replay, compare, cleanup, dump-urls, service) and distributed/local application dispatch seams. - Updated architecture docs with Phase 7 implementation artifacts, current coupling metrics snapshot, and Mermaid before/after boundary diagrams. +- Added technical architecture patterns doc covering type-level invariants/newtypes, invalid-state elimination, cache/inlining guidance, dispatch strategy (static-first), and low-lock concurrency patterns using `Arc`, atomics, channels, and `ArcShift`. +- Simplified architecture references by removing legacy migration-risk and baseline-metrics ARD documents and retaining a current flow-focused architecture overview. ## 0.1.9 diff --git a/docs/README.md b/docs/README.md index 4a52dbe..7e28ab3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,10 +10,9 @@ This folder is organized by concern: ## Architecture - `docs/architecture/README.md`: architecture document taxonomy and conventions. -- `docs/architecture/ard/ARCHITECTURE_OVERVIEW.md`: generated module/dependency overview. -- `docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md`: architecture risks and phased migration plan. -- `docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md`: migration baseline coupling metrics. +- `docs/architecture/ard/ARCHITECTURE_OVERVIEW.md`: current mode-by-mode call-flow overview. - `docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md`: accepted architecture decision. +- `docs/architecture/patterns/type-safety-performance-concurrency.md`: technical patterns for type invariants, dispatch, cache behavior, and lock-free concurrency. ## Assets diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3a33eff..238a414 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,8 +13,6 @@ This directory is split by architecture document type. ## Current Canonical Docs - `docs/architecture/ard/ARCHITECTURE_OVERVIEW.md` -- `docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md` -- `docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md` - `docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md` ## Naming Conventions diff --git a/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md b/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md index 35d5f05..2bc301e 100644 --- a/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md +++ b/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md @@ -8,8 +8,6 @@ `strest` currently works as a modular monolith, but core behavior is coupled to adapter concerns, especially CLI (`TesterArgs`) and runtime IO wiring. The migration target is vertical slices with explicit ports/adapters boundaries, without a big-bang rewrite. -The primary risks are documented in `docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md`. - ## Decision Adopt a phased migration architecture with these boundaries: diff --git a/docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md b/docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md deleted file mode 100644 index de3fee8..0000000 --- a/docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md +++ /dev/null @@ -1,56 +0,0 @@ -# Architecture Baseline Metrics - -_Snapshot date: 2026-02-13_ - -This baseline is used to track migration progress toward vertical slices with hexagonal boundaries. - -## Counting Method - -- Scope: `src/**/*.rs`. -- Excludes tests: `**/tests/**`, `**/tests.rs`, `**/test_*.rs`, `**/*_test.rs`. -- Source of truth: `scripts/check_architecture.sh`. - -## Baseline - -- `non_test_rust_files`: `209` -- `files_referencing_crate_args`: `71` -- `files_referencing_tester_args`: `62` - -## Top Cross-Module Edges (Top 10) - -- `distributed -> args` (`22`) -- `distributed -> error` (`18`) -- `app -> error` (`17`) -- `charts -> error` (`16`) -- `app -> metrics` (`16`) -- `config -> error` (`13`) -- `charts -> metrics` (`13`) -- `app -> args` (`12`) -- `config -> args` (`11`) -- `protocol -> args` (`10`) - -## Phase 7 Snapshot - -_Snapshot date: 2026-02-14_ - -- `non_test_rust_files`: `220` -- `files_referencing_crate_args`: `70` -- `files_referencing_tester_args`: `65` - -## Phase 7 Top Cross-Module Edges (Top 10) - -- `distributed -> args` (`22`) -- `distributed -> error` (`19`) -- `app -> error` (`17`) -- `charts -> error` (`16`) -- `app -> metrics` (`15`) -- `config -> error` (`13`) -- `charts -> metrics` (`13`) -- `app -> args` (`12`) -- `config -> args` (`11`) -- `protocol -> domain` (`7`) - -## Interpretation - -- `files_referencing_crate_args` decreased from baseline (`71` -> `70`). -- `files_referencing_tester_args` increased from the original baseline due expanded migration tests in non-test module files; Phase 7 guardrails now hard-fail application-layer `TesterArgs` coupling to keep new architecture seams clean while remaining infra modules continue incremental migration. diff --git a/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md b/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md index 4546f62..f38399f 100644 --- a/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md +++ b/docs/architecture/ard/ARCHITECTURE_OVERVIEW.md @@ -1,996 +1,118 @@ # Strest Architecture Overview -_Generated from `src/**/*.rs` on 2026-02-13 13:05:45 UTC_ +_Generated from `src/**/*.rs` on 2026-02-14 16:15:09 UTC_ -_Phase 7 migration annotations updated on 2026-02-14._ +_Purpose: direct call-flow view of the current architecture with explicit slice boundaries._ -## Scope -- Module inventory includes all source modules under `src/` (including test modules inside `src`). -- Dependency graph edges are derived from `crate::...` references in non-test source files only. -- Feature-gated modules: `wasm_plugins`, `wasm_runtime`, `fuzzing`, and legacy chart implementations. +## Boundary Contract +1. `entry` calls `application` only. +2. `application` orchestrates use-cases and calls ports only. +3. `adapters::runtime` implements those ports and calls concrete runtime slices. +4. Runtime slices (`app`, `distributed`, `service`) do not call each other directly. +5. Boundary checks are enforced by `scripts/check_architecture.sh`. -## Runtime Flow +## No-Spiderweb View (Current) ```mermaid -flowchart TD - main["main.rs"] --> entry["entry::run"] - entry --> planBuild["entry::plan::build_plan"] - entry --> planExec["entry::plan::execute_plan"] +flowchart LR + main["main::main"] --> entry["entry::run"] + entry --> plan["entry::plan::{build, execute}"] + + plan --> appSlice["application::slice_execution"] + plan --> appDist["application::distributed_run"] - planExec --> local["app::run_local"] - planExec --> controller["distributed::run_controller"] - planExec --> agent["distributed::run_agent"] - planExec --> replay["app::run_replay"] - planExec --> compare["app::run_compare"] - planExec --> cleanup["app::run_cleanup"] - planExec --> service["service::handle_service_action"] + appSlice --> rtLocal["RuntimeLocalPort"] + appSlice --> rtReplay["RuntimeReplayPort"] + appSlice --> rtCompare["RuntimeComparePort"] + appSlice --> rtCleanup["RuntimeCleanupPort"] + appSlice --> rtService["RuntimeServicePort"] + appDist --> rtDistributed["RuntimeDistributedPort"] - local --> proto["protocol::setup_request_sender"] - local --> metrics["metrics::setup_metrics_collector"] - local --> ui["ui::render::setup_render_ui"] - local --> logs["app::logs::setup_log_sinks"] + rtLocal --> local["app::run_local"] + rtReplay --> replay["app::run_replay"] + rtCompare --> compare["app::run_compare"] + rtCleanup --> cleanup["app::run_cleanup"] + rtService --> service["service::handle_service_action"] + rtDistributed --> controller["distributed::run_controller"] + rtDistributed --> agent["distributed::run_agent"] +``` - proto --> http["http::setup_request_sender"] - proto --> transports["protocol::runtime::{tcp/udp/ws/grpc/mqtt}"] +## Flow By Mode - controller --> ctrlRunner["distributed::controller::runner"] - ctrlRunner --> ctrlAuto["controller::auto::*"] - ctrlRunner --> ctrlManual["controller::manual::*"] - ctrlAuto --> wire["distributed::protocol::{read_message/send_message}"] - ctrlManual --> wire +### Local Run +Call chain: +`main -> entry::run -> entry::plan::execute_plan -> application::slice_execution::execute_local -> adapters::runtime::RuntimeLocalPort::run_local -> app::run_local` - agent --> agentSession["distributed::agent::session"] - agentSession --> wire +```mermaid +flowchart LR + p["entry::plan::execute_plan"] --> a["slice_execution::execute_local"] + a --> r["RuntimeLocalPort::run_local"] + r --> l["app::run_local"] + l --> pr["protocol::setup_request_sender"] + l --> mc["metrics::setup_metrics_collector"] + l --> ui["ui::render::setup_render_ui"] ``` -## Hexagonal Migration Snapshot (Phase 7) +### Replay +Call chain: +`main -> entry -> slice_execution::execute_replay -> RuntimeReplayPort::run_replay -> app::run_replay` -### Before (Legacy Coupling Path) ```mermaid flowchart LR - cli["CLI parse (`TesterArgs`)"] - plan["entry::plan::build_plan"] - commands["application::commands\n(owned `TesterArgs`)"] - local["application::local_run\n(direct `TesterArgs`)"] - dist["application::distributed_run\n(direct `TesterArgs`)"] - runtime["Runtime adapters + infra"] - - cli --> plan --> commands - commands --> local --> runtime - commands --> dist --> runtime + p["entry::plan::execute_plan"] --> a["slice_execution::execute_replay"] + a --> r["RuntimeReplayPort::run_replay"] + r --> l["app::run_replay"] ``` -### After (Phase 7 Boundary) +### Compare +Call chain: +`main -> entry -> slice_execution::execute_compare -> RuntimeComparePort::run_compare -> app::run_compare` + ```mermaid flowchart LR - cli["CLI/config adapters (`TesterArgs`)"] - mapper["adapters::cli::mapper"] - plan["entry::plan\n(command + adapter payload)"] - appcmd["application::commands\n(run metadata only)"] - applocal["application::local_run\n(generic ports + `LocalRunSettings`)"] - appdist["application::distributed_run\n(generic dispatch)"] - runtime["Runtime adapters + infra (`TesterArgs`)"] - - cli --> mapper --> plan - plan --> appcmd - appcmd --> applocal - appcmd --> appdist - plan --> runtime - applocal --> runtime - appdist --> runtime + p["entry::plan::execute_plan"] --> a["slice_execution::execute_compare"] + a --> r["RuntimeComparePort::run_compare"] + r --> l["app::run_compare"] ``` -## Top-Level Dependency Graph +### Cleanup +Call chain: +`main -> entry -> slice_execution::execute_cleanup -> RuntimeCleanupPort::run_cleanup -> app::run_cleanup` + ```mermaid flowchart LR - n1["app (30)"] - n2["args (13)"] - n3["charts (24)"] - n4["config (16)"] - n5["distributed (42)"] - n6["entry (5)"] - n7["error (11)"] - n8["fuzzing (1)"] - n9["http (15)"] - n10["lib (1)"] - n11["main (1)"] - n12["metrics (14)"] - n13["protocol (21)"] - n14["script (2)"] - n15["service (1)"] - n16["shutdown (1)"] - n17["sinks (4)"] - n18["system (5)"] - n19["ui (17)"] - n20["wasm_plugins (5)"] - n21["wasm_runtime (7)"] - - n1 -->|16| n2 - n1 -->|1| n3 - n1 -->|17| n7 - n1 -->|16| n12 - n1 -->|3| n16 - n1 -->|5| n18 - n1 -->|7| n19 - n1 -->|2| n20 - n2 -->|2| n7 - n2 -->|1| n12 - n2 -->|1| n17 - n3 -->|2| n2 - n3 -->|16| n7 - n3 -->|14| n12 - n4 -->|17| n2 - n4 -->|15| n7 - n4 -->|1| n12 - n4 -->|1| n17 - n5 -->|2| n1 - n5 -->|23| n2 - n5 -->|1| n3 - n5 -->|5| n4 - n5 -->|18| n7 - n5 -->|10| n12 - n5 -->|4| n16 - n5 -->|7| n17 - n5 -->|2| n18 - n5 -->|4| n19 - n6 -->|1| n1 - n6 -->|3| n2 - n6 -->|4| n4 - n6 -->|2| n5 - n6 -->|5| n7 - n6 -->|1| n13 - n6 -->|1| n14 - n6 -->|1| n15 - n6 -->|2| n18 - n8 -->|1| n2 - n8 -->|4| n4 - n8 -->|1| n7 - n8 -->|2| n9 - n8 -->|1| n12 - n9 -->|2| n2 - n9 -->|1| n7 - n12 -->|5| n7 - n12 -->|1| n16 - n12 -->|2| n19 - n13 -->|22| n2 - n13 -->|4| n7 - n13 -->|2| n9 - n13 -->|4| n12 - n13 -->|4| n16 - n14 -->|1| n2 - n14 -->|2| n7 - n14 -->|1| n21 - n15 -->|1| n2 - n15 -->|1| n7 - n17 -->|2| n7 - n18 -->|1| n7 - n18 -->|1| n16 - n19 -->|2| n7 - n19 -->|1| n16 - n20 -->|1| n2 - n20 -->|2| n7 - n20 -->|1| n12 - n21 -->|1| n2 - n21 -->|3| n4 - n21 -->|3| n7 + p["entry::plan::execute_plan"] --> a["slice_execution::execute_cleanup"] + a --> r["RuntimeCleanupPort::run_cleanup"] + r --> l["app::run_cleanup"] ``` -## Complete Module Hierarchy +### Distributed Controller +Call chain: +`main -> entry -> distributed_run::execute(controller) -> RuntimeDistributedPort::run_controller -> distributed::run_controller` + ```mermaid -flowchart TB - n22["crate"] - n22 --> n1 - n22 --> n2 - n22 --> n3 - n22 --> n4 - n22 --> n5 - n22 --> n6 - n22 --> n7 - n22 --> n8 - n22 --> n9 - n22 --> n10 - n22 --> n11 - n22 --> n12 - n22 --> n13 - n22 --> n14 - n22 --> n15 - n22 --> n16 - n22 --> n17 - n22 --> n18 - n22 --> n19 - n22 --> n20 - n22 --> n21 - subgraph sg_app["app"] - n1["app"] - n23["app::cleanup"] - n24["app::compare"] - n25["app::compare::compare_output"] - n26["app::export"] - n27["app::logs"] - n28["app::logs::merge"] - n29["app::logs::parsing"] - n30["app::logs::records"] - n31["app::logs::setup"] - n32["app::logs::streaming"] - n33["app::progress"] - n34["app::replay"] - n35["app::replay::bounds"] - n36["app::replay::records"] - n37["app::replay::runner"] - n38["app::replay::snapshots"] - n39["app::replay::state"] - n40["app::replay::summary"] - n41["app::replay::tests"] - n42["app::replay::ui"] - n43["app::runner"] - n44["app::runner::alloc"] - n45["app::runner::core"] - n46["app::runner::core::finalize"] - n47["app::runner::rss"] - n48["app::runtime_errors"] - n49["app::summary"] - n50["app::summary::lines"] - n51["app::summary::percentiles"] - n1 --> n23 - n1 --> n24 - n24 --> n25 - n1 --> n26 - n1 --> n27 - n27 --> n28 - n27 --> n29 - n27 --> n30 - n27 --> n31 - n27 --> n32 - n1 --> n33 - n1 --> n34 - n34 --> n35 - n34 --> n36 - n34 --> n37 - n34 --> n38 - n34 --> n39 - n34 --> n40 - n34 --> n41 - n34 --> n42 - n1 --> n43 - n43 --> n44 - n43 --> n45 - n45 --> n46 - n43 --> n47 - n1 --> n48 - n1 --> n49 - n49 --> n50 - n49 --> n51 - end - subgraph sg_args["args"] - n2["args"] - n52["args::cli"] - n53["args::cli::presets"] - n54["args::cli::tester"] - n55["args::defaults"] - n56["args::parsers"] - n57["args::tests"] - n58["args::tests::defaults"] - n59["args::tests::headers"] - n60["args::tests::options_core"] - n61["args::tests::options_extra"] - n62["args::tests::subcommands"] - n63["args::types"] - n2 --> n52 - n52 --> n53 - n52 --> n54 - n2 --> n55 - n2 --> n56 - n2 --> n57 - n57 --> n58 - n57 --> n59 - n57 --> n60 - n57 --> n61 - n57 --> n62 - n2 --> n63 - end - subgraph sg_charts["charts"] - n3["charts"] - n64["charts::aggregated"] - n65["charts::aggregated::buckets"] - n66["charts::aggregated::latency"] - n67["charts::aggregated::rps"] - n68["charts::aggregated::util"] - n69["charts::average"] - n70["charts::cumulative"] - n71["charts::driver"] - n72["charts::driver::naming"] - n73["charts::driver::plotting"] - n74["charts::errors"] - n75["charts::inflight"] - n76["charts::latency"] - n77["charts::rps"] - n78["charts::status"] - n79["charts::streaming"] - n80["charts::streaming::basic"] - n81["charts::streaming::basic::buckets"] - n82["charts::streaming::basic::counts"] - n83["charts::streaming::breakdown"] - n84["charts::streaming::latency"] - n85["charts::tests"] - n86["charts::timeouts"] - n3 --> n64 - n64 --> n65 - n64 --> n66 - n64 --> n67 - n64 --> n68 - n3 --> n69 - n3 --> n70 - n3 --> n71 - n71 --> n72 - n71 --> n73 - n3 --> n74 - n3 --> n75 - n3 --> n76 - n3 --> n77 - n3 --> n78 - n3 --> n79 - n79 --> n80 - n80 --> n81 - n80 --> n82 - n79 --> n83 - n79 --> n84 - n3 --> n85 - n3 --> n86 - end - subgraph sg_config["config"] - n4["config"] - n87["config::apply"] - n88["config::apply::distributed"] - n89["config::apply::load"] - n90["config::apply::scenario"] - n91["config::apply::section_basic"] - n92["config::apply::section_runtime"] - n93["config::apply::section_runtime::section_runtime_network"] - n94["config::apply::section_runtime::section_runtime_output"] - n95["config::apply::section_tail"] - n96["config::apply::util"] - n97["config::loader"] - n98["config::parse"] - n99["config::test_support"] - n100["config::tests"] - n101["config::types"] - n4 --> n87 - n87 --> n88 - n87 --> n89 - n87 --> n90 - n87 --> n91 - n87 --> n92 - n92 --> n93 - n92 --> n94 - n87 --> n95 - n87 --> n96 - n4 --> n97 - n4 --> n98 - n4 --> n99 - n4 --> n100 - n4 --> n101 - end - subgraph sg_distributed["distributed"] - n5["distributed"] - n102["distributed::agent"] - n103["distributed::agent::command"] - n104["distributed::agent::run_exec"] - n105["distributed::agent::session"] - n106["distributed::agent::wire"] - n107["distributed::controller"] - n108["distributed::controller::agent"] - n109["distributed::controller::auto"] - n110["distributed::controller::auto::events"] - n111["distributed::controller::auto::finalize"] - n112["distributed::controller::auto::setup"] - n113["distributed::controller::control"] - n114["distributed::controller::http"] - n115["distributed::controller::load"] - n116["distributed::controller::manual"] - n117["distributed::controller::manual::connections"] - n118["distributed::controller::manual::control_http"] - n119["distributed::controller::manual::loop_handlers"] - n120["distributed::controller::manual::loop_idle"] - n121["distributed::controller::manual::orchestrator"] - n122["distributed::controller::manual::run_finalize"] - n123["distributed::controller::manual::run_lifecycle"] - n124["distributed::controller::manual::state"] - n125["distributed::controller::runner"] - n126["distributed::controller::shared"] - n127["distributed::controller::shared::aggregation"] - n128["distributed::controller::shared::events"] - n129["distributed::controller::shared::timing"] - n130["distributed::controller::shared::ui"] - n131["distributed::controller::tests"] - n132["distributed::controller::tests::aggregation"] - n133["distributed::controller::tests::ui"] - n134["distributed::protocol"] - n135["distributed::protocol::io"] - n136["distributed::protocol::types"] - n137["distributed::summary"] - n138["distributed::tests"] - n139["distributed::tests::sink_runs"] - n140["distributed::tests::wire_args"] - n141["distributed::utils"] - n142["distributed::wire"] - n5 --> n102 - n102 --> n103 - n102 --> n104 - n102 --> n105 - n102 --> n106 - n5 --> n107 - n107 --> n108 - n107 --> n109 - n109 --> n110 - n109 --> n111 - n109 --> n112 - n107 --> n113 - n107 --> n114 - n107 --> n115 - n107 --> n116 - n116 --> n117 - n116 --> n118 - n116 --> n119 - n116 --> n120 - n116 --> n121 - n116 --> n122 - n116 --> n123 - n116 --> n124 - n107 --> n125 - n107 --> n126 - n126 --> n127 - n126 --> n128 - n126 --> n129 - n126 --> n130 - n107 --> n131 - n131 --> n132 - n131 --> n133 - n5 --> n134 - n134 --> n135 - n134 --> n136 - n5 --> n137 - n5 --> n138 - n138 --> n139 - n138 --> n140 - n5 --> n141 - n5 --> n142 - end - subgraph sg_entry["entry"] - n6["entry"] - n143["entry::plan"] - n144["entry::plan::build"] - n145["entry::plan::execute"] - n146["entry::plan::types"] - n6 --> n143 - n143 --> n144 - n143 --> n145 - n143 --> n146 - end - subgraph sg_error["error"] - n7["error"] - n147["error::app"] - n148["error::config"] - n149["error::distributed"] - n150["error::http"] - n151["error::metrics"] - n152["error::script"] - n153["error::service"] - n154["error::sink"] - n155["error::test_support"] - n156["error::validation"] - n7 --> n147 - n7 --> n148 - n7 --> n149 - n7 --> n150 - n7 --> n151 - n7 --> n152 - n7 --> n153 - n7 --> n154 - n7 --> n155 - n7 --> n156 - end - subgraph sg_fuzzing["fuzzing"] - n8["fuzzing"] - end - subgraph sg_http["http"] - n9["http"] - n157["http::rate"] - n158["http::sender"] - n159["http::sender::config"] - n160["http::sender::worker"] - n161["http::tests"] - n162["http::tls"] - n163["http::workload"] - n164["http::workload::builders"] - n165["http::workload::builders_auth"] - n166["http::workload::data"] - n167["http::workload::execution"] - n168["http::workload::runner"] - n169["http::workload::runner_common"] - n170["http::workload::template"] - n9 --> n157 - n9 --> n158 - n158 --> n159 - n158 --> n160 - n9 --> n161 - n9 --> n162 - n9 --> n163 - n163 --> n164 - n163 --> n165 - n163 --> n166 - n163 --> n167 - n163 --> n168 - n163 --> n169 - n163 --> n170 - end - subgraph sg_lib["lib"] - n10["lib"] - end - subgraph sg_main["main"] - n11["main"] - end - subgraph sg_metrics["metrics"] - n12["metrics"] - n171["metrics::collector"] - n172["metrics::collector::helpers"] - n173["metrics::collector::helpers::processing"] - n174["metrics::collector::helpers::summary"] - n175["metrics::collector::helpers::windows"] - n176["metrics::collector::state"] - n177["metrics::histogram"] - n178["metrics::logging"] - n179["metrics::logging::reader"] - n180["metrics::logging::writer"] - n181["metrics::logging::writer::db"] - n182["metrics::tests"] - n183["metrics::types"] - n12 --> n171 - n171 --> n172 - n172 --> n173 - n172 --> n174 - n172 --> n175 - n171 --> n176 - n12 --> n177 - n12 --> n178 - n178 --> n179 - n178 --> n180 - n180 --> n181 - n12 --> n182 - n12 --> n183 - end - subgraph sg_protocol["protocol"] - n13["protocol"] - n184["protocol::builtins"] - n185["protocol::examples"] - n186["protocol::examples::chat_websocket"] - n187["protocol::examples::game_udp"] - n188["protocol::examples::telemetry_mqtt"] - n189["protocol::registry"] - n190["protocol::runtime"] - n191["protocol::runtime::datagram"] - n192["protocol::runtime::grpc"] - n193["protocol::runtime::mqtt"] - n194["protocol::runtime::resolve"] - n195["protocol::runtime::spawner"] - n196["protocol::runtime::tests"] - n197["protocol::runtime::tests::datagram_mqtt"] - n198["protocol::runtime::tests::scheme_resolution"] - n199["protocol::runtime::tests::transport_http_grpc"] - n200["protocol::runtime::transports"] - n201["protocol::runtime::types"] - n202["protocol::tests"] - n203["protocol::traits"] - n13 --> n184 - n13 --> n185 - n185 --> n186 - n185 --> n187 - n185 --> n188 - n13 --> n189 - n13 --> n190 - n190 --> n191 - n190 --> n192 - n190 --> n193 - n190 --> n194 - n190 --> n195 - n190 --> n196 - n196 --> n197 - n196 --> n198 - n196 --> n199 - n190 --> n200 - n190 --> n201 - n13 --> n202 - n13 --> n203 - end - subgraph sg_script["script"] - n14["script"] - n204["script::loader"] - n14 --> n204 - end - subgraph sg_service["service"] - n15["service"] - end - subgraph sg_shutdown["shutdown"] - n16["shutdown"] - end - subgraph sg_sinks["sinks"] - n17["sinks"] - n205["sinks::config"] - n206["sinks::format"] - n207["sinks::writers"] - n17 --> n205 - n17 --> n206 - n17 --> n207 - end - subgraph sg_system["system"] - n18["system"] - n208["system::banner"] - n209["system::logger"] - n210["system::probestack"] - n211["system::shutdown_handlers"] - n18 --> n208 - n18 --> n209 - n18 --> n210 - n18 --> n211 - end - subgraph sg_ui["ui"] - n19["ui"] - n212["ui::model"] - n213["ui::render"] - n214["ui::render::charts"] - n215["ui::render::charts_status_data"] - n216["ui::render::charts_window"] - n217["ui::render::dashboard"] - n218["ui::render::formatting"] - n219["ui::render::frame"] - n220["ui::render::lifecycle"] - n221["ui::render::progress"] - n222["ui::render::summary"] - n223["ui::render::summary_panels_metrics"] - n224["ui::render::summary_panels_quality"] - n225["ui::render::summary_run"] - n226["ui::render::theme"] - n227["ui::tests"] - n19 --> n212 - n19 --> n213 - n213 --> n214 - n213 --> n215 - n213 --> n216 - n213 --> n217 - n213 --> n218 - n213 --> n219 - n213 --> n220 - n213 --> n221 - n213 --> n222 - n213 --> n223 - n213 --> n224 - n213 --> n225 - n213 --> n226 - n19 --> n227 - end - subgraph sg_wasm_plugins["wasm_plugins"] - n20["wasm_plugins"] - n228["wasm_plugins::constants"] - n229["wasm_plugins::host"] - n230["wasm_plugins::tests"] - n231["wasm_plugins::validate"] - n20 --> n228 - n20 --> n229 - n20 --> n230 - n20 --> n231 - end - subgraph sg_wasm_runtime["wasm_runtime"] - n21["wasm_runtime"] - n232["wasm_runtime::constants"] - n233["wasm_runtime::loader"] - n234["wasm_runtime::module"] - n235["wasm_runtime::parse"] - n236["wasm_runtime::tests"] - n237["wasm_runtime::validate"] - n21 --> n232 - n21 --> n233 - n21 --> n234 - n21 --> n235 - n21 --> n236 - n21 --> n237 - end +flowchart LR + p["entry::plan::execute_plan"] --> a["distributed_run::execute(mode=controller)"] + a --> r["RuntimeDistributedPort::run_controller"] + r --> d["distributed::run_controller"] ``` -## Module Inventory -### `app` (30) -```text -app -app::cleanup -app::compare -app::compare::compare_output -app::export -app::logs -app::logs::merge -app::logs::parsing -app::logs::records -app::logs::setup -app::logs::streaming -app::progress -app::replay -app::replay::bounds -app::replay::records -app::replay::runner -app::replay::snapshots -app::replay::state -app::replay::summary -app::replay::tests -app::replay::ui -app::runner -app::runner::alloc -app::runner::core -app::runner::core::finalize -app::runner::rss -app::runtime_errors -app::summary -app::summary::lines -app::summary::percentiles -``` -### `args` (13) -```text -args -args::cli -args::cli::presets -args::cli::tester -args::defaults -args::parsers -args::tests -args::tests::defaults -args::tests::headers -args::tests::options_core -args::tests::options_extra -args::tests::subcommands -args::types -``` -### `charts` (24) -```text -charts -charts::aggregated -charts::aggregated::buckets -charts::aggregated::latency -charts::aggregated::rps -charts::aggregated::util -charts::average -charts::cumulative -charts::driver -charts::driver::naming -charts::driver::plotting -charts::errors -charts::inflight -charts::latency -charts::rps -charts::status -charts::streaming -charts::streaming::basic -charts::streaming::basic::buckets -charts::streaming::basic::counts -charts::streaming::breakdown -charts::streaming::latency -charts::tests -charts::timeouts -``` -### `config` (16) -```text -config -config::apply -config::apply::distributed -config::apply::load -config::apply::scenario -config::apply::section_basic -config::apply::section_runtime -config::apply::section_runtime::section_runtime_network -config::apply::section_runtime::section_runtime_output -config::apply::section_tail -config::apply::util -config::loader -config::parse -config::test_support -config::tests -config::types -``` -### `distributed` (42) -```text -distributed -distributed::agent -distributed::agent::command -distributed::agent::run_exec -distributed::agent::session -distributed::agent::wire -distributed::controller -distributed::controller::agent -distributed::controller::auto -distributed::controller::auto::events -distributed::controller::auto::finalize -distributed::controller::auto::setup -distributed::controller::control -distributed::controller::http -distributed::controller::load -distributed::controller::manual -distributed::controller::manual::connections -distributed::controller::manual::control_http -distributed::controller::manual::loop_handlers -distributed::controller::manual::loop_idle -distributed::controller::manual::orchestrator -distributed::controller::manual::run_finalize -distributed::controller::manual::run_lifecycle -distributed::controller::manual::state -distributed::controller::runner -distributed::controller::shared -distributed::controller::shared::aggregation -distributed::controller::shared::events -distributed::controller::shared::timing -distributed::controller::shared::ui -distributed::controller::tests -distributed::controller::tests::aggregation -distributed::controller::tests::ui -distributed::protocol -distributed::protocol::io -distributed::protocol::types -distributed::summary -distributed::tests -distributed::tests::sink_runs -distributed::tests::wire_args -distributed::utils -distributed::wire -``` -### `entry` (5) -```text -entry -entry::plan -entry::plan::build -entry::plan::execute -entry::plan::types -``` -### `error` (11) -```text -error -error::app -error::config -error::distributed -error::http -error::metrics -error::script -error::service -error::sink -error::test_support -error::validation -``` -### `fuzzing` (1) -```text -fuzzing -``` -### `http` (15) -```text -http -http::rate -http::sender -http::sender::config -http::sender::worker -http::tests -http::tls -http::workload -http::workload::builders -http::workload::builders_auth -http::workload::data -http::workload::execution -http::workload::runner -http::workload::runner_common -http::workload::template -``` -### `lib` (1) -```text -lib -``` -### `main` (1) -```text -main -``` -### `metrics` (14) -```text -metrics -metrics::collector -metrics::collector::helpers -metrics::collector::helpers::processing -metrics::collector::helpers::summary -metrics::collector::helpers::windows -metrics::collector::state -metrics::histogram -metrics::logging -metrics::logging::reader -metrics::logging::writer -metrics::logging::writer::db -metrics::tests -metrics::types -``` -### `protocol` (21) -```text -protocol -protocol::builtins -protocol::examples -protocol::examples::chat_websocket -protocol::examples::game_udp -protocol::examples::telemetry_mqtt -protocol::registry -protocol::runtime -protocol::runtime::datagram -protocol::runtime::grpc -protocol::runtime::mqtt -protocol::runtime::resolve -protocol::runtime::spawner -protocol::runtime::tests -protocol::runtime::tests::datagram_mqtt -protocol::runtime::tests::scheme_resolution -protocol::runtime::tests::transport_http_grpc -protocol::runtime::transports -protocol::runtime::types -protocol::tests -protocol::traits -``` -### `script` (2) -```text -script -script::loader -``` -### `service` (1) -```text -service -``` -### `shutdown` (1) -```text -shutdown -``` -### `sinks` (4) -```text -sinks -sinks::config -sinks::format -sinks::writers -``` -### `system` (5) -```text -system -system::banner -system::logger -system::probestack -system::shutdown_handlers -``` -### `ui` (17) -```text -ui -ui::model -ui::render -ui::render::charts -ui::render::charts_status_data -ui::render::charts_window -ui::render::dashboard -ui::render::formatting -ui::render::frame -ui::render::lifecycle -ui::render::progress -ui::render::summary -ui::render::summary_panels_metrics -ui::render::summary_panels_quality -ui::render::summary_run -ui::render::theme -ui::tests -``` -### `wasm_plugins` (5) -```text -wasm_plugins -wasm_plugins::constants -wasm_plugins::host -wasm_plugins::tests -wasm_plugins::validate -``` -### `wasm_runtime` (7) -```text -wasm_runtime -wasm_runtime::constants -wasm_runtime::loader -wasm_runtime::module -wasm_runtime::parse -wasm_runtime::tests -wasm_runtime::validate +### Distributed Agent +Call chain: +`main -> entry -> distributed_run::execute(agent) -> RuntimeDistributedPort::run_agent -> distributed::run_agent -> AgentLocalRunPort -> app::run_local` + +```mermaid +flowchart LR + p["entry::plan::execute_plan"] --> a["distributed_run::execute(mode=agent)"] + a --> r["RuntimeDistributedPort::run_agent"] + r --> d["distributed::run_agent"] + d --> port["AgentLocalRunPort"] + port --> l["app::run_local"] ``` + +## Enforced Boundary Checks +Current `scripts/check_architecture.sh` guardrails include: +- `src/application` cannot import `crate::app` or `crate::distributed`. +- `src/distributed` cannot import `crate::app` or `crate::application`. +- `src/entry` cannot import `crate::app`, `crate::distributed`, or `crate::service`. + +Latest local run on 2026-02-14 passed these checks. diff --git a/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md b/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md deleted file mode 100644 index e1b3855..0000000 --- a/docs/architecture/ard/ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md +++ /dev/null @@ -1,391 +0,0 @@ -# Architecture Risks and Hexagonal Migration Plan - -## Executive Summary -The codebase is a functional modular monolith, but business logic is strongly coupled to infrastructure, especially the CLI argument model (`TesterArgs`) and runtime IO concerns. - -Current coupling profile (non-test files): -- `211` non-test Rust files. -- `71` files reference `crate::args`. -- `62` files directly use `TesterArgs`. -- Heaviest top-level dependencies: `distributed -> args (23)`, `protocol -> args (22)`, `config -> args (17)`, `app -> args (16)`. - -This creates high friction for architectural goals: vertical slices, hexagonal boundaries, and clean separation between infra (CLI/parsing) and domain (business behavior). - -## Findings (Prioritized) - -### R1. CLI model (`TesterArgs`) is the de facto domain model -Severity: Critical - -Evidence: -- `TesterArgs` is a clap-bound struct with parser metadata in `src/args/cli/tester.rs:18` and `src/args/cli/tester.rs:24`. -- Core runtime depends directly on it: - - `src/app/runner/core/mod.rs:36` - - `src/protocol/runtime.rs:35` - - `src/metrics/collector/mod.rs:28` - - `src/distributed/controller/auto/setup.rs:30` - - `src/distributed/controller/manual/run_lifecycle.rs:21` - -Impact: -- Domain and use-case code cannot evolve independently from CLI schema. -- Any CLI option growth increases transitive complexity across runtime modules. - -Recommendation: -- Introduce domain command types (`RunLocalCommand`, `RunDistributedCommand`, `ReplayCommand`, etc.) and map `TesterArgs -> Command` only in an adapter layer. - -### R2. Domain types live in `args` (interface layer) -Severity: Critical - -Evidence: -- Core concepts (`Protocol`, `LoadMode`, `Scenario`, `ScenarioStep`) are defined in `src/args/types.rs:101`, `src/args/types.rs:136`, `src/args/types.rs:299`, `src/args/types.rs:307`. -- Same module also carries CLI-oriented derives and parsing coupling (e.g., `ValueEnum`) in `src/args/types.rs:9`, `src/args/types.rs:101`. - -Impact: -- Domain policy objects are anchored to interface concerns. -- Prevents clean reuse for non-CLI entry points (service API, controller API, future SDK). - -Recommendation: -- Move business enums/models to `domain` and keep CLI serialization/parsing wrappers in `adapters::cli`. - -### R3. Config pipeline mutates CLI struct directly -Severity: High - -Evidence: -- `apply_config(args: &mut TesterArgs, ...)` in `src/config/apply.rs:21`. -- Scenario parsing uses runtime defaults from CLI args in `src/config/apply/scenario.rs:6` and `src/config/apply/scenario.rs:19`. - -Impact: -- Configuration behavior is tied to CLI precedence mechanics. -- Hard to reason about “effective runtime config” outside CLI execution. - -Recommendation: -- Parse config into domain settings/overrides object, then merge in application layer with explicit precedence policy. - -### R4. Use-case orchestration mixes domain flow with adapters -Severity: High - -Evidence: -- Local run orchestration in `src/app/runner/core/mod.rs` handles: - - plugins `src/app/runner/core/mod.rs:42` - - shutdown channels `src/app/runner/core/mod.rs:53` - - UI setup `src/app/runner/core/mod.rs:128` - - protocol sender creation `src/app/runner/core/mod.rs:112` - - metrics collector setup `src/app/runner/core/mod.rs:138` -- Distributed setup/finalization also mixes concerns: - - UI setup in `src/distributed/controller/auto/setup.rs:189` - - chart/sink writing in `src/distributed/controller/auto/finalize.rs:86` and `src/distributed/controller/auto/finalize.rs:104` - -Impact: -- Hard to test core policies without tokio/UI/network dependencies. -- Changes to output or transport behavior risk regressions in run control logic. - -Recommendation: -- Move orchestration into application services that depend on ports (`UiPort`, `SinkPort`, `TrafficPort`, `MetricsPort`, `ShutdownPort`). - -### R5. Protocol runtime selection is centralized and infra-coupled -Severity: High - -Evidence: -- Big `match` on protocol in `src/protocol/runtime.rs:41` with direct calls into HTTP and transport code. -- Depends on `TesterArgs` throughout `src/protocol/runtime.rs:35`. - -Impact: -- Adding protocol behavior touches central switch and request setup flow. -- Protocol execution cannot be swapped/tested cleanly as adapters. - -Recommendation: -- Introduce `TransportAdapter` port registry keyed by domain `ProtocolKind`; application asks registry to build sender from domain command. - -### R6. Distributed slice leaks presentation/output concerns -Severity: Medium-High - -Evidence: -- Distributed shared aggregation imports charts and sinks: `src/distributed/controller/shared/aggregation.rs:4` and `src/distributed/controller/shared/aggregation.rs:8`. -- Distributed UI updates build `UiData` directly: `src/distributed/controller/shared/ui.rs:13`. - -Impact: -- Distributed domain decisions depend on specific output technologies. -- Hard to run headless controller service with alternative observers. - -Recommendation: -- Keep distributed slice focused on coordination/state; publish domain events and push rendering/sinks to adapters. - -### R7. Entry planning carries full CLI struct across all run modes -Severity: Medium - -Evidence: -- `RunPlan` variants still carry `TesterArgs` in `src/entry/plan/types.rs:31`, `src/entry/plan/types.rs:33`, `src/entry/plan/types.rs:35`, `src/entry/plan/types.rs:38`. - -Impact: -- Every mode receives oversized, weakly-typed option bags. -- Mode-specific invariants are enforced late and scattered. - -Recommendation: -- Build strongly typed mode commands early in planning; keep `RunPlan` payloads mode-specific and minimal. - -## Misalignment Patterns - -### P1. Horizontal modules, vertical behavior -Behavior is vertical (local run, distributed run, replay, compare), but code organization is mostly horizontal by technical layer (`http`, `metrics`, `ui`, `config`), causing broad coupling. - -### P2. Mutable mega-config object anti-pattern -`TesterArgs` acts as mutable global state passing through multiple subsystems; modules both read and rewrite it. - -### P3. Adapter logic embedded inside application flow -UI, sink, chart, signal, plugin wiring appears in core run functions rather than at composition boundaries. - -### P4. Implicit precedence rules -Config/CLI merge semantics are encoded as mutation order instead of explicit, versioned policy objects. - -## Existing Seams to Leverage - -1. `distributed::wire` already transforms to a transport DTO (`WireArgs`) in `src/distributed/wire.rs:10`. -2. Protocol registry abstraction exists in `src/protocol/traits.rs` and `src/protocol/registry.rs`. -3. Entry plan already models run modes (`RunPlan`) in `src/entry/plan/types.rs:28`. - -These are useful anchors for incremental migration without a rewrite. - -## Target Architecture (Vertical Slices + Hexagonal) - -```mermaid -flowchart LR - subgraph Adapters[Adapters / Infrastructure] - CLI[CLI Adapter\nclap + env + defaults] - CFG[Config Adapter\nTOML/JSON] - NET[Transport Adapters\nHTTP/TCP/UDP/WS/gRPC/MQTT] - OUT[Output Adapters\nUI/Charts/Sinks/Logs] - DISTNET[Distributed Network Adapter\ncontroller-agent wire] - SCRIPT[WASM Script/Plugin Adapter] - end - - subgraph App[Application Layer] - LOCALUC[Local Run Use Case] - DISTUC[Distributed Run Use Case] - REPLAYUC[Replay Use Case] - COMPAREUC[Compare Use Case] - CLEANUC[Cleanup Use Case] - end - - subgraph Domain[Domain Layer] - RUNCFG[RunConfig / LoadProfile / Scenario] - POLICY[Validation + Load Policies] - MODEL[Metrics + Summary Models] - EVENTS[Domain Events] - end - - CLI --> LOCALUC - CLI --> DISTUC - CLI --> REPLAYUC - CLI --> COMPAREUC - CFG --> LOCALUC - CFG --> DISTUC - SCRIPT --> LOCALUC - - LOCALUC --> RUNCFG - LOCALUC --> POLICY - LOCALUC --> MODEL - DISTUC --> RUNCFG - DISTUC --> POLICY - DISTUC --> MODEL - REPLAYUC --> MODEL - COMPAREUC --> MODEL - - LOCALUC --> NET - DISTUC --> DISTNET - LOCALUC --> OUT - DISTUC --> OUT - REPLAYUC --> OUT - COMPAREUC --> OUT - - MODEL --> EVENTS - EVENTS --> OUT -``` - -## Proposed Vertical Slices - -1. `local_run` slice -- Domain: run config, load policies, runtime invariants. -- Application: run lifecycle orchestration. -- Adapters: protocol sender, metrics stream, ui/sink/charts output. - -2. `distributed_run` slice -- Domain: agent/session/run state, aggregation rules. -- Application: controller/agent workflows. -- Adapters: TCP wire protocol, controller API, distributed output adapters. - -3. `replay_compare` slice -- Domain: replay windows, comparison math. -- Application: replay and compare use cases. -- Adapters: terminal UI and file IO. - -4. `shared_kernel` (minimal) -- Strongly shared value objects only: protocol kind, load mode, durations, errors. - -## Ports and Adapters Blueprint - -### Core ports (application-facing) -- `RunTrafficPort`: start/stop traffic, stream request outcomes. -- `MetricsPort`: aggregate outcomes, emit snapshots. -- `OutputPort`: summaries/events/charts/sinks/UI updates. -- `ScriptPort`: scenario/script loading hooks. -- `ClusterPort`: agent registration/config/start/stop/report. -- `ClockPort` and `ShutdownPort`: deterministic time/cancel control. - -### Adapter implementations (initial) -- CLI adapter: `TesterArgs` parsing + mapping. -- Config adapter: file loaders/parsers to `ConfigOverrides`. -- Transport adapters: existing `http` + protocol runtime senders. -- Output adapters: existing UI/charts/sinks/logs. -- Cluster adapter: existing distributed protocol IO. - -## Migration Plan (Incremental, No Big-Bang) - -### Phase 0: Architecture guardrails (1 week) -1. Add architecture ADR in `docs/` defining layers and dependency rules. -2. Add CI script enforcing forbidden imports: -- domain cannot import `clap`, `reqwest`, `tokio`, `ratatui`, `crossterm`. -- application cannot import `clap` directly. -3. Track baseline coupling metrics (`TesterArgs` references, cross-module edges). - -Exit criteria: -- Guardrails merged and enforced in CI. - -Phase 0 artifacts (implemented): -- ADR: `docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md` -- Guardrail script: `scripts/check_architecture.sh` -- Coupling baseline snapshot: `docs/architecture/ard/ARCHITECTURE_BASELINE_METRICS.md` -- CI enforcement: `.github/workflows/pr.yml` and `.github/workflows/release.yml` - -### Phase 1: Introduce domain commands + anti-corruption mapping (1-2 weeks) -1. Create `domain::run` types (`RunConfig`, `ProtocolKind`, `LoadMode`, `Scenario`). -2. Create `application::commands` per mode. -3. Implement `adapters::cli::mapper` from `TesterArgs` to commands. -4. Keep legacy APIs; use mapping in entry. - -Exit criteria: -- `entry` calls use-case commands, not raw `TesterArgs` (except adapter boundary). - -Phase 1 artifacts (implemented): -- Domain run model: `src/domain/run.rs` -- Application commands per mode: `src/application/commands.rs` -- CLI anti-corruption mapper: `src/adapters/cli/mapper.rs` -- Entry plan command wiring: `src/entry/plan/types.rs`, `src/entry/plan/build.rs`, `src/entry/plan/execute.rs` - -### Phase 2: Config decoupling (1-2 weeks) -1. Replace `config::apply_config(&mut TesterArgs, ...)` with `ConfigOverrides` builder. -2. Merge order defined in one place: `CLI > Config > Preset defaults`. -3. Scenario parsing consumes domain defaults, not `TesterArgs`. - -Exit criteria: -- Config module no longer depends on `TesterArgs` mutability. - -Phase 2 artifacts (implemented): -- Config override merge path: `src/config/apply.rs` -- Scenario defaults parsing seam: `src/config/apply/scenario.rs` -- Entry wiring for effective args + scenario registry: `src/entry/plan/build.rs` - -### Phase 3: Local run use case extraction (2 weeks) -1. Extract `run_local` to `application::local_run::execute(command, ports)`. -2. Introduce ports for traffic, metrics, outputs, shutdown. -3. Implement adapters by wrapping existing modules. - -Exit criteria: -- `src/app/runner/core/mod.rs` reduced to adapter composition. - -Phase 3 artifacts (implemented): -- Local run use-case orchestration with ports: `src/application/local_run.rs` -- Local run adapter composition layer: `src/app/runner/core/mod.rs` - -### Phase 4: Protocol adapter boundary (1-2 weeks) -1. Refactor protocol switch to registry-based `TransportAdapter` implementations. -2. Move protocol setup to adapter layer. -3. Keep `ProtocolRegistry` but drive it from domain `ProtocolKind`. - -Exit criteria: -- Application no longer depends on `protocol/runtime` internals. - -Phase 4 artifacts (implemented): -- Transport adapter boundary contract: `src/protocol/traits.rs` -- Registry-driven builtin transport adapter wiring: `src/protocol/builtins.rs`, `src/protocol/registry.rs`, `src/protocol/runtime.rs` -- Domain protocol key flow in planning/local-run adapter seams: `src/entry/plan/build.rs`, `src/application/local_run.rs`, `src/app/runner/core/mod.rs` - -### Phase 5: Distributed slice extraction (2-3 weeks) -1. Introduce `DistributedRunCommand` and domain state models. -2. Move controller/agent workflows into application services. -3. Replace direct UI/sink/chart calls with output events and output adapters. - -Exit criteria: -- distributed application flow has no direct UI/charts/sinks imports. - -Phase 5 artifacts (implemented): -- Distributed run command + application execution port: `src/application/commands.rs`, `src/application/distributed_run.rs`, `src/entry/plan/types.rs`, `src/entry/plan/build.rs`, `src/entry/plan/execute.rs` -- Controller output adapter/event boundary for auto/manual flows: `src/distributed/controller/output.rs`, `src/distributed/controller/auto/setup.rs`, `src/distributed/controller/auto/events.rs`, `src/distributed/controller/auto/finalize.rs`, `src/distributed/controller/manual/run_lifecycle.rs`, `src/distributed/controller/manual/loop_handlers.rs`, `src/distributed/controller/manual/run_finalize.rs`, `src/distributed/controller/manual/state.rs` -- Shared aggregation narrowed to state aggregation primitives: `src/distributed/controller/shared/aggregation.rs`, `src/distributed/controller/shared/events.rs` - -### Phase 6: Replay/compare slice extraction (1-2 weeks) -1. Split replay/compare use cases from terminal event loop logic. -2. Keep key event handling and rendering in adapters. - -Exit criteria: -- replay/compare core logic testable without terminal runtime. - -Phase 6 artifacts (implemented): -- Terminal-independent replay/compare playback state transitions and range helpers: `src/application/replay_compare.rs` -- Replay/compare adapter loops narrowed to key handling + rendering while delegating state transitions: `src/app/replay/runner.rs`, `src/app/compare.rs`, `src/app/replay/state.rs` - -### Phase 7: Remove legacy coupling and enforce strict boundaries (1 week) -1. Deprecate direct `TesterArgs` use outside CLI adapter. -2. Remove now-obsolete conversion glue. -3. Raise CI checks from warning to fail-on-violation. - -Exit criteria: -- `TesterArgs` references constrained to CLI/config adapter composition layer. - -Phase 7 artifacts (implemented): -- Application command model no longer stores CLI structs: `src/application/commands.rs` -- Local/distributed application seams now accept adapter payloads generically while keeping typed run settings in application: `src/application/local_run.rs`, `src/application/distributed_run.rs` -- Entry planning now carries typed commands plus adapter payloads, instead of embedding CLI structs in application commands: `src/entry/plan/types.rs`, `src/entry/plan/build.rs`, `src/entry/plan/execute.rs` -- Runtime adapter composition maps `TesterArgs -> LocalRunSettings` at adapter boundary and owns WASM plugin lifecycle there: `src/app/runner/core/mod.rs` -- Architecture guardrails now fail on application-layer `TesterArgs` / `crate::args` coupling: `scripts/check_architecture.sh` -- Added migration validation tests for all entry routing modes and distributed/local application dispatch seams: `src/entry/plan/build.rs`, `src/application/distributed_run.rs`, `src/application/local_run.rs` - -Phase 7 validation snapshot (2026-02-14): -- `cargo make architecture-check` passes. -- `src/application` contains no `TesterArgs` references and no `crate::args` imports. -- Coupling counters at snapshot time: - - `non_test_rust_files`: `220` - - `files_referencing_crate_args`: `70` - - `files_referencing_tester_args`: `65` - -## Recommended First Backlog (Concrete) - -1. Create `src/domain/run.rs` and move `Protocol`, `LoadMode`, `Scenario`, `ScenarioStep` there. -2. Create `src/adapters/cli/mapper.rs` with `fn to_run_command(args: TesterArgs) -> AppResult`. -3. Create `src/application/local_run.rs` with `RunLocalCommand` and `execute` signature. -4. Update `src/entry/plan/types.rs` to hold typed commands instead of raw `TesterArgs` where possible. -5. Add `scripts/check_architecture.sh` and CI job with import-boundary assertions. - -## Success Metrics - -Track these per PR/sprint: -- Count of non-test files referencing `TesterArgs` (baseline: `62`, target first milestone: `<35`, final target: `<10` and only adapters/bootstrap). -- Count of non-test files referencing `crate::args` (baseline: `71`, target first milestone: `<40`, final target: adapter-only). -- Number of use cases executable with mocked ports and no terminal/network dependencies. -- Time-to-add-new-protocol/new-output-sink (should fall as adapters isolate infra concerns). - -Current trend note (2026-02-14): -- `files_referencing_crate_args` dropped from baseline (`71` -> `70`). -- `files_referencing_tester_args` is above the historical baseline because migration test coverage now lives in non-test module files; next cleanup should move CLI-heavy fixtures into excluded test directories and continue adapter-only narrowing in infra modules. - -## Risks During Migration - -1. Behavior drift from precedence changes (`CLI vs config`) during mapping extraction. -Mitigation: golden tests from current CLI fixtures before refactor. - -2. Increased temporary complexity (old and new pathways coexisting). -Mitigation: feature flags / branch-by-abstraction and strict deprecation checkpoints. - -3. Performance regressions from extra abstraction. -Mitigation: keep hot paths in adapters concrete; use trait objects at composition boundaries only. - -4. Team adoption inconsistency. -Mitigation: ADR + CI guardrails + PR template checks for boundary violations. diff --git a/docs/architecture/ard/README.md b/docs/architecture/ard/README.md index 4f1d619..61b3d01 100644 --- a/docs/architecture/ard/README.md +++ b/docs/architecture/ard/README.md @@ -7,5 +7,3 @@ Use this folder for architectural analysis, dependency maps, risk registers, and Current docs: - `ARCHITECTURE_OVERVIEW.md` -- `ARCHITECTURE_RISKS_HEXAGONAL_PLAN.md` -- `ARCHITECTURE_BASELINE_METRICS.md` diff --git a/docs/architecture/patterns/README.md b/docs/architecture/patterns/README.md index b9786a1..3b3cc16 100644 --- a/docs/architecture/patterns/README.md +++ b/docs/architecture/patterns/README.md @@ -5,3 +5,4 @@ Use this folder for reusable architecture patterns that should be applied consis Current patterns: - `vertical-slices-hexagonal.md` +- `type-safety-performance-concurrency.md` diff --git a/docs/architecture/patterns/type-safety-performance-concurrency.md b/docs/architecture/patterns/type-safety-performance-concurrency.md new file mode 100644 index 0000000..1eb4107 --- /dev/null +++ b/docs/architecture/patterns/type-safety-performance-concurrency.md @@ -0,0 +1,97 @@ +# Type Safety, Performance, and Concurrency Patterns + +## Intent + +Define shared engineering rules for: +- making invalid states unrepresentable +- preserving cache-friendly hot paths +- choosing dispatch strategy +- minimizing lock contention in concurrent paths + +These rules are expected across slices and adapters. + +## 1. Newtypes and Invalid States + +### Rule +Represent invariants in types, not in scattered runtime checks. + +### Prefer +- Newtypes for constrained values (for example positive-only numeric types). +- Enums for mutually exclusive modes instead of boolean combinations. +- Small command/config structs with required fields over partially initialized structs. + +### Avoid +- Passing raw primitive values with hidden constraints (`u64` that must be `> 0`). +- State machines encoded as unrelated booleans. +- Late validation deep in execution paths. + +### Boundary Guidance +- Parse/validate at adapter boundaries (`args`, `config`, wire input). +- Keep application/domain APIs typed so invalid input cannot compile or be constructed. + +## 2. Cache Locality and Inlining + +### Rule +Optimize data layout and call structure for hot paths first; optimize instructions second. + +### Prefer +- Contiguous data and predictable iteration in metrics/request loops. +- Reuse allocations where possible and pre-size collections when capacity is known. +- Passing by reference in hot paths to avoid clones. +- Small helper functions that are obvious candidates for inlining by the compiler. + +### Inlining Policy +- Let the compiler decide by default. +- Use `#[inline]` or `#[inline(always)]` only when profiling proves measurable gain. +- Remove forced inlining when gains are not repeatable. + +## 3. Dispatch Strategy (Static First) + +### Rule +Prefer static dispatch for core execution paths; use dynamic dispatch only at extension seams. + +### Prefer static dispatch when +- behavior is known at compile time +- code is in hot paths +- monomorphization overhead is acceptable + +### Use dynamic dispatch when +- runtime extensibility is required (plugin/registry boundaries) +- implementation set is not known at compile time +- reduced compile-time or binary-size pressure is more important than max throughput + +### Practical Guidance +- Keep dynamic trait objects at boundaries. +- Convert to concrete/static execution as early as possible after selection. + +## 4. Lock-Free and Low-Lock Concurrency + +### Rule +Prefer immutable sharing + atomic coordination over shared mutable locks in hot paths. + +### Prefer +- `Arc` for shared ownership. +- atomics for counters/flags (`AtomicU64`, `AtomicBool`, etc.). +- channels (`mpsc`, `watch`) for ownership transfer and signaling. +- `ArcShift` for snapshot-style shared state updates where read-mostly maps need occasional replacement. + +### Use locks only when +- mutation requires compound invariants that atomics cannot safely express +- the code path is not performance critical + +### Avoid +- coarse `Mutex`/`RwLock` around high-frequency request/metrics paths +- holding locks across `.await` + +## 5. PR Checklist (Technical) + +Before merging performance/concurrency-sensitive changes: +1. Invariants are encoded in types at boundaries. +2. New invalid combinations are not representable by API shape. +3. Dispatch choice is explicit and justified (static by default). +4. Shared state avoids unnecessary lock contention. +5. Required checks pass: + - `cargo make format` + - `cargo make clippy` + - `cargo make test` + - `cargo make architecture-check` diff --git a/scripts/check_architecture.sh b/scripts/check_architecture.sh index 06014dd..2c83f54 100755 --- a/scripts/check_architecture.sh +++ b/scripts/check_architecture.sh @@ -63,7 +63,7 @@ check_forbidden_crates_in_layer() { local regex="\\b${crate_name}::" local matches if [[ "$HAS_RG" -eq 1 ]]; then - matches="$(rg -n --glob '*.rs' "$regex" "$layer_dir" || true)" + matches="$(rg -n "${NON_TEST_GLOBS[@]}" "$regex" "$layer_dir" || true)" else matches="$(grep -R -n -E --include='*.rs' "$regex" "$layer_dir" || true)" fi @@ -89,7 +89,7 @@ check_forbidden_pattern_in_layer() { local matches if [[ "$HAS_RG" -eq 1 ]]; then - matches="$(rg -n --glob '*.rs' "$regex" "$layer_dir" || true)" + matches="$(rg -n "${NON_TEST_GLOBS[@]}" "$regex" "$layer_dir" || true)" else matches="$(grep -R -n -E --include='*.rs' "$regex" "$layer_dir" || true)" fi @@ -153,6 +153,13 @@ check_forbidden_crates_in_layer "src/domain" "clap" "reqwest" "tokio" "ratatui" check_forbidden_crates_in_layer "src/application" "clap" check_forbidden_pattern_in_layer "src/application" "'TesterArgs' references" "\\bTesterArgs\\b" check_forbidden_pattern_in_layer "src/application" "'crate::args' imports" "crate::args::" +check_forbidden_pattern_in_layer "src/application" "'crate::app' imports" "crate::app::" +check_forbidden_pattern_in_layer "src/application" "'crate::distributed' imports" "crate::distributed::" +check_forbidden_pattern_in_layer "src/distributed" "'crate::app' imports" "crate::app::" +check_forbidden_pattern_in_layer "src/distributed" "'crate::application' imports" "crate::application::" +check_forbidden_pattern_in_layer "src/entry" "'crate::app' imports" "crate::app::" +check_forbidden_pattern_in_layer "src/entry" "'crate::distributed' imports" "crate::distributed::" +check_forbidden_pattern_in_layer "src/entry" "'crate::service' imports" "crate::service::" echo echo "Coupling baseline metrics" diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 42e8dd6..e46a838 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -1 +1,2 @@ pub(crate) mod cli; +pub(crate) mod runtime; diff --git a/src/adapters/runtime/execution_ports.rs b/src/adapters/runtime/execution_ports.rs new file mode 100644 index 0000000..627eb36 --- /dev/null +++ b/src/adapters/runtime/execution_ports.rs @@ -0,0 +1,106 @@ +use std::collections::BTreeMap; + +use async_trait::async_trait; + +use crate::application::distributed_run::DistributedRunPort; +use crate::application::local_run; +use crate::application::slice_execution::{ + CleanupPort, ComparePort, LocalRunPort, ReplayRunPort, ServicePort, +}; +use crate::args::{CleanupArgs, CompareArgs, TesterArgs}; +use crate::config::types::ScenarioConfig; +use crate::distributed::{AgentLocalRunPort, AgentRunOutcome}; +use crate::error::AppResult; +use crate::metrics::StreamSnapshot; +use tokio::sync::{mpsc, watch}; + +pub(crate) struct RuntimeLocalPort; + +#[async_trait] +impl LocalRunPort for RuntimeLocalPort { + async fn run_local(&self, adapter_args: TesterArgs) -> AppResult { + crate::app::run_local(adapter_args, None, None).await + } +} + +pub(crate) struct RuntimeReplayPort; + +#[async_trait] +impl ReplayRunPort for RuntimeReplayPort { + async fn run_replay(&self, adapter_args: TesterArgs) -> AppResult<()> { + crate::app::run_replay(&adapter_args).await + } +} + +pub(crate) struct RuntimeCleanupPort; + +#[async_trait] +impl CleanupPort for RuntimeCleanupPort { + async fn run_cleanup(&self, cleanup_args: CleanupArgs) -> AppResult<()> { + crate::app::run_cleanup(&cleanup_args).await + } +} + +pub(crate) struct RuntimeComparePort; + +#[async_trait] +impl ComparePort for RuntimeComparePort { + async fn run_compare(&self, compare_args: CompareArgs) -> AppResult<()> { + crate::app::run_compare(&compare_args).await + } +} + +pub(crate) struct RuntimeServicePort; + +impl ServicePort for RuntimeServicePort { + fn handle_service_action(&self, adapter_args: TesterArgs) -> AppResult<()> { + crate::service::handle_service_action(&adapter_args) + } +} + +pub(crate) struct RuntimeDistributedPort; + +#[async_trait] +impl DistributedRunPort for RuntimeDistributedPort { + async fn run_controller( + &self, + adapter_args: &TesterArgs, + scenarios: Option>, + ) -> AppResult<()> { + crate::distributed::run_controller(adapter_args, scenarios).await + } + + async fn run_agent(&self, adapter_args: &TesterArgs) -> AppResult<()> { + let local_port = RuntimeAgentLocalRunPort; + crate::distributed::run_agent(adapter_args.clone(), &local_port).await + } +} + +struct RuntimeAgentLocalRunPort; + +#[async_trait] +impl AgentLocalRunPort for RuntimeAgentLocalRunPort { + async fn run_local( + &self, + args: TesterArgs, + stream_tx: Option>, + external_shutdown: Option>, + ) -> AppResult { + let outcome = crate::app::run_local(args, stream_tx, external_shutdown).await?; + Ok(AgentRunOutcome { + summary: outcome.summary, + histogram: outcome.histogram, + success_histogram: outcome.success_histogram, + latency_sum_ms: outcome.latency_sum_ms, + success_latency_sum_ms: outcome.success_latency_sum_ms, + runtime_errors: outcome.runtime_errors, + }) + } +} + +pub(crate) fn print_runtime_errors(errors: &[String]) { + eprintln!("Runtime errors:"); + for error in errors { + eprintln!("- {}", error); + } +} diff --git a/src/adapters/runtime/mod.rs b/src/adapters/runtime/mod.rs new file mode 100644 index 0000000..22e93ca --- /dev/null +++ b/src/adapters/runtime/mod.rs @@ -0,0 +1,6 @@ +mod execution_ports; + +pub(crate) use execution_ports::{ + RuntimeCleanupPort, RuntimeComparePort, RuntimeDistributedPort, RuntimeLocalPort, + RuntimeReplayPort, RuntimeServicePort, print_runtime_errors, +}; diff --git a/src/app/compare.rs b/src/app/compare.rs index c67a9d9..a082fd3 100644 --- a/src/app/compare.rs +++ b/src/app/compare.rs @@ -9,12 +9,12 @@ use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use tokio::sync::watch; -use crate::application::replay_compare::{ +use crate::args::CompareArgs; +use crate::error::{AppError, AppResult, MetricsError}; +use crate::system::replay_compare::{ PlaybackAction, PlaybackState, advance_playback, apply_playback_action, clamp_window_to_records, records_range, resolve_step_ms, }; -use crate::args::CompareArgs; -use crate::error::{AppError, AppResult, MetricsError}; use crate::ui::model::{CompareOverlay, UiData}; use crate::ui::render::setup_render_ui; diff --git a/src/app/logs.rs b/src/app/logs.rs index a31ff13..c003ca4 100644 --- a/src/app/logs.rs +++ b/src/app/logs.rs @@ -45,10 +45,6 @@ pub(crate) fn merge_log_results( merge::merge_log_results(results, metrics_max) } -pub(crate) const fn empty_summary() -> metrics::MetricsSummary { - merge::empty_summary() -} - pub(crate) async fn load_chart_data_streaming( paths: &[PathBuf], expected_status_code: u16, diff --git a/src/app/logs/merge.rs b/src/app/logs/merge.rs index 9929af3..a7b42fb 100644 --- a/src/app/logs/merge.rs +++ b/src/app/logs/merge.rs @@ -118,21 +118,3 @@ pub(super) fn merge_log_results( success_histogram, )) } - -pub(super) const fn empty_summary() -> metrics::MetricsSummary { - metrics::MetricsSummary { - duration: Duration::ZERO, - total_requests: 0, - successful_requests: 0, - error_requests: 0, - timeout_requests: 0, - transport_errors: 0, - non_expected_status: 0, - min_latency_ms: 0, - max_latency_ms: 0, - avg_latency_ms: 0, - success_min_latency_ms: 0, - success_max_latency_ms: 0, - success_avg_latency_ms: 0, - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index 54b0ec9..4f2e836 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,11 +5,9 @@ pub(crate) mod logs; mod progress; mod replay; mod runner; -mod runtime_errors; pub(crate) mod summary; pub(crate) use cleanup::run_cleanup; pub(crate) use compare::run_compare; pub(crate) use replay::run_replay; pub(crate) use runner::run_local; -pub(crate) use runtime_errors::print_runtime_errors; diff --git a/src/app/replay/runner.rs b/src/app/replay/runner.rs index da3553c..5cbc306 100644 --- a/src/app/replay/runner.rs +++ b/src/app/replay/runner.rs @@ -6,12 +6,12 @@ use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use tokio::sync::watch; -use crate::application::replay_compare::{ - PlaybackAction, PlaybackState, advance_playback, apply_playback_action, resolve_step_ms, -}; use crate::args::TesterArgs; use crate::error::{AppError, AppResult, MetricsError, ValidationError}; use crate::metrics::MetricRecord; +use crate::system::replay_compare::{ + PlaybackAction, PlaybackState, advance_playback, apply_playback_action, resolve_step_ms, +}; use crate::ui::model::UiData; use crate::ui::render::setup_render_ui; diff --git a/src/app/replay/state.rs b/src/app/replay/state.rs index 83651c6..006a5f4 100644 --- a/src/app/replay/state.rs +++ b/src/app/replay/state.rs @@ -1,4 +1,4 @@ -pub(crate) type ReplayWindow = crate::application::replay_compare::PlaybackState; +pub(crate) type ReplayWindow = crate::system::replay_compare::PlaybackState; #[derive(Default)] pub(crate) struct SnapshotMarkers { diff --git a/src/app/runner/core/mod.rs b/src/app/runner/core/mod.rs index 0147131..52cf46f 100644 --- a/src/app/runner/core/mod.rs +++ b/src/app/runner/core/mod.rs @@ -260,8 +260,14 @@ impl OutputPort for RuntimeOutputAdapter { run_start: Instant, charts_enabled: bool, summary_enabled: bool, - ) -> AppResult { - logs::setup_log_sinks(adapter_args, run_start, charts_enabled, summary_enabled).await + ) -> AppResult { + let setup = + logs::setup_log_sinks(adapter_args, run_start, charts_enabled, summary_enabled).await?; + Ok(local_run::LocalRunLogSetup { + log_sink: setup.log_sink, + handles: setup.handles, + paths: setup.paths, + }) } fn setup_render_ui( diff --git a/src/app/runtime_errors.rs b/src/app/runtime_errors.rs deleted file mode 100644 index 2be3715..0000000 --- a/src/app/runtime_errors.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub(crate) fn print_runtime_errors(errors: &[String]) { - eprintln!("Runtime errors:"); - for error in errors { - eprintln!("- {}", error); - } -} diff --git a/src/app/summary.rs b/src/app/summary.rs index 16eea1a..4711595 100644 --- a/src/app/summary.rs +++ b/src/app/summary.rs @@ -4,7 +4,7 @@ mod percentiles; use crate::args::TesterArgs; use crate::metrics; -pub(crate) use lines::{chart_status_line, selection_lines, summary_lines}; +pub(crate) use lines::summary_lines; pub(crate) use percentiles::compute_percentiles; /// Minimum non-zero duration used to avoid divide-by-zero. diff --git a/src/app/summary/lines.rs b/src/app/summary/lines.rs index 535b49c..fce9432 100644 --- a/src/app/summary/lines.rs +++ b/src/app/summary/lines.rs @@ -1,5 +1,6 @@ -use crate::args::{OutputFormat, PositiveU64, TesterArgs, TimeUnit}; -use crate::metrics::{self, MetricsRange}; +use crate::args::{TesterArgs, TimeUnit}; +use crate::metrics; +use crate::system::{chart_status_line, selection_lines}; use super::{PERCENT_DIVISOR, SummaryExtras, SummaryStats}; @@ -143,95 +144,6 @@ pub(crate) fn summary_lines( lines } -pub(crate) fn selection_lines(args: &TesterArgs, charts_output_path: Option<&str>) -> Vec { - let mut lines = Vec::new(); - lines.push("Selections:".to_owned()); - lines.push(format!("protocol: {}", args.protocol.as_str())); - lines.push(format!("load_mode: {}", args.load_mode.as_str())); - lines.push(format!("url: {}", args.url.as_deref().unwrap_or("none"))); - lines.push(format!("method: {:?}", args.method)); - lines.push(format!("duration_s: {}", args.target_duration.get())); - lines.push(format!("requests: {}", format_opt_u64(args.requests))); - lines.push(format!( - "rate_limit_rps: {}", - format_opt_u64(args.rate_limit) - )); - lines.push(format!("max_tasks: {}", args.max_tasks.get())); - lines.push(format!("spawn_rate: {}", args.spawn_rate_per_tick.get())); - lines.push(format!("spawn_interval_ms: {}", args.tick_interval.get())); - lines.push(format!("expected_status: {}", args.expected_status_code)); - lines.push(format!( - "request_timeout_ms: {}", - args.request_timeout.as_millis() - )); - lines.push(format!( - "connect_timeout_ms: {}", - args.connect_timeout.as_millis() - )); - lines.push(format!("redirect_limit: {}", args.redirect_limit)); - lines.push(format!("no_tui: {}", args.no_ui)); - lines.push(format!("summary: {}", args.summary)); - lines.push(format!("no_charts: {}", args.no_charts)); - lines.push(format!("charts_path: {}", args.charts_path)); - lines.push(format!( - "charts_latency_bucket_ms: {}", - args.charts_latency_bucket_ms.get() - )); - lines.push(format!("tmp_path: {}", args.tmp_path)); - lines.push(format!("keep_tmp: {}", args.keep_tmp)); - lines.push(format!( - "metrics_range: {}", - format_metrics_range(&args.metrics_range) - )); - lines.push(format!("metrics_max: {}", args.metrics_max.get())); - lines.push(format!( - "output_format: {}", - format_output_format(args.output_format) - )); - lines.push(format!( - "output: {}", - args.output.as_deref().unwrap_or("none") - )); - lines.push(format!( - "export_csv: {}", - args.export_csv.as_deref().unwrap_or("none") - )); - lines.push(format!( - "export_json: {}", - args.export_json.as_deref().unwrap_or("none") - )); - lines.push(format!( - "export_jsonl: {}", - args.export_jsonl.as_deref().unwrap_or("none") - )); - lines.push(format!("no_color: {}", args.no_color)); - lines.push(format!( - "charts_output: {}", - charts_output_path.unwrap_or("none") - )); - lines -} - -pub(crate) fn chart_status_line( - args: &TesterArgs, - charts_output_path: Option<&str>, - metrics_truncated: bool, -) -> String { - if args.no_charts { - return "Charts: disabled (--no-charts selected)".to_owned(); - } - if let Some(path) = charts_output_path { - return format!("Charts: saved in {}", path); - } - if metrics_truncated { - return format!( - "Charts: enabled (truncated at {} metrics).", - args.metrics_max.get() - ); - } - "Charts: enabled".to_owned() -} - fn format_duration_ms(value_ms: u64, unit: TimeUnit) -> String { match unit { TimeUnit::Ns => format!("{}ns", u128::from(value_ms).saturating_mul(NS_PER_MS)), @@ -253,27 +165,3 @@ fn format_fraction_ms(value_ms: u64, unit_ms: u64, suffix: &str) -> String { .unwrap_or(0); format!("{}.{:03}{}", whole, thousandths, suffix) } - -fn format_opt_u64(value: Option) -> String { - value - .map(|val| val.get().to_string()) - .unwrap_or_else(|| "none".to_owned()) -} - -fn format_metrics_range(range: &Option) -> String { - range.as_ref().map_or_else( - || "none".to_owned(), - |range| format!("{}-{}", range.0.start(), range.0.end()), - ) -} - -const fn format_output_format(format: Option) -> &'static str { - match format { - Some(OutputFormat::Text) => "text", - Some(OutputFormat::Json) => "json", - Some(OutputFormat::Jsonl) => "jsonl", - Some(OutputFormat::Csv) => "csv", - Some(OutputFormat::Quiet) => "quiet", - None => "none", - } -} diff --git a/src/application/local_run.rs b/src/application/local_run.rs index 15ab38c..80a682b 100644 --- a/src/application/local_run.rs +++ b/src/application/local_run.rs @@ -6,7 +6,6 @@ use tokio::sync::{mpsc, watch}; use tokio::time::Instant; use tracing::{info, warn}; -use crate::app::logs; use crate::domain::run::ProtocolKind; use crate::error::{AppError, AppResult, ValidationError}; use crate::metrics::{self, Metrics}; @@ -41,6 +40,12 @@ pub(crate) struct RunOutcome { pub runtime_errors: Vec, } +pub(crate) struct LocalRunLogSetup { + pub log_sink: Option>, + pub handles: Vec>>, + pub paths: Vec, +} + pub(crate) struct LocalRunExecutionCommand { protocol: ProtocolKind, settings: LocalRunSettings, @@ -171,7 +176,7 @@ pub(crate) trait OutputPort { run_start: Instant, charts_enabled: bool, summary_enabled: bool, - ) -> AppResult; + ) -> AppResult; fn setup_render_ui( &self, shutdown_tx: &ShutdownSender, @@ -251,7 +256,7 @@ where } let run_start = Instant::now(); - let logs::LogSetup { + let LocalRunLogSetup { log_sink, handles: log_handles, paths: log_paths, @@ -323,7 +328,7 @@ where Err(err) => { runtime_errors.push(format!("Metrics collector task failed: {}", err)); metrics::MetricsReport { - summary: logs::empty_summary(), + summary: empty_summary(), } } }; @@ -342,6 +347,24 @@ where .await } +const fn empty_summary() -> metrics::MetricsSummary { + metrics::MetricsSummary { + duration: std::time::Duration::ZERO, + total_requests: 0, + successful_requests: 0, + error_requests: 0, + timeout_requests: 0, + transport_errors: 0, + non_expected_status: 0, + min_latency_ms: 0, + max_latency_ms: 0, + avg_latency_ms: 0, + success_min_latency_ms: 0, + success_max_latency_ms: 0, + success_avg_latency_ms: 0, + } +} + #[cfg(test)] mod tests { use std::sync::{ @@ -351,8 +374,6 @@ mod tests { use std::time::Duration; use super::*; - use crate::app::logs::LogSetup; - #[derive(Debug, Clone, Copy)] struct FakeAdapterArgs; @@ -410,7 +431,7 @@ mod tests { ) -> tokio::task::JoinHandle { tokio::spawn(async { metrics::MetricsReport { - summary: logs::empty_summary(), + summary: empty_summary(), } }) } @@ -483,8 +504,8 @@ mod tests { _run_start: Instant, _charts_enabled: bool, _summary_enabled: bool, - ) -> AppResult { - Ok(LogSetup { + ) -> AppResult { + Ok(LocalRunLogSetup { log_sink: None, handles: Vec::new(), paths: Vec::new(), @@ -514,7 +535,7 @@ mod tests { ) -> AppResult { self.finalize_called.store(true, Ordering::SeqCst); Ok(RunOutcome { - summary: logs::empty_summary(), + summary: empty_summary(), histogram: metrics::LatencyHistogram::new()?, success_histogram: metrics::LatencyHistogram::new()?, latency_sum_ms: 0, diff --git a/src/application/mod.rs b/src/application/mod.rs index 2ce8401..f06a3bb 100644 --- a/src/application/mod.rs +++ b/src/application/mod.rs @@ -1,4 +1,4 @@ pub(crate) mod commands; pub(crate) mod distributed_run; pub(crate) mod local_run; -pub(crate) mod replay_compare; +pub(crate) mod slice_execution; diff --git a/src/application/slice_execution.rs b/src/application/slice_execution.rs new file mode 100644 index 0000000..bcde620 --- /dev/null +++ b/src/application/slice_execution.rs @@ -0,0 +1,304 @@ +use async_trait::async_trait; + +use crate::error::AppResult; + +#[async_trait] +pub(crate) trait LocalRunPort { + async fn run_local(&self, adapter_args: TAdapterArgs) -> AppResult; +} + +#[async_trait] +pub(crate) trait ReplayRunPort { + async fn run_replay(&self, adapter_args: TAdapterArgs) -> AppResult<()>; +} + +#[async_trait] +pub(crate) trait CleanupPort { + async fn run_cleanup(&self, cleanup_args: TCleanupArgs) -> AppResult<()>; +} + +#[async_trait] +pub(crate) trait ComparePort { + async fn run_compare(&self, compare_args: TCompareArgs) -> AppResult<()>; +} + +pub(crate) trait ServicePort { + fn handle_service_action(&self, adapter_args: TAdapterArgs) -> AppResult<()>; +} + +pub(crate) async fn execute_local( + adapter_args: TAdapterArgs, + local_port: &TPort, +) -> AppResult +where + TPort: LocalRunPort + Sync, +{ + local_port.run_local(adapter_args).await +} + +pub(crate) async fn execute_replay( + adapter_args: TAdapterArgs, + replay_port: &TPort, +) -> AppResult<()> +where + TPort: ReplayRunPort + Sync, +{ + replay_port.run_replay(adapter_args).await +} + +pub(crate) async fn execute_cleanup( + cleanup_args: TCleanupArgs, + cleanup_port: &TPort, +) -> AppResult<()> +where + TPort: CleanupPort + Sync, +{ + cleanup_port.run_cleanup(cleanup_args).await +} + +pub(crate) async fn execute_compare( + compare_args: TCompareArgs, + compare_port: &TPort, +) -> AppResult<()> +where + TPort: ComparePort + Sync, +{ + compare_port.run_compare(compare_args).await +} + +pub(crate) fn execute_service( + adapter_args: TAdapterArgs, + service_port: &TPort, +) -> AppResult<()> +where + TPort: ServicePort, +{ + service_port.handle_service_action(adapter_args) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + + use crate::error::AppResult; + + use super::{ + CleanupPort, ComparePort, LocalRunPort, ReplayRunPort, ServicePort, execute_cleanup, + execute_compare, execute_local, execute_replay, execute_service, + }; + + struct FakeLocalPort { + called: AtomicBool, + seen: Arc>>, + } + + #[async_trait::async_trait] + impl LocalRunPort for FakeLocalPort { + async fn run_local(&self, adapter_args: String) -> AppResult { + self.called.store(true, Ordering::SeqCst); + if let Ok(mut seen) = self.seen.lock() { + seen.push(adapter_args.clone()); + } + Ok(adapter_args.len()) + } + } + + struct FakeReplayPort { + called: AtomicBool, + seen: Arc>>, + } + + #[async_trait::async_trait] + impl ReplayRunPort for FakeReplayPort { + async fn run_replay(&self, adapter_args: String) -> AppResult<()> { + self.called.store(true, Ordering::SeqCst); + if let Ok(mut seen) = self.seen.lock() { + seen.push(adapter_args); + } + Ok(()) + } + } + + struct FakeCleanupPort { + called: AtomicBool, + seen: Arc>>, + } + + #[async_trait::async_trait] + impl CleanupPort for FakeCleanupPort { + async fn run_cleanup(&self, cleanup_args: u64) -> AppResult<()> { + self.called.store(true, Ordering::SeqCst); + if let Ok(mut seen) = self.seen.lock() { + seen.push(cleanup_args); + } + Ok(()) + } + } + + struct FakeComparePort { + called: AtomicBool, + seen: Arc>>, + } + + #[async_trait::async_trait] + impl ComparePort for FakeComparePort { + async fn run_compare(&self, compare_args: u64) -> AppResult<()> { + self.called.store(true, Ordering::SeqCst); + if let Ok(mut seen) = self.seen.lock() { + seen.push(compare_args); + } + Ok(()) + } + } + + struct FakeServicePort { + called: AtomicBool, + seen: Arc>>, + } + + impl ServicePort for FakeServicePort { + fn handle_service_action(&self, adapter_args: String) -> AppResult<()> { + self.called.store(true, Ordering::SeqCst); + if let Ok(mut seen) = self.seen.lock() { + seen.push(adapter_args); + } + Ok(()) + } + } + + #[tokio::test(flavor = "current_thread")] + async fn execute_local_calls_port() -> AppResult<()> { + let seen = Arc::new(Mutex::new(Vec::new())); + let port = FakeLocalPort { + called: AtomicBool::new(false), + seen: seen.clone(), + }; + + let len = execute_local("local".to_owned(), &port).await?; + + if !port.called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "expected local port to be called", + )); + } + if len != 5 { + return Err(crate::error::AppError::validation( + "expected local outcome to be returned", + )); + } + if let Ok(seen) = seen.lock() + && seen.as_slice() != ["local"] + { + return Err(crate::error::AppError::validation( + "expected local args to be forwarded", + )); + } + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn execute_replay_calls_port() -> AppResult<()> { + let seen = Arc::new(Mutex::new(Vec::new())); + let port = FakeReplayPort { + called: AtomicBool::new(false), + seen: seen.clone(), + }; + + execute_replay("replay".to_owned(), &port).await?; + + if !port.called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "expected replay port to be called", + )); + } + if let Ok(seen) = seen.lock() + && seen.as_slice() != ["replay"] + { + return Err(crate::error::AppError::validation( + "expected replay args to be forwarded", + )); + } + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn execute_cleanup_calls_port() -> AppResult<()> { + let seen = Arc::new(Mutex::new(Vec::new())); + let port = FakeCleanupPort { + called: AtomicBool::new(false), + seen: seen.clone(), + }; + + execute_cleanup(42, &port).await?; + + if !port.called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "expected cleanup port to be called", + )); + } + if let Ok(seen) = seen.lock() + && seen.as_slice() != [42] + { + return Err(crate::error::AppError::validation( + "expected cleanup args to be forwarded", + )); + } + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn execute_compare_calls_port() -> AppResult<()> { + let seen = Arc::new(Mutex::new(Vec::new())); + let port = FakeComparePort { + called: AtomicBool::new(false), + seen: seen.clone(), + }; + + execute_compare(77, &port).await?; + + if !port.called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "expected compare port to be called", + )); + } + if let Ok(seen) = seen.lock() + && seen.as_slice() != [77] + { + return Err(crate::error::AppError::validation( + "expected compare args to be forwarded", + )); + } + + Ok(()) + } + + #[test] + fn execute_service_calls_port() -> AppResult<()> { + let seen = Arc::new(Mutex::new(Vec::new())); + let port = FakeServicePort { + called: AtomicBool::new(false), + seen: seen.clone(), + }; + + execute_service("service".to_owned(), &port)?; + + if !port.called.load(Ordering::SeqCst) { + return Err(crate::error::AppError::validation( + "expected service port to be called", + )); + } + if let Ok(seen) = seen.lock() + && seen.as_slice() != ["service"] + { + return Err(crate::error::AppError::validation( + "expected service args to be forwarded", + )); + } + + Ok(()) + } +} diff --git a/src/distributed/agent.rs b/src/distributed/agent.rs index 9852698..a9d3ef3 100644 --- a/src/distributed/agent.rs +++ b/src/distributed/agent.rs @@ -9,13 +9,20 @@ use tracing::{info, warn}; use crate::args::TesterArgs; use crate::error::AppResult; +pub(crate) use run_exec::{AgentLocalRunPort, AgentRunOutcome}; /// Runs the distributed agent loop. /// /// # Errors /// /// Returns an error if the agent cannot connect, negotiate, or execute a run. -pub async fn run_agent(args: TesterArgs) -> AppResult<()> { +pub async fn run_agent( + args: TesterArgs, + local_run_port: &TLocalRunPort, +) -> AppResult<()> +where + TLocalRunPort: AgentLocalRunPort + Sync, +{ let standby = args.agent_standby; let reconnect_delay = Duration::from_millis(args.agent_reconnect_ms.get()); info!( @@ -25,7 +32,7 @@ pub async fn run_agent(args: TesterArgs) -> AppResult<()> { ); loop { - let result = session::run_agent_session(&args).await; + let result = session::run_agent_session(&args, local_run_port).await; match result { Ok(()) => { if !standby { diff --git a/src/distributed/agent/run_exec.rs b/src/distributed/agent/run_exec.rs index fe0f570..eb54054 100644 --- a/src/distributed/agent/run_exec.rs +++ b/src/distributed/agent/run_exec.rs @@ -1,3 +1,4 @@ +use async_trait::async_trait; use tokio::sync::{mpsc, watch}; use tracing::{debug, info}; @@ -13,13 +14,37 @@ use crate::distributed::protocol::{ use crate::distributed::utils::duration_to_ms; use crate::distributed::wire::apply_wire_args; -pub(super) async fn run_agent_run( +#[derive(Debug)] +pub(crate) struct AgentRunOutcome { + pub(crate) summary: crate::metrics::MetricsSummary, + pub(crate) histogram: crate::metrics::LatencyHistogram, + pub(crate) success_histogram: crate::metrics::LatencyHistogram, + pub(crate) latency_sum_ms: u128, + pub(crate) success_latency_sum_ms: u128, + pub(crate) runtime_errors: Vec, +} + +#[async_trait] +pub(crate) trait AgentLocalRunPort { + async fn run_local( + &self, + args: TesterArgs, + stream_tx: Option>, + external_shutdown: Option>, + ) -> AppResult; +} + +pub(super) async fn run_agent_run( base_args: &TesterArgs, config: ConfigMessage, agent_id: String, out_tx: &mpsc::UnboundedSender, cmd_rx: &mut mpsc::UnboundedReceiver, -) -> AppResult<()> { + local_run_port: &TLocalRunPort, +) -> AppResult<()> +where + TLocalRunPort: AgentLocalRunPort + Sync, +{ let ConfigMessage { run_id, args } = config; let mut run_args = base_args.clone(); if let Err(err) = apply_wire_args(&mut run_args, args) { @@ -42,7 +67,7 @@ pub(super) async fn run_agent_run( (None, None) }; - let mut run_future = Box::pin(crate::app::run_local(run_args, stream_tx, Some(stop_rx))); + let mut run_future = Box::pin(local_run_port.run_local(run_args, stream_tx, Some(stop_rx))); let mut abort_reason: Option = None; let run_outcome = loop { diff --git a/src/distributed/agent/session.rs b/src/distributed/agent/session.rs index 9d15147..bb90bf4 100644 --- a/src/distributed/agent/session.rs +++ b/src/distributed/agent/session.rs @@ -10,12 +10,18 @@ use crate::args::TesterArgs; use crate::error::{AppError, AppResult, DistributedError}; use super::command::AgentCommand; -use super::run_exec::run_agent_run; +use super::run_exec::{AgentLocalRunPort, run_agent_run}; use super::wire::{build_agent_id, build_hello, send_wire}; use crate::distributed::protocol::{HeartbeatMessage, WireMessage, read_message, send_message}; use crate::distributed::utils::current_time_ms; -pub(super) async fn run_agent_session(base_args: &TesterArgs) -> AppResult<()> { +pub(super) async fn run_agent_session( + base_args: &TesterArgs, + local_run_port: &TLocalRunPort, +) -> AppResult<()> +where + TLocalRunPort: AgentLocalRunPort + Sync, +{ let join = base_args.agent_join.as_deref().ok_or_else(|| { AppError::distributed(DistributedError::MissingOption { option: "--agent-join", @@ -123,8 +129,15 @@ pub(super) async fn run_agent_session(base_args: &TesterArgs) -> AppResult<()> { tokio::time::sleep(Duration::from_millis(start.start_after_ms)).await; } - let run_result = - run_agent_run(base_args, config, agent_id.clone(), &out_tx, &mut cmd_rx).await; + let run_result = run_agent_run( + base_args, + config, + agent_id.clone(), + &out_tx, + &mut cmd_rx, + local_run_port, + ) + .await; if let Err(err) = run_result { if !base_args.agent_standby { diff --git a/src/distributed/controller/tests/aggregation.rs b/src/distributed/controller/tests/aggregation.rs index 8315bc9..61de91f 100644 --- a/src/distributed/controller/tests/aggregation.rs +++ b/src/distributed/controller/tests/aggregation.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use crate::error::{AppError, AppResult}; -use super::{AgentSnapshot, WireSummary, aggregate_snapshots, build_hist}; +use super::{ + AgentSnapshot, WireSummary, aggregate_snapshots, build_hist, record_aggregated_sample, +}; #[test] fn aggregate_snapshots_merges_summary() -> AppResult<()> { @@ -117,3 +119,122 @@ fn aggregate_snapshots_merges_summary() -> AppResult<()> { } Ok(()) } + +#[test] +fn record_aggregated_sample_deduplicates_identical_snapshots() -> AppResult<()> { + let summary = WireSummary { + duration_ms: 1000, + total_requests: 10, + successful_requests: 9, + error_requests: 1, + timeout_requests: 0, + transport_errors: 0, + non_expected_status: 0, + success_min_latency_ms: 10, + success_max_latency_ms: 20, + success_latency_sum_ms: 150, + min_latency_ms: 10, + max_latency_ms: 20, + latency_sum_ms: 180, + }; + let hist = build_hist(&[10, 20])?; + let success_hist = build_hist(&[10, 20])?; + let mut agent_states = HashMap::new(); + agent_states.insert( + "a".to_owned(), + AgentSnapshot { + summary, + histogram: hist, + success_histogram: success_hist, + }, + ); + + let mut samples = Vec::new(); + record_aggregated_sample(&mut samples, &agent_states); + record_aggregated_sample(&mut samples, &agent_states); + if samples.len() != 1 { + return Err(AppError::distributed(format!( + "Expected one deduplicated sample, got {}", + samples.len() + ))); + } + + let next_summary = WireSummary { + duration_ms: 2000, + total_requests: 15, + successful_requests: 14, + error_requests: 1, + timeout_requests: 0, + transport_errors: 0, + non_expected_status: 0, + success_min_latency_ms: 10, + success_max_latency_ms: 25, + success_latency_sum_ms: 220, + min_latency_ms: 10, + max_latency_ms: 25, + latency_sum_ms: 250, + }; + agent_states.insert( + "a".to_owned(), + AgentSnapshot { + summary: next_summary, + histogram: build_hist(&[10, 25])?, + success_histogram: build_hist(&[10, 25])?, + }, + ); + record_aggregated_sample(&mut samples, &agent_states); + if samples.len() != 2 { + return Err(AppError::distributed(format!( + "Expected second sample after state change, got {}", + samples.len() + ))); + } + Ok(()) +} + +#[test] +fn aggregate_snapshots_handles_many_agents() -> AppResult<()> { + let mut agent_states = HashMap::new(); + for idx in 0..128u64 { + let summary = WireSummary { + duration_ms: 1000, + total_requests: 1, + successful_requests: 1, + error_requests: 0, + timeout_requests: 0, + transport_errors: 0, + non_expected_status: 0, + success_min_latency_ms: 10 + idx, + success_max_latency_ms: 10 + idx, + success_latency_sum_ms: u128::from(10 + idx), + min_latency_ms: 10 + idx, + max_latency_ms: 10 + idx, + latency_sum_ms: u128::from(10 + idx), + }; + agent_states.insert( + format!("agent-{}", idx), + AgentSnapshot { + summary, + histogram: build_hist(&[10 + idx])?, + success_histogram: build_hist(&[10 + idx])?, + }, + ); + } + + let (summary, merged_hist, success_hist) = aggregate_snapshots(&agent_states)?; + if summary.total_requests != 128 { + return Err(AppError::distributed(format!( + "Unexpected total request count for large aggregation: {}", + summary.total_requests + ))); + } + if merged_hist.count() != 128 || success_hist.count() != 128 { + return Err(AppError::distributed(format!( + "Unexpected histogram counts for large aggregation: total={}, success={}", + merged_hist.count(), + success_hist.count() + ))); + } + + Ok(()) +} diff --git a/src/distributed/controller/tests/events.rs b/src/distributed/controller/tests/events.rs new file mode 100644 index 0000000..7fc1436 --- /dev/null +++ b/src/distributed/controller/tests/events.rs @@ -0,0 +1,203 @@ +use std::collections::{HashMap, HashSet}; + +use crate::error::{AppError, AppResult}; +use crate::metrics::LatencyHistogram; + +use super::super::shared::AgentEvent; +use super::handle_agent_event; +use crate::distributed::protocol::{ReportMessage, StreamMessage, WireSummary}; + +fn summary_fixture() -> WireSummary { + WireSummary { + duration_ms: 1000, + total_requests: 10, + successful_requests: 9, + error_requests: 1, + timeout_requests: 0, + transport_errors: 0, + non_expected_status: 0, + success_min_latency_ms: 10, + success_max_latency_ms: 30, + success_latency_sum_ms: 160, + min_latency_ms: 10, + max_latency_ms: 30, + latency_sum_ms: 200, + } +} + +fn histogram_b64_fixture() -> AppResult { + let mut histogram = LatencyHistogram::new()?; + histogram.record(10)?; + histogram.record(30)?; + histogram.encode_base64() +} + +#[test] +fn disconnected_event_marks_agent_as_failed() -> AppResult<()> { + let mut pending_agents = HashSet::from(["agent-1".to_owned()]); + let mut agent_states = HashMap::new(); + let mut runtime_errors = Vec::new(); + + handle_agent_event( + AgentEvent::Disconnected { + agent_id: "agent-1".to_owned(), + message: "socket closed".to_owned(), + }, + "run-1", + &mut pending_agents, + &mut agent_states, + &mut runtime_errors, + ); + + if pending_agents.contains("agent-1") { + return Err(AppError::distributed( + "Expected disconnected agent to be removed from pending set", + )); + } + if !runtime_errors + .iter() + .any(|error| error.contains("disconnected")) + { + return Err(AppError::distributed( + "Expected disconnected event to be reported as runtime error", + )); + } + Ok(()) +} + +#[test] +fn report_with_mismatched_run_id_is_rejected() -> AppResult<()> { + let mut pending_agents = HashSet::from(["agent-1".to_owned()]); + let mut agent_states = HashMap::new(); + let mut runtime_errors = Vec::new(); + let report = ReportMessage { + run_id: "wrong-run".to_owned(), + agent_id: "agent-1".to_owned(), + summary: summary_fixture(), + histogram_b64: histogram_b64_fixture()?, + success_histogram_b64: None, + runtime_errors: vec![], + }; + + handle_agent_event( + AgentEvent::Report { + agent_id: "agent-1".to_owned(), + message: report, + }, + "run-1", + &mut pending_agents, + &mut agent_states, + &mut runtime_errors, + ); + + if pending_agents.contains("agent-1") { + return Err(AppError::distributed( + "Expected mismatched run id report to clear pending agent", + )); + } + if !runtime_errors + .iter() + .any(|error| error.contains("mismatched run id")) + { + return Err(AppError::distributed( + "Expected mismatched run id to be reported as runtime error", + )); + } + if !agent_states.is_empty() { + return Err(AppError::distributed( + "Expected mismatched run id report not to update agent state", + )); + } + Ok(()) +} + +#[test] +fn report_with_mismatched_agent_id_is_rejected() -> AppResult<()> { + let mut pending_agents = HashSet::from(["agent-1".to_owned()]); + let mut agent_states = HashMap::new(); + let mut runtime_errors = Vec::new(); + let report = ReportMessage { + run_id: "run-1".to_owned(), + agent_id: "agent-2".to_owned(), + summary: summary_fixture(), + histogram_b64: histogram_b64_fixture()?, + success_histogram_b64: None, + runtime_errors: vec![], + }; + + handle_agent_event( + AgentEvent::Report { + agent_id: "agent-1".to_owned(), + message: report, + }, + "run-1", + &mut pending_agents, + &mut agent_states, + &mut runtime_errors, + ); + + if pending_agents.contains("agent-1") { + return Err(AppError::distributed( + "Expected mismatched agent id report to clear pending agent", + )); + } + if !runtime_errors + .iter() + .any(|error| error.contains("unexpected id")) + { + return Err(AppError::distributed( + "Expected mismatched agent id to be reported as runtime error", + )); + } + if !agent_states.is_empty() { + return Err(AppError::distributed( + "Expected mismatched agent id report not to update agent state", + )); + } + Ok(()) +} + +#[test] +fn stream_with_invalid_histogram_reports_decode_error() -> AppResult<()> { + let mut pending_agents = HashSet::from(["agent-1".to_owned()]); + let mut agent_states = HashMap::new(); + let mut runtime_errors = Vec::new(); + let stream = StreamMessage { + run_id: "run-1".to_owned(), + agent_id: "agent-1".to_owned(), + summary: summary_fixture(), + histogram_b64: "not-a-valid-histogram".to_owned(), + success_histogram_b64: None, + }; + + handle_agent_event( + AgentEvent::Stream { + agent_id: "agent-1".to_owned(), + message: stream, + }, + "run-1", + &mut pending_agents, + &mut agent_states, + &mut runtime_errors, + ); + + if !pending_agents.contains("agent-1") { + return Err(AppError::distributed( + "Expected stream decode failure not to remove pending agent", + )); + } + if !runtime_errors + .iter() + .any(|error| error.contains("histogram decode failed")) + { + return Err(AppError::distributed( + "Expected stream decode failure to be reported as runtime error", + )); + } + if !agent_states.is_empty() { + return Err(AppError::distributed( + "Expected stream decode failure not to update agent state", + )); + } + Ok(()) +} diff --git a/src/distributed/controller/tests/mod.rs b/src/distributed/controller/tests/mod.rs index 296681f..1a7374d 100644 --- a/src/distributed/controller/tests/mod.rs +++ b/src/distributed/controller/tests/mod.rs @@ -5,9 +5,12 @@ use crate::error::AppResult; use crate::metrics::LatencyHistogram; use super::super::protocol::WireSummary; -use super::shared::{AgentSnapshot, aggregate_snapshots, update_ui}; +use super::shared::{ + AgentSnapshot, aggregate_snapshots, handle_agent_event, record_aggregated_sample, update_ui, +}; mod aggregation; +mod events; mod ui; fn build_hist(values: &[u64]) -> AppResult { diff --git a/src/distributed/mod.rs b/src/distributed/mod.rs index 7c1b3d7..4dc42d9 100644 --- a/src/distributed/mod.rs +++ b/src/distributed/mod.rs @@ -5,8 +5,8 @@ mod summary; mod utils; mod wire; -pub use agent::run_agent; -pub use controller::run_controller; +pub(crate) use agent::{AgentLocalRunPort, AgentRunOutcome, run_agent}; +pub(crate) use controller::run_controller; #[cfg(test)] mod tests; diff --git a/src/distributed/summary.rs b/src/distributed/summary.rs index d8f08e5..a4d5d64 100644 --- a/src/distributed/summary.rs +++ b/src/distributed/summary.rs @@ -1,8 +1,8 @@ use std::time::Duration; -use crate::app::summary as app_summary; use crate::args::TesterArgs; use crate::metrics::MetricsSummary; +use crate::system::{chart_status_line, selection_lines}; use super::protocol::WireSummary; @@ -204,12 +204,9 @@ pub(super) fn print_summary( stats.avg_rpm_x100 % PERCENT_DIVISOR ); - println!( - "{}", - app_summary::chart_status_line(args, charts_output_path, false) - ); + println!("{}", chart_status_line(args, charts_output_path, false)); if args.show_selections { - for line in app_summary::selection_lines(args, charts_output_path) { + for line in selection_lines(args, charts_output_path) { println!("{}", line); } } diff --git a/src/distributed/tests/mod.rs b/src/distributed/tests/mod.rs index ab0d7ab..a4067ab 100644 --- a/src/distributed/tests/mod.rs +++ b/src/distributed/tests/mod.rs @@ -6,11 +6,13 @@ use tokio::sync::watch; use super::protocol::WireArgs; use super::wire::{apply_wire_args, build_wire_args}; -use super::{run_agent, run_controller}; +use super::{AgentLocalRunPort, AgentRunOutcome, run_agent, run_controller}; use crate::args::{HttpMethod, LoadMode, PositiveU64, PositiveUsize, Protocol, TesterArgs}; use crate::error::{AppError, AppResult}; +use crate::metrics::StreamSnapshot; mod sink_runs; +mod stability; mod wire_args; fn positive_u64(value: u64) -> AppResult { @@ -235,7 +237,8 @@ async fn run_distributed(controller_args: TesterArgs, agent_args: TesterArgs) -> let controller_handle = tokio::spawn(async move { run_controller(&controller_args, None).await }); tokio::time::sleep(Duration::from_millis(200)).await; - let agent_result = run_agent(agent_args).await; + let local_port = TestAgentLocalRunPort; + let agent_result = run_agent(agent_args, &local_port).await; let controller_result = controller_handle .await .map_err(|err| AppError::distributed(format!("Controller task join failed: {}", err)))?; @@ -243,3 +246,25 @@ async fn run_distributed(controller_args: TesterArgs, agent_args: TesterArgs) -> controller_result?; Ok(()) } + +struct TestAgentLocalRunPort; + +#[async_trait::async_trait] +impl AgentLocalRunPort for TestAgentLocalRunPort { + async fn run_local( + &self, + args: TesterArgs, + stream_tx: Option>, + external_shutdown: Option>, + ) -> AppResult { + let outcome = crate::app::run_local(args, stream_tx, external_shutdown).await?; + Ok(AgentRunOutcome { + summary: outcome.summary, + histogram: outcome.histogram, + success_histogram: outcome.success_histogram, + latency_sum_ms: outcome.latency_sum_ms, + success_latency_sum_ms: outcome.success_latency_sum_ms, + runtime_errors: outcome.runtime_errors, + }) + } +} diff --git a/src/distributed/tests/stability.rs b/src/distributed/tests/stability.rs new file mode 100644 index 0000000..0bf7a09 --- /dev/null +++ b/src/distributed/tests/stability.rs @@ -0,0 +1,126 @@ +use std::time::Duration; + +use crate::error::{AppError, AppResult}; + +use super::{ + allocate_port, base_args, positive_u64, run_async_test, run_distributed, + spawn_http_server_or_skip, +}; + +#[test] +fn distributed_streaming_soak_multiple_runs_remains_stable() -> AppResult<()> { + run_async_test(async { + let Some((url, shutdown_tx)) = spawn_http_server_or_skip().await? else { + return Ok(()); + }; + let tmp_dir = tempfile::tempdir() + .map_err(|err| AppError::distributed(format!("Failed to create temp dir: {}", err)))?; + + for run_idx in 0..3 { + let run_tmp = tmp_dir.path().join(format!("soak-run-{}", run_idx)); + std::fs::create_dir_all(&run_tmp).map_err(|err| { + AppError::distributed(format!( + "Failed to create soak run directory {}: {}", + run_tmp.display(), + err + )) + })?; + let tmp_path = run_tmp + .to_str() + .ok_or_else(|| AppError::distributed("Failed to convert tmp path"))? + .to_owned(); + + let controller_port = allocate_port()?; + let controller_addr = format!("127.0.0.1:{}", controller_port); + + let mut controller_args = base_args(url.clone(), tmp_path.clone())?; + controller_args.controller_listen = Some(controller_addr.clone()); + controller_args.distributed_stream_summaries = true; + controller_args.distributed_stream_interval_ms = Some(positive_u64(200)?); + controller_args.target_duration = positive_u64(1)?; + + let mut agent_args = base_args(url.clone(), tmp_path)?; + agent_args.agent_join = Some(controller_addr); + agent_args.distributed_stream_summaries = true; + agent_args.distributed_stream_interval_ms = Some(positive_u64(200)?); + agent_args.target_duration = positive_u64(1)?; + + let run_result = tokio::time::timeout( + Duration::from_secs(15), + run_distributed(controller_args, agent_args), + ) + .await + .map_err(|err| { + AppError::distributed(format!("Timed out waiting for soak run: {}", err)) + })?; + run_result.map_err(|err| { + AppError::distributed(format!("Soak run {} failed: {}", run_idx, err)) + })?; + } + + shutdown_tx + .send(true) + .map_err(|err| AppError::distributed(format!("Failed to shutdown server: {}", err)))?; + + Ok(()) + }) +} + +#[test] +fn distributed_auth_token_mismatch_fails_fast() -> AppResult<()> { + run_async_test(async { + let Some((url, shutdown_tx)) = spawn_http_server_or_skip().await? else { + return Ok(()); + }; + let controller_port = allocate_port()?; + let controller_addr = format!("127.0.0.1:{}", controller_port); + let tmp_dir = tempfile::tempdir() + .map_err(|err| AppError::distributed(format!("Failed to create temp dir: {}", err)))?; + let tmp_path = tmp_dir + .path() + .to_str() + .ok_or_else(|| AppError::distributed("Failed to convert tmp path"))? + .to_owned(); + + let mut controller_args = base_args(url.clone(), tmp_path.clone())?; + controller_args.controller_listen = Some(controller_addr.clone()); + controller_args.auth_token = Some("controller-secret".to_owned()); + controller_args.agent_wait_timeout_ms = Some(positive_u64(500)?); + + let mut agent_args = base_args(url, tmp_path)?; + agent_args.agent_join = Some(controller_addr); + agent_args.auth_token = Some("agent-secret-wrong".to_owned()); + + let run_result = tokio::time::timeout( + Duration::from_secs(10), + run_distributed(controller_args, agent_args), + ) + .await + .map_err(|err| { + AppError::distributed(format!("Timed out waiting for auth failure: {}", err)) + })?; + + shutdown_tx + .send(true) + .map_err(|err| AppError::distributed(format!("Failed to shutdown server: {}", err)))?; + + let run_error = match run_result { + Ok(()) => { + return Err(AppError::distributed( + "Expected distributed auth token mismatch to fail", + )); + } + Err(err) => err, + }; + + let error_text = run_error.to_string().to_ascii_lowercase(); + if !error_text.contains("auth token") { + return Err(AppError::distributed(format!( + "Expected auth token mismatch error, got: {}", + run_error + ))); + } + + Ok(()) + }) +} diff --git a/src/entry/plan/execute.rs b/src/entry/plan/execute.rs index ab48501..6f3ee98 100644 --- a/src/entry/plan/execute.rs +++ b/src/entry/plan/execute.rs @@ -1,13 +1,11 @@ -use std::collections::BTreeMap; - -use async_trait::async_trait; use rand::distributions::Distribution; use rand::thread_rng; -use crate::app::{self, run_cleanup, run_compare, run_local, run_replay}; -use crate::application::distributed_run::{self, DistributedRunPort}; -use crate::args::TesterArgs; -use crate::config::types::ScenarioConfig; +use crate::adapters::runtime::{ + RuntimeCleanupPort, RuntimeComparePort, RuntimeDistributedPort, RuntimeLocalPort, + RuntimeReplayPort, RuntimeServicePort, print_runtime_errors, +}; +use crate::application::{distributed_run, slice_execution}; use crate::domain::run::RunConfig; use crate::error::{AppError, AppResult, ValidationError}; use crate::system::banner; @@ -16,18 +14,25 @@ use super::types::{DumpUrlsPlan, RunPlan}; pub(crate) async fn execute_plan(plan: RunPlan) -> AppResult<()> { match plan { - RunPlan::Cleanup(cleanup_args) => run_cleanup(&cleanup_args).await, - RunPlan::Compare(compare_args) => run_compare(&compare_args).await, + RunPlan::Cleanup(cleanup_args) => { + let cleanup_port = RuntimeCleanupPort; + slice_execution::execute_cleanup(cleanup_args, &cleanup_port).await + } + RunPlan::Compare(compare_args) => { + let compare_port = RuntimeComparePort; + slice_execution::execute_compare(compare_args, &compare_port).await + } RunPlan::Replay { command, args } => { log_run_command("replay", command.run_config()); banner::print_cli_banner(command.no_color()); println!(); - run_replay(&args).await + let replay_port = RuntimeReplayPort; + slice_execution::execute_replay(args, &replay_port).await } RunPlan::DumpUrls(plan) => dump_urls(plan), RunPlan::Service(args) => { - crate::service::handle_service_action(&args)?; - Ok(()) + let service_port = RuntimeServicePort; + slice_execution::execute_service(args, &service_port) } RunPlan::Distributed { command, args } => { log_run_command(command.mode_name(), command.run_config()); @@ -40,13 +45,14 @@ pub(crate) async fn execute_plan(plan: RunPlan) -> AppResult<()> { log_run_command("local", command.run_config()); banner::print_cli_banner(command.no_color()); println!(); - let outcome = match run_local(args, None, None).await { + let local_port = RuntimeLocalPort; + let outcome = match slice_execution::execute_local(args, &local_port).await { Ok(outcome) => outcome, Err(AppError::Validation(ValidationError::RunCancelled)) => return Ok(()), Err(err) => return Err(err), }; if !outcome.runtime_errors.is_empty() { - app::print_runtime_errors(&outcome.runtime_errors); + print_runtime_errors(&outcome.runtime_errors); return Err(AppError::validation(ValidationError::RuntimeErrors)); } Ok(()) @@ -54,23 +60,6 @@ pub(crate) async fn execute_plan(plan: RunPlan) -> AppResult<()> { } } -struct RuntimeDistributedPort; - -#[async_trait] -impl DistributedRunPort for RuntimeDistributedPort { - async fn run_controller( - &self, - adapter_args: &TesterArgs, - scenarios: Option>, - ) -> AppResult<()> { - crate::distributed::run_controller(adapter_args, scenarios).await - } - - async fn run_agent(&self, adapter_args: &TesterArgs) -> AppResult<()> { - crate::distributed::run_agent(adapter_args.clone()).await - } -} - fn log_run_command(kind: &str, run_config: &RunConfig) { let target = run_config.target_url.as_deref().unwrap_or(""); let scenario_steps = run_config.scenario_step_count(); diff --git a/src/system/mod.rs b/src/system/mod.rs index 3d96a07..ed1c9a9 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -2,4 +2,8 @@ pub(crate) mod banner; pub(crate) mod logger; #[cfg(feature = "wasm")] pub(crate) mod probestack; +pub(crate) mod replay_compare; pub(crate) mod shutdown_handlers; +pub(crate) mod summary_output; + +pub(crate) use summary_output::{chart_status_line, selection_lines}; diff --git a/src/application/replay_compare.rs b/src/system/replay_compare.rs similarity index 100% rename from src/application/replay_compare.rs rename to src/system/replay_compare.rs diff --git a/src/system/summary_output.rs b/src/system/summary_output.rs new file mode 100644 index 0000000..0c99f8d --- /dev/null +++ b/src/system/summary_output.rs @@ -0,0 +1,115 @@ +use crate::args::{OutputFormat, PositiveU64, TesterArgs}; +use crate::metrics::MetricsRange; + +pub(crate) fn selection_lines(args: &TesterArgs, charts_output_path: Option<&str>) -> Vec { + let mut lines = Vec::new(); + lines.push("Selections:".to_owned()); + lines.push(format!("protocol: {}", args.protocol.as_str())); + lines.push(format!("load_mode: {}", args.load_mode.as_str())); + lines.push(format!("url: {}", args.url.as_deref().unwrap_or("none"))); + lines.push(format!("method: {:?}", args.method)); + lines.push(format!("duration_s: {}", args.target_duration.get())); + lines.push(format!("requests: {}", format_opt_u64(args.requests))); + lines.push(format!( + "rate_limit_rps: {}", + format_opt_u64(args.rate_limit) + )); + lines.push(format!("max_tasks: {}", args.max_tasks.get())); + lines.push(format!("spawn_rate: {}", args.spawn_rate_per_tick.get())); + lines.push(format!("spawn_interval_ms: {}", args.tick_interval.get())); + lines.push(format!("expected_status: {}", args.expected_status_code)); + lines.push(format!( + "request_timeout_ms: {}", + args.request_timeout.as_millis() + )); + lines.push(format!( + "connect_timeout_ms: {}", + args.connect_timeout.as_millis() + )); + lines.push(format!("redirect_limit: {}", args.redirect_limit)); + lines.push(format!("no_tui: {}", args.no_ui)); + lines.push(format!("summary: {}", args.summary)); + lines.push(format!("no_charts: {}", args.no_charts)); + lines.push(format!("charts_path: {}", args.charts_path)); + lines.push(format!( + "charts_latency_bucket_ms: {}", + args.charts_latency_bucket_ms.get() + )); + lines.push(format!("tmp_path: {}", args.tmp_path)); + lines.push(format!("keep_tmp: {}", args.keep_tmp)); + lines.push(format!( + "metrics_range: {}", + format_metrics_range(&args.metrics_range) + )); + lines.push(format!("metrics_max: {}", args.metrics_max.get())); + lines.push(format!( + "output_format: {}", + format_output_format(args.output_format) + )); + lines.push(format!( + "output: {}", + args.output.as_deref().unwrap_or("none") + )); + lines.push(format!( + "export_csv: {}", + args.export_csv.as_deref().unwrap_or("none") + )); + lines.push(format!( + "export_json: {}", + args.export_json.as_deref().unwrap_or("none") + )); + lines.push(format!( + "export_jsonl: {}", + args.export_jsonl.as_deref().unwrap_or("none") + )); + lines.push(format!("no_color: {}", args.no_color)); + lines.push(format!( + "charts_output: {}", + charts_output_path.unwrap_or("none") + )); + lines +} + +pub(crate) fn chart_status_line( + args: &TesterArgs, + charts_output_path: Option<&str>, + metrics_truncated: bool, +) -> String { + if args.no_charts { + return "Charts: disabled (--no-charts selected)".to_owned(); + } + if let Some(path) = charts_output_path { + return format!("Charts: saved in {}", path); + } + if metrics_truncated { + return format!( + "Charts: enabled (truncated at {} metrics).", + args.metrics_max.get() + ); + } + "Charts: enabled".to_owned() +} + +fn format_opt_u64(value: Option) -> String { + value + .map(|val| val.get().to_string()) + .unwrap_or_else(|| "none".to_owned()) +} + +fn format_metrics_range(range: &Option) -> String { + range.as_ref().map_or_else( + || "none".to_owned(), + |range| format!("{}-{}", range.0.start(), range.0.end()), + ) +} + +const fn format_output_format(format: Option) -> &'static str { + match format { + Some(OutputFormat::Text) => "text", + Some(OutputFormat::Json) => "json", + Some(OutputFormat::Jsonl) => "jsonl", + Some(OutputFormat::Csv) => "csv", + Some(OutputFormat::Quiet) => "quiet", + None => "none", + } +} From 9bb75c29aa164de1e1fee153e73c38f4460d5e24 Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 14 Feb 2026 17:53:28 +0100 Subject: [PATCH 3/4] docs: add ARD/ADR technical architecture guidance --- CHANGELOG.md | 6 +- docs/README.md | 2 + docs/architecture/README.md | 2 + .../adr/ADR-0001-hexagonal-vertical-slices.md | 1 + ...R-0002-type-safety-dispatch-concurrency.md | 54 ++++++++++++ docs/architecture/adr/README.md | 5 ++ .../ard/ARCHITECTURE_TECHNICAL_GUIDE.md | 82 +++++++++++++++++++ docs/architecture/ard/README.md | 1 + 8 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/adr/ADR-0002-type-safety-dispatch-concurrency.md create mode 100644 docs/architecture/ard/ARCHITECTURE_TECHNICAL_GUIDE.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a543f7..4a587aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ The format is based on Keep a Changelog, and this project follows SemVer. - Added architecture guardrails that fail checks when `src/application` reintroduces `TesterArgs` or `crate::args` imports. - Removed remaining non-test direct `distributed -> app` and `application -> app` imports by introducing explicit runtime ports and shared summary-output utilities, so orchestration flows now route via application/adapters seams. - Added migration validation coverage for all run-plan feature routes (local, distributed controller/agent, replay, compare, cleanup, dump-urls, service) and distributed/local application dispatch seams. -- Updated architecture docs with Phase 7 implementation artifacts, current coupling metrics snapshot, and Mermaid before/after boundary diagrams. -- Added technical architecture patterns doc covering type-level invariants/newtypes, invalid-state elimination, cache/inlining guidance, dispatch strategy (static-first), and low-lock concurrency patterns using `Arc`, atomics, channels, and `ArcShift`. -- Simplified architecture references by removing legacy migration-risk and baseline-metrics ARD documents and retaining a current flow-focused architecture overview. +- Updated architecture docs to a current flow-focused overview with explicit mode-by-mode call chains. +- Added technical architecture docs across ARD/ADR/patterns covering type-level invariants/newtypes, invalid-state elimination, cache/inlining guidance, dispatch strategy (static-first), and low-lock concurrency patterns using `Arc`, atomics, channels, and `ArcShift`. +- Removed legacy migration-risk and baseline-metrics ARD documents and updated doc indexes/references accordingly. ## 0.1.9 diff --git a/docs/README.md b/docs/README.md index 7e28ab3..bd415fb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,9 @@ This folder is organized by concern: - `docs/architecture/README.md`: architecture document taxonomy and conventions. - `docs/architecture/ard/ARCHITECTURE_OVERVIEW.md`: current mode-by-mode call-flow overview. +- `docs/architecture/ard/ARCHITECTURE_TECHNICAL_GUIDE.md`: technical implementation guide for invariants, dispatch, and concurrency. - `docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md`: accepted architecture decision. +- `docs/architecture/adr/ADR-0002-type-safety-dispatch-concurrency.md`: accepted technical decision for type safety, dispatch, and low-lock concurrency. - `docs/architecture/patterns/type-safety-performance-concurrency.md`: technical patterns for type invariants, dispatch, cache behavior, and lock-free concurrency. ## Assets diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 238a414..5357aca 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -13,7 +13,9 @@ This directory is split by architecture document type. ## Current Canonical Docs - `docs/architecture/ard/ARCHITECTURE_OVERVIEW.md` +- `docs/architecture/ard/ARCHITECTURE_TECHNICAL_GUIDE.md` - `docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md` +- `docs/architecture/adr/ADR-0002-type-safety-dispatch-concurrency.md` ## Naming Conventions diff --git a/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md b/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md index 2bc301e..f0ab20b 100644 --- a/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md +++ b/docs/architecture/adr/ADR-0001-hexagonal-vertical-slices.md @@ -49,3 +49,4 @@ Adopt a phased migration architecture with these boundaries: - Phase 1 introduces typed commands and mapping from CLI args. - Phase 2 moves config precedence to explicit override policies. - Later phases extract local/distributed/replay slices behind ports. +- Detailed technical execution guidance is captured in `ADR-0002-type-safety-dispatch-concurrency.md`. diff --git a/docs/architecture/adr/ADR-0002-type-safety-dispatch-concurrency.md b/docs/architecture/adr/ADR-0002-type-safety-dispatch-concurrency.md new file mode 100644 index 0000000..3bdb691 --- /dev/null +++ b/docs/architecture/adr/ADR-0002-type-safety-dispatch-concurrency.md @@ -0,0 +1,54 @@ +# ADR-0002: Type-Safe APIs, Static-First Dispatch, and Low-Lock Concurrency + +- Status: Accepted +- Date: 2026-02-14 +- Deciders: strest maintainers + +## Context + +After Phase 7 boundary migration, architecture seams are established (`entry -> application -> adapters -> slices`). + +The next risk is implementation drift inside those seams: +- invariants moving back to ad-hoc runtime checks +- overuse of dynamic dispatch on hot paths +- lock contention in concurrent execution paths + +## Decision + +Adopt these technical architecture rules: + +1. Type-safety first +- Encode invariants in types (newtypes/enums) at system boundaries. +- Prefer APIs that cannot represent invalid combinations. + +2. Static dispatch by default +- Use static/generic dispatch for core orchestration and hot paths. +- Use dynamic dispatch only at explicit runtime-extension boundaries. + +3. Low-lock concurrency +- Prefer `Arc`, atomics, and channels for high-frequency coordination. +- Use `ArcShift` for read-mostly shared snapshots in manual controller flows. +- Avoid lock scopes that cross `.await`. + +4. Performance policy +- Optimize for cache behavior and allocation discipline first. +- Use explicit inlining annotations only with profiling evidence. + +## Consequences + +### Positive +- Stronger compile-time guarantees for config and execution state. +- Lower overhead on core execution paths. +- Better concurrency behavior under distributed/high-load scenarios. +- Clearer review criteria for architecture-sensitive changes. + +### Tradeoffs +- More up-front type modeling work. +- Possible binary-size increase from monomorphization in generic paths. +- Requires discipline to keep dynamic dispatch limited to boundary seams. + +## Follow-up + +- Keep the implementation guidance in `docs/architecture/ard/ARCHITECTURE_TECHNICAL_GUIDE.md`. +- Keep reusable rule summaries in `docs/architecture/patterns/type-safety-performance-concurrency.md`. +- Enforce boundary constraints through existing architecture checks and code review. diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index f27b893..ba4a221 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -4,6 +4,11 @@ Architecture Decision Records. Use this folder for accepted and superseded architectural decisions. Each ADR should capture context, decision, consequences, and follow-up. +Current docs: + +- `ADR-0001-hexagonal-vertical-slices.md` +- `ADR-0002-type-safety-dispatch-concurrency.md` + Naming: - `ADR--.md` diff --git a/docs/architecture/ard/ARCHITECTURE_TECHNICAL_GUIDE.md b/docs/architecture/ard/ARCHITECTURE_TECHNICAL_GUIDE.md new file mode 100644 index 0000000..d70b6b7 --- /dev/null +++ b/docs/architecture/ard/ARCHITECTURE_TECHNICAL_GUIDE.md @@ -0,0 +1,82 @@ +# Architecture Technical Guide + +## Purpose + +This ARD defines technical implementation guidance for type safety, performance, and concurrency in the current hexagonal architecture. + +This document is implementation-focused. +For the formal decision, see `docs/architecture/adr/ADR-0002-type-safety-dispatch-concurrency.md`. + +## 1. Type Invariants with Newtypes + +Encode constraints in types instead of scattering runtime checks. + +Guidance: +- Use constrained newtypes for validated inputs. +- Prefer enums over loosely related boolean flags. +- Validate at boundaries (CLI/config/wire), then pass typed values inward. + +Current examples in code: +- `PositiveU64` and `PositiveUsize` in `src/args`. +- Typed run-plan commands in `src/application/commands.rs`. + +## 2. Make Invalid States Unrepresentable + +Shape APIs so impossible states cannot be expressed. + +Guidance: +- Split mode-specific behavior into explicit command variants. +- Use dedicated structs for per-mode required data. +- Avoid optional fields when a field is logically required for a mode. + +Current examples: +- Entry run plan variants in `src/entry/plan/types.rs`. +- Slice execution ports in `src/application/slice_execution.rs`. + +## 3. Cache Locality and Inlining + +Prioritize data movement and predictable access patterns in hot paths. + +Guidance: +- Keep hot-loop data contiguous and iteration predictable. +- Reuse allocations and pre-size collections when bounds are known. +- Avoid unnecessary clones in request/metrics paths. +- Let the compiler inline by default. +- Use forced inlining only when profiling demonstrates repeatable gains. + +## 4. Dispatch Strategy (Static Preferred) + +Static dispatch is the default for core execution paths. + +Guidance: +- Prefer generic/static dispatch in application and runtime orchestration. +- Use dynamic dispatch only at extension boundaries that require runtime selection. +- Keep trait-object usage near boundaries, then transition to concrete/static calls. + +Current examples: +- Static/generic orchestration in `src/application/local_run.rs`. +- Dynamic extension boundary in protocol registry (`src/protocol/registry.rs`). + +## 5. Low-Lock Concurrency + +Prefer immutable sharing + atomics/channels over lock-heavy shared mutation. + +Guidance: +- Use `Arc` for shared ownership. +- Use atomics for counters/flags. +- Use channels (`mpsc`, `watch`) for signaling and data handoff. +- Use `ArcShift` for read-mostly shared snapshots with occasional whole-value replacement. +- Avoid holding locks across `.await`. + +Current examples: +- Agent/controller coordination in `src/distributed`. +- Manual controller shared state snapshots via `ArcShift` in `src/distributed/controller/manual`. + +## 6. Review Checklist + +For architecture-sensitive changes, verify: +1. Invariants are encoded in types at the boundary. +2. API shape prevents invalid state combinations. +3. Dispatch choice is explicit and static-first. +4. Shared state avoids unnecessary lock contention. +5. Required contribution checks pass. diff --git a/docs/architecture/ard/README.md b/docs/architecture/ard/README.md index 61b3d01..d8e0875 100644 --- a/docs/architecture/ard/README.md +++ b/docs/architecture/ard/README.md @@ -7,3 +7,4 @@ Use this folder for architectural analysis, dependency maps, risk registers, and Current docs: - `ARCHITECTURE_OVERVIEW.md` +- `ARCHITECTURE_TECHNICAL_GUIDE.md` From 9e9a58c57f114ce4e9841ea37a4d80ad30f5ff3a Mon Sep 17 00:00:00 2001 From: Celestial Date: Sat, 14 Feb 2026 18:09:28 +0100 Subject: [PATCH 4/4] fix: exclude test files in architecture-check fallback --- scripts/check_architecture.sh | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/check_architecture.sh b/scripts/check_architecture.sh index 2c83f54..d31b258 100755 --- a/scripts/check_architecture.sh +++ b/scripts/check_architecture.sh @@ -48,6 +48,24 @@ count_matching_files() { echo "$count" } +find_non_test_layer_matches() { + local layer_dir="$1" + local regex="$2" + local file_path + local match_tmp + + match_tmp="$(mktemp)" + while IFS= read -r file_path; do + [[ "$file_path" == "${layer_dir}/"* ]] || continue + grep -n -E -- "$regex" "$file_path" >> "$match_tmp" || true + done < <(list_non_test_rust_files) + + if [[ -s "$match_tmp" ]]; then + cat "$match_tmp" + fi + rm -f "$match_tmp" +} + check_forbidden_crates_in_layer() { local layer_dir="$1" shift @@ -65,7 +83,7 @@ check_forbidden_crates_in_layer() { if [[ "$HAS_RG" -eq 1 ]]; then matches="$(rg -n "${NON_TEST_GLOBS[@]}" "$regex" "$layer_dir" || true)" else - matches="$(grep -R -n -E --include='*.rs' "$regex" "$layer_dir" || true)" + matches="$(find_non_test_layer_matches "$layer_dir" "$regex")" fi if [[ -n "$matches" ]]; then echo "error: forbidden '${crate_name}' usage detected in ${layer_dir}" @@ -91,7 +109,7 @@ check_forbidden_pattern_in_layer() { if [[ "$HAS_RG" -eq 1 ]]; then matches="$(rg -n "${NON_TEST_GLOBS[@]}" "$regex" "$layer_dir" || true)" else - matches="$(grep -R -n -E --include='*.rs' "$regex" "$layer_dir" || true)" + matches="$(find_non_test_layer_matches "$layer_dir" "$regex")" fi if [[ -n "$matches" ]]; then echo "error: forbidden ${description} detected in ${layer_dir}"