Core

Runners

Four engines, one interface: every runner produces an mpsc::Receiver<LogLine> that streams output live and closes when the run ends. Pick one via ExecutionPlan.

Native step engine (step::runner)

Pure Rust, no external binary. run_steps(steps, config, tx) runs the step list under one of three load profiles, resolved from the config by step::schedule::Schedule:

  • Fixedvus + duration: config.vus tokio tasks each loop over the step list until the duration expires.
  • Ramping VUsstages: (k6-style): a supervisor task recomputes the target VU count every ~100ms by linear interpolation between stage targets (the first stage ramps from 0, each next one from the previous target). Scaling up spawns fresh VU tasks; scaling down flags the newest VUs, which finish their in-flight step and exit at the next step boundary (graceful). The run length is the sum of the stage durations.
  • Arrival-ratearrival: (open model): a dispatcher computes iteration start instants by inverting the piecewise-linear rate integral and hands permits to a worker pool that starts at pre_allocated_vus (default 1) and grows lazily to max_vus. Unlike the closed VU-loop models, new iterations start on schedule even when the system under test slows down — a permit nobody can serve (pool saturated at max_vus) is dropped, counted in the dropped_iterations summary metric, and logged at most once per 5s. The counter is emitted even at 0, so std/thresholds@v1 gates like dropped_iterations: ["count==0"] resolve on clean runs instead of erroring on an unknown metric.

For staged/arrival runs the summary's vus line reports the observed concurrency — vus....................: <last> min=<min> max=<max> — and the periodic [stats] line gains a trailing vus=N with the live count. Fixed runs keep the historical formats unchanged.

  • Per-VU Context — step outputs and ${{ }} interpolation are isolated between VUs, persistent across iterations of the same VU

Exit marker on agent streams

When a run executes through the machine agent (perfscaled), every engine's log stream ends with exactly one system line after all engine output:

[sys] __perfscale_exit__=<code|signal|unknown>
  • code — the engine's exit code (0 = success; the native engine reports 1 for a violated severity: fail thresholds gate or an invalid load profile)
  • signal — the engine process was killed by a signal (e.g. OOM killer)
  • unknown — the exit code could not be determined

Consumers should treat the marker as the authoritative run result: stderr noise (for example k6 console.log) does not mean failure when the exit code is 0. Consumers older than agent 0.3.6 see no marker and fall back to heuristics; older consumers reading a new agent's stream simply print the line as a regular system message.

  • HTTP timings from std/http@v1 feed the shared metrics; other actions (WebSocket, gRPC, TCP/UDP) contribute counters and latency histograms (e.g. ws_msg_rtt, grpc_req_duration) through the same collector
  • Ends with the k6-compatible summary block + Done — Xs wall clock
  • vus: 0 is clamped to 1; duration strings parse via parse_duration_secs ("90", "1m30s", "1h" — minimum 1s). Stage durations are validated strictly: unparseable or zero lengths fail the run (and perfscale lint) with a clear error, as do stages combined with arrival, and arrival without max_vus >= 1

k6 (runner::k6)

Wraps an existing k6 install:

  1. the script is written to $TMPDIR/perfscale-<uuid>.js
  2. k6 run --no-color <script> is spawned with piped stdio
  3. stdout/stderr stream as LogLines; the temp file is removed on exit

Two modes:

FunctionReturnsUse
run_streaming(script)Receiver<LogLine>live output
run_oneshot(script)RunResult { exit_code, success, stdout, stderr, script }collect-then-inspect

Load configuration (VUs, stages, thresholds) belongs in the script's own options block — perfscale does not inject k6 flags.

Missing binary → k6 not found in PATH — install from https://k6.io/....

locust (runner::locust)

Wraps an existing locust install in headless mode:

locust -f <script> --headless -u <users> -r <spawn_rate> -t <duration> --csv <tmp-prefix> [--host <host>]

LocustOpts { users, spawn_rate, duration, host } maps from a generic RunConfig via LocustOpts::from_run_config (vus → users and spawn_rate).

While running, locust's own stdout/stderr stream through. After exit, the runner parses the Aggregated row of <prefix>_stats.csv and emits the same summary block the other engines produce:

http_req_duration......: avg=42.50ms p(50)=40ms p(90)=60ms p(95)=68ms p(99)=85ms min=10ms max=120ms
http_req_failed........: 2.00%
http_reqs..............: 100 10.50/s

Temp CSV files (_stats, _stats_history, _failures, _exceptions) are cleaned up afterwards. A missing/short CSV yields a system line (failed to read locust stats: ...) rather than an error — the process output has already been streamed.

Missing binary → locust not found in PATH — install with pip install locust.

JMeter (runner::jmeter)

Wraps an existing jmeter install in non-GUI mode:

jmeter -n -t <plan.jmx>

The plan owns the whole load shape (thread groups, timers, throughput) — perfscale passes no -J properties and no perfscale config applies. While running, jmeter's own stdout/stderr stream through. After exit, the final console line (summary = N in HH:MM:SS = R/s Avg: .. Min: .. Max: .. Err: .. (E%)) is translated into the k6-compatible summary block:

http_req_duration......: avg=4.00ms min=1.00ms max=42.00ms
http_req_failed........: 0.25%
http_reqs..............: 1200 80.0/s

What JMeter does not get: the console summary carries no percentiles, so the translated duration line has avg/min/max only; there are no perfscale std/thresholds@v1 gates over jmeter runs (exit code is the CI gate — jmeter exits non-zero on plan/startup errors); and .jtl result files are not parsed (throughput parsing of .jtl is future work).

Missing binary → jmeter not found in PATH — install from https://jmeter.apache.org/download_jmeter.cgi (requires a JRE).

Choosing an engine

nativek6locustJMeter
Install needednonek6 binarypython + locustJRE + jmeter
Scenario languageYAML stepsJavaScriptPython.jmx test plan (XML)
Scripting powerlow (4 actions)highhighhigh
Load modelfixed VUs, ramping stages:, or arrival-ratestages/thresholds/scenariosusers/spawn-ratethread groups/timers (in the plan)
Best forsmoke tests, CI gates, simple API flowscomplex k6 suites you already havepython-centric teamsexisting JMeter suites