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:
- Fixed —
vus+duration:config.vustokio tasks each loop over the step list until the duration expires. - Ramping VUs —
stages:(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-rate —
arrival:(open model): a dispatcher computes iteration start instants by inverting the piecewise-linear rate integral and hands permits to a worker pool that starts atpre_allocated_vus(default 1) and grows lazily tomax_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 atmax_vus) is dropped, counted in thedropped_iterationssummary metric, and logged at most once per 5s. The counter is emitted even at 0, sostd/thresholds@v1gates likedropped_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 reports1for a violatedseverity: failthresholds 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@v1feed 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: 0is clamped to 1; duration strings parse viaparse_duration_secs("90","1m30s","1h"— minimum 1s). Stage durations are validated strictly: unparseable or zero lengths fail the run (andperfscale lint) with a clear error, as dostagescombined witharrival, andarrivalwithoutmax_vus >= 1
k6 (runner::k6)
Wraps an existing k6 install:
- the script is written to
$TMPDIR/perfscale-<uuid>.js k6 run --no-color <script>is spawned with piped stdio- stdout/stderr stream as
LogLines; the temp file is removed on exit
Two modes:
| Function | Returns | Use |
|---|---|---|
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
| native | k6 | locust | JMeter | |
|---|---|---|---|---|
| Install needed | none | k6 binary | python + locust | JRE + jmeter |
| Scenario language | YAML steps | JavaScript | Python | .jmx test plan (XML) |
| Scripting power | low (4 actions) | high | high | high |
| Load model | fixed VUs, ramping stages:, or arrival-rate | stages/thresholds/scenarios | users/spawn-rate | thread groups/timers (in the plan) |
| Best for | smoke tests, CI gates, simple API flows | complex k6 suites you already have | python-centric teams | existing JMeter suites |