Core
Setup and teardown
A native run is more than the VU loop. The config file can bracket the load
with one-time steps: before: prepares the world (fetch a token, start a
local server), after: tears it down — on every exit path.
Run lifecycle
before steps (once, in order, fail-fast)
│
▼
VU loop (steps × iterations until duration expires)
│
▼
after steps (once, best-effort) ← runs on EVERY exit path:
│ normal finish, failed run,
▼ failed before, Ctrl-C/SIGTERM
auto-kill of remaining managed processes
│
▼
summary
before:steps run once, in order, before any VU is spawned. If any of them fails, the run aborts before spawning VUs (Setup failed, aborting run: …) — a broken setup would make every iteration fail identically.after:steps run always: after a normal finish, after a failed run, after a failedbefore:(a setup step may already have started something the teardown exists to clean up), and after Ctrl-C/SIGTERM. Unlikebefore:, a failing teardown step is logged (teardown step '<name>' failed (continuing)) and the remaining steps still run — partial cleanup beats none.- After the teardown, every managed process still alive in the run's
registry is stopped automatically (SIGTERM, escalated to SIGKILL after
a grace period, whole process group). A forgotten
kill_processnever leaks a server. - Besides cleanup,
after:is where run-level SLO gates live: astd/thresholds@v1step sees everything the run collected and gates the exit code on it (see actions.md). - The metric summary prints last.
Data flow: vars.* and config.*
Two config blocks feed the steps:
| Block | Exposed as | Visible to |
|---|---|---|
variables: | ${{ vars.<key> }} | before steps, test steps, after steps |
before: step outputs | ${{ config.<name>.<field> }} | test steps and after steps |
variables:
region: eu-west
before:
- uses: std/http@v1
with:
url: "https://api.example.com/token?region=${{ vars.region }}"
outputs: auth # → ${{ config.auth.status }} etc. later on
- A
beforestep sees${{ vars.* }}and the outputs of earlier setup steps (each under its ownoutputsname). - Test steps see
config.*andvars.*— but never each other's outputs across VUs (per-VU contexts are independent). aftersteps see the sameconfig.*andvars.*as test steps did, so a teardown step can reference exactly what the test referenced.- Interpolation always yields a string: a numeric output like
${{ config.keeper.port }}reaches the action as"8080". Actions that take numbers accept the string form.
What does not work in before:
Live connections (std/ws-connect@v1, std/grpc-connect@v1 and the stream
family) are scoped to the context that created them. The setup context is
gone before the first VU starts, so a connection opened in before: is dead
by the time a test step asks for its id — open connections per VU iteration
instead (they are drained at iteration end).
Processes are the exception: std/child_process@v1 registers its child in a
run-scoped registry, which is exactly why a server started in before:
stays alive and killable for the whole run.
Background processes end to end
The canonical setup/teardown pair: start a local service in before:, hit
it from the test, stop it in after:. Here a "position keeper" mock for a
CFD-trading scenario:
# config.yaml
vus: 20
duration: 5m
allow_process_actions: true # fail-closed gate, default false
before:
- name: position-keeper
uses: std/child_process@v1
with:
command: python3
args: ["-m", "http.server", "8080"]
port: 8080 # echoed to outputs; port: 0 auto-assigns
waitUntil: # block the step until the server is ready
port_open: 8080
timeout: 15s
restart: on-failure # supervisor restarts crashes (max 3, 1s apart)
outputs: keeper # → ${{ config.keeper.port }} etc.
after:
- name: stop-keeper
uses: std/kill_process@v1
with:
name: keeper # registry lookup — always the current pid
signal: TERM
# test.yaml
steps:
- uses: std/http@v1
with:
url: "http://127.0.0.1:${{ config.keeper.port }}/positions"
check:
status: 200
What happens underneath:
- Streaming: the child's stdout/stderr stream into the run log with a
position-keeper:prefix (same shape as the k6 runner) and accumulate in bounded tail buffers (64 KiB per stream by default,buffer_kbto tune). - Supervision: with
restart: on-failure(oralways), a supervisor task watches the child and respawns it afterbackoff_ms(default 1000), up tomax_restarts(default 3) times, logging each restart. - Outputs are a snapshot:
{ pid, ppid, pgid, port, stdout, stderr, restart_count }.ppidis perfscale itself;pgidis the child's process group. After a restart the storedpidis stale — which is whystd/kill_process@v1should address the process byname(the step name or theoutputsname): the registry lookup always resolves the current pid.pid:(raw OS pid) exists as a best-effort escape hatch for processes outside the registry. - Auto-kill: even without the
after:step, the keeper would be stopped when the run ends. The explicitkill_processis about a clean, timely stop — and about being able to assert on it.
See std/child_process@v1 and
std/kill_process@v1 for the full parameter
references, and
examples/with-processes.config.yaml
for a runnable variant.
waitUntil — readiness gates
A child_process step blocks until the process is ready (or the gate times
out). Two forms:
# Object form — all listed matchers must hold:
waitUntil:
stdout_contains: "Serving HTTP" # substring in captured stdout
stderr_contains: "listening" # substring in captured stderr
stdout_matches: "on port \\d+" # regex against captured stdout
stderr_matches: "err\\d+" # regex against captured stderr
port_open: 8080 # TCP connect to 127.0.0.1:<port> works;
# 0 = the step's own `port`
timeout: 15s # duration string, default 30s
on_timeout: fail # fail (default) | continue
# String form — one matcher, defaults for the rest:
waitUntil: 'contains(stdout, "Serving HTTP")'
waitUntil: 'matches(stderr, "err\\d+")'
waitUntil: 'port_open(8080)'
- With
on_timeout: failan unmet gate fails the step (and therefore the run when used inbefore:);continuelogs the miss and proceeds anyway. - A process that exits before becoming ready fails the step immediately with its exit code and an stderr tail — no waiting out the timeout.
Interrupts (SIGINT / SIGTERM)
Two-stage semantics:
- First SIGINT (Ctrl-C) or SIGTERM: the VU loop stops between steps, and
the run proceeds through the normal teardown —
after:steps, process auto-kill, summary. A log line announces it:Interrupt received — stopping load, running teardown (interrupt again to force-quit). - Second signal: immediate
exit(130)— teardown itself may be wedged, and the operator clearly wants out. Anything not yet stopped by then may leak, so prefer waiting out the first stage.
Safety and portability
- Fail-closed gate: both process actions fail with
process actions disabled (allow_process_actions is false)unless the config opts in withallow_process_actions: true— a step list from an untrusted source cannot spawn or signal OS processes. The same pattern asallow_file_actionsfor the file actions. - Process groups (unix): every child leads its own process group
(
pgid == pid), so atree: truekill signals exactly the child's group — it can never hit perfscale or its parent. - Non-unix: there are no POSIX signals or process groups.
std/kill_process@v1bynameterminates the direct child only (treehas no effect);pid:is unsupported. Everything else — capture, restart supervision,waitUntil, auto-kill — works the same.