Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Wind-Tunnel — Performance and Load Testing

Wind-Tunnel is Holochain’s load testing framework. It applies user-defined load to running Holochain conductors and measures system response: latency, throughput, DHT sync lag, resource usage. It is completely separate from Sweettest (integration/correctness) and Playwright (E2E UI).

Repo: https://github.com/holochain/wind-tunnel Version: 0.6.1 Used for: Performance regression CI (every merge to holochain main), soak testing, benchmarking


Testing Layers Compared

LayerToolPurposeOutput
1Sweettest (Rust)Correctness — does the hApp work right?pass/fail
2Playwright (TypeScript)E2E functional — does the UI+zome flow work?pass/fail
3Wind-Tunnel (Rust)Performance — how fast/scalable is this?metrics (latency, throughput)

Rule: Use Wind-Tunnel when you need time-series performance data, not when you need correctness assertions.


Published Crates

CratePurpose
wind_tunnel_runnerCore: ScenarioDefinitionBuilder, run(), AgentContext, RunnerContext, Executor
wind_tunnel_instrumentsMetrics: Reporter, ReportMetric, OperationRecord
wind_tunnel_instruments_deriveProc macro: #[wind_tunnel_instrument]
wind_tunnel_coreCore types: AgentBailError, ShutdownHandle
holochain_wind_tunnel_runnerHolochain bindings: call_zome(), install_app(), HolochainAgentContext
holochain_client_instrumentedAuto-instrumented AdminWebsocket / AppWebsocket

Add to Cargo.toml:

[dev-dependencies]
holochain_wind_tunnel_runner = "0.6"

Core API

ScenarioDefinitionBuilder

#![allow(unused)]
fn main() {
use holochain_wind_tunnel_runner::prelude::*;
use holochain_wind_tunnel_runner::happ_path;

ScenarioDefinitionBuilder::<HolochainRunnerContext, HolochainAgentContext>::new_with_init(
    env!("CARGO_PKG_NAME")
)
    .with_default_duration_s(60)          // seconds to run
    .use_build_info(conductor_build_info) // attach conductor metadata to reports
    .use_agent_setup(fn)                  // called once per agent before loop
    .use_agent_behaviour(fn)              // called repeatedly per agent for entire duration
    .use_agent_teardown(fn)               // called once per agent after duration ends
    .use_named_agent_behaviour("write", fn)       // named role for multi-behavior scenarios
    .use_named_agent_behaviour("read", fn)        // multiple roles assigned via CLI
    .use_setup(fn)                        // global setup (before any agents)
    .use_teardown(fn)                     // global teardown (after all agents)
    .add_capture_env("MY_ENV_VAR")        // include env vars in report metadata
}

Lifecycle Order

Global Setup
  → Agent Setup (each agent, once)
    → Agent Behaviour loop (each agent, repeated until duration/shutdown)
  → Agent Teardown (each agent, once)
Global Teardown

Contexts

#![allow(unused)]
fn main() {
// Per-agent context — available inside every hook
impl AgentContext<HolochainRunnerContext, HolochainAgentContext<SV>> {
    fn agent_index(&self) -> usize;
    fn agent_name(&self) -> &str;
    fn runner_context(&self) -> &RunnerContext;
    fn get(&self) -> &HolochainAgentContext<SV>;        // read agent state
    fn get_mut(&mut self) -> &mut HolochainAgentContext<SV>; // write agent state
}

// Shared runner context
impl RunnerContext {
    fn reporter(&self) -> Arc<Reporter>;       // metrics sink
    fn executor(&self) -> &Executor;           // async runtime
    fn get_connection_string(&self) -> Option<&str>;
    fn force_stop_scenario(&self);
}

// Async code inside sync hooks
ctx.runner_context().executor().execute_in_place(async {
    // Holochain client calls (async) go here
})?;
}

Holochain Conductor Helpers

#![allow(unused)]
fn main() {
// Setup helpers (call in agent_setup)
start_conductor_and_configure_urls(ctx)?;       // start conductor + bind ports
install_app(ctx, happ_path!("my_happ"), &"my_happ".to_string())?;
use_installed_app(ctx, app_id)?;                // connect to already-installed app

// Teardown helpers (call in agent_teardown)
uninstall_app(ctx, None).ok();                  // None = use current app_id

// Peer coordination
try_wait_for_min_agents(ctx, 3, Duration::from_secs(30))?;
try_wait_until_full_arc_peer_discovered(ctx)?;
get_peer_list_randomized(ctx)?;  // -> Vec<AgentPubKey>

// Zome calls
let result: MyType = call_zome(ctx, "zome_name", "fn_name", payload)?;
}

Custom Metrics (ReportMetric)

#![allow(unused)]
fn main() {
use wind_tunnel_runner::prelude::ReportMetric;

let metric = ReportMetric::new("sync_lag")          // auto-prefixed: wt.custom.sync_lag
    .with_tag("agent", agent_pubkey.to_string())
    .with_field("value", lag_seconds);              // f64

ctx.runner_context().reporter().clone().add_custom(metric);
}

Custom Per-Agent State

#![allow(unused)]
fn main() {
#[derive(Debug, Default)]
struct ScenarioValues {
    sent_count: u32,
    seen_hashes: HashSet<ActionHash>,
}

impl UserValuesConstraint for ScenarioValues {}

// Use HolochainAgentContext<ScenarioValues> everywhere
// Access via: ctx.get().scenario_values and ctx.get_mut().scenario_values
}

Scenario Patterns

Pattern 1: Simple Zome Call Benchmark

use holochain_wind_tunnel_runner::prelude::*;
use holochain_wind_tunnel_runner::happ_path;

fn agent_setup(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext>,
) -> HookResult {
    start_conductor_and_configure_urls(ctx)?;
    install_app(ctx, happ_path!("my_happ"), &"my_happ".to_string())?;
    Ok(())
}

fn agent_behaviour(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext>,
) -> HookResult {
    // Runs repeatedly. Zome call latency auto-captured.
    let _: MyReturn = call_zome(ctx, "my_zome", "my_fn", ())?;
    Ok(())
}

fn main() -> WindTunnelResult<()> {
    let builder =
        ScenarioDefinitionBuilder::<HolochainRunnerContext, HolochainAgentContext>::new_with_init(
            env!("CARGO_PKG_NAME"),
        )
        .with_default_duration_s(60)
        .use_build_info(conductor_build_info)
        .use_agent_setup(agent_setup)
        .use_agent_behaviour(agent_behaviour)
        .use_agent_teardown(|ctx| { uninstall_app(ctx, None).ok(); Ok(()) });

    run(builder)?;
    Ok(())
}

Pattern 2: Write/Read CRUD Performance

#![allow(unused)]
fn main() {
fn agent_behaviour(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext>,
) -> HookResult {
    let action_hash: ActionHash = call_zome(
        ctx, "my_zome", "create_entry", MyEntry { value: "test".to_string() },
    )?;
    let record: Option<Record> = call_zome(
        ctx, "my_zome", "get_entry", action_hash,
    )?;
    assert!(record.is_some(), "Entry must be readable immediately after create");
    Ok(())
}
}

Pattern 3: DHT Sync Lag (Multi-Role)

#[derive(Debug, Default)]
struct ScenarioValues {
    sent_actions: u32,
    seen_actions: HashSet<ActionHash>,
}
impl UserValuesConstraint for ScenarioValues {}

// Writer: creates timestamped entries, records sent_count metric
fn agent_behaviour_write(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext<ScenarioValues>>,
) -> HookResult {
    call_zome(ctx, "timed", "create_timed_entry", Timestamp::now())?;
    ctx.get_mut().scenario_values.sent_actions += 1;
    let metric = ReportMetric::new("sent_count")
        .with_field("value", ctx.get().scenario_values.sent_actions);
    ctx.runner_context().reporter().clone().add_custom(metric);
    Ok(())
}

// Reader: queries locally, computes lag since creation, records sync_lag metric
fn agent_behaviour_record_lag(
    ctx: &mut AgentContext<HolochainRunnerContext, HolochainAgentContext<ScenarioValues>>,
) -> HookResult {
    let found: Vec<(ActionHash, Timestamp)> =
        call_zome(ctx, "timed", "get_timed_entries_local", ())?;
    let reporter = ctx.runner_context().reporter().clone();
    for (hash, created_at) in found {
        if !ctx.get().scenario_values.seen_actions.contains(&hash) {
            let lag_s = (Timestamp::now().as_micros() - created_at.as_micros()) as f64 / 1e6;
            reporter.add_custom(ReportMetric::new("sync_lag").with_field("value", lag_s));
            ctx.get_mut().scenario_values.seen_actions.insert(hash);
        }
    }
    Ok(())
}

fn main() -> WindTunnelResult<()> {
    let builder = ScenarioDefinitionBuilder::<
        HolochainRunnerContext, HolochainAgentContext<ScenarioValues>,
    >::new_with_init(env!("CARGO_PKG_NAME"))
        .with_default_duration_s(60)
        .use_build_info(conductor_build_info)
        .use_agent_setup(agent_setup)
        .use_named_agent_behaviour("write", agent_behaviour_write)
        .use_named_agent_behaviour("record_lag", agent_behaviour_record_lag)
        .use_agent_teardown(|ctx| { uninstall_app(ctx, None).ok(); Ok(()) });
    run(builder)?;
    Ok(())
}

Run with: cargo run -- --behaviour write:2 --behaviour record_lag:2 --duration 120


Running Wind-Tunnel Tests

# Minimal run (in-memory reporter, single agent)
RUST_LOG=info cargo run -p my_scenario -- --duration 60

# Multiple agents, named roles
cargo run -p my_scenario -- --agents 4 --duration 120
cargo run -p my_scenario -- --behaviour write:2 --behaviour read:2 --duration 120

# Against external conductor (pre-running)
cargo run -p my_scenario -- --connection-string ws://localhost:8888 --duration 60

# With InfluxDB file reporter (for analysis)
cargo run -p my_scenario -- --reporter=influx-file --duration 300

# Soak test (no time limit)
cargo run -p my_scenario -- --soak --reporter=influx-file

Environment Variables

VariablePurpose
WT_HOLOCHAIN_PATHPath to custom Holochain binary
HOLOCHAIN_INFLUXIVE_FILEEnable conductor-level metrics to file
WT_METRICS_DIRDirectory for metrics output (set by Nix)
RUST_LOGLog level (e.g. RUST_LOG=info)

Metrics Architecture

Wind-Tunnel collects three simultaneous metric layers, enabling correlation:

LayerSourceWhat
OSTelegraf (systemd)CPU, memory, disk I/O, network, swap
ConductorHolochain influxiveInternal conductor performance
ScenarioReportMetricCustom application metrics

Reporter backends:

FlagUse
--reporter=in-memoryConsole output (default, local dev)
--reporter=influx-fileWrite InfluxDB line protocol for upload
--reporter=noopDisable all metrics

Cargo.toml Metadata for hApp Packaging

[package.metadata.required-dna]
name = "my_zome"
zomes = ["my_zome"]

[package.metadata.required-happ]
name = "my_happ"
dnas = ["my_zome"]

Use happ_path!("my_happ") macro in code to resolve the built hApp path. A shared build.rs (build = "../scenario_build.rs") packages zomes into DNAs/hApps automatically.


Pre-Built Scenarios (Reference)

25 scenarios in the wind-tunnel repo cover common Holochain performance patterns:

ScenarioTests
zome_call_single_valueBaseline zome call latency
write_readCreate + immediate get throughput
dht_sync_lagDHT propagation delay between agents
app_installApp installation latency (minimal vs large)
remote_signalsRemote signal round-trip latency
remote_call_rateRemote zome call throughput
two_party_countersigningFull countersigning session lifecycle
single_write_many_readWrite amplification pattern
validation_receiptsValidation receipt delivery timing
local_signalsLocal signal handling performance
full_arc_create_validated_zero_arc_readMixed-arc topology
zero_arc_create_dataZero-arc creation throughput

Published results: https://holochain.github.io/wind-tunnel/


When to Write a Wind-Tunnel Scenario

Write a Wind-Tunnel scenario (not a Sweettest test) when you need:

  • Continuous latency monitoring — track how long zome calls take under load over time
  • Throughput measurement — ops/sec for entry creation, linking, querying
  • DHT propagation timing — how long until another agent sees your entries
  • Regression detection — catch performance regressions between Holochain versions
  • Soak testing — sustained load over hours to detect memory leaks or degradation
  • Multi-node topology testing — full-arc vs zero-arc behavior at scale

Do NOT use Wind-Tunnel for:

  • Checking correctness (use Sweettest)
  • Testing UI flows (use Playwright)
  • Testing specific HDK behaviors (use Sweettest inline zomes)