Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,29 @@ root.
Three files split cleanly along "engine vs. two interchangeable front-ends":

- **[engine.go](engine.go)** — `Engine`, the display-agnostic load-test runner. Owns launching
processes at `Config.Rate` up to `Config.MaxParallel`, honoring `MaxCount`/`TestDuration`,
processes at `Config.Rate` up to `Config.MaxParallel`, honouring `MaxCount`/`TestDuration`,
and tracking results (counts, running-process set, duration history, a bounded activity
feed). Exposes its state via `Snapshot()` (cheap, capped-sample percentiles — safe to poll
every UI tick) and `FinalSnapshot()` (full-history percentiles, call once after `Run()`
returns). Lifecycle is a one-way `Stage` progression (`Running → Stopping → Killing →
Finished`) driven by `StopLaunching()`/`KillRunning()` (first/second Ctrl-C) and observable
via the `Stopping()`/`Finished()` channels. `OutputMode` (`Discard`/`Passthrough`/`Capture`)
is decided by the caller, not the engine — see `outputMode()` in [main.go](main.go).
- **[plain.go](plain.go)** — non-interactive driver: prints a config header, overwrites a
status line on stderr once a second, streams "system" log lines (process errors) as they
occur, and prints `FormatSummary()` on exit. Used whenever stdout/stderr isn't a real
terminal, or `--no-tui` is passed.
- **[plain.go](plain.go)** — non-interactive driver: prints a config header, a status line on a
timer (each update on its own line, suppressed when unchanged except for a periodic
heartbeat), streams "system" log lines (process errors) as they occur, and prints a summary
on exit. Used whenever stdout/stderr isn't a real terminal, or `--non-interactive` is passed.
Rendering is behind the `plainReporter` interface (`plain_reporters.go`): `textReporter` is the
behaviour above; `jsonReporter` (selected via `-o json`) instead emits `status`/`log`/`summary`
events as JSON Lines on stdout and drops the header/notices, which have no place in that
schema.
- **[tui.go](tui.go)** — interactive driver: a bubbletea `Model` with five panels (Status,
Config, Latency, Running, Log) plus a recent-Activity sidebar, driven by `Engine.Snapshot()`
on a tick and by `Engine.LogLines()` for the log panel. Panel sizing is recalculated from
terminal dimensions in `recalcSizes()`; layout math is the trickiest part of this file if
something looks off after a resize.
- **[main.go](main.go)** — CLI flag definitions (`urfave/cli/v3`) and the interactive/plain
dispatch: `isInteractiveTerminal()` checks stdout *and* stderr are TTYs, `--no-tui` forces
dispatch: `isInteractiveTerminal()` checks stdout *and* stderr are TTYs, `--non-interactive` forces
plain mode even in a terminal. `resolveLogDir()` turns `--log-dir`/`LOADER_LOG_DIR` (or the
`.loader` default) into a fresh timestamped subfolder per run (UTC, colon-free so it's valid
on filesystems like NTFS — see `logDirTimeFormat`); `--no-log` leaves
Expand All @@ -63,7 +67,7 @@ Three files split cleanly along "engine vs. two interchangeable front-ends":

Both drivers talk to `Engine` through the same public surface (`Run`, `Snapshot`,
`FinalSnapshot`, `LogLines`, `StopLaunching`, `KillRunning`, `Stopping`, `Finished`) — there is
no driver-specific state inside `Engine`. When changing engine behavior, check that both
no driver-specific state inside `Engine`. When changing engine behaviour, check that both
`plain.go` and `tui.go` still make sense against the new semantics.

Key invariants worth knowing before touching `engine.go`:
Expand Down
53 changes: 48 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ loader [options] COMMAND [ARGS...]
| `--max-parallel` | `-p` | `20` | Maximum number of simultaneous processes |
| `--max-count` | `-n` | `0` | Total processes to launch before stopping (0 = unlimited) |
| `--duration` | `-d` | `0` | Stop launching after this duration (0 = unlimited) |
| `--verbose` | | off | Show stdout/stderr from each process (non-TUI mode) |
| `--no-tui` | | off | Force plain-text output instead of the fullscreen TUI |
| `--verbose` | | off | Show stdout/stderr from each process (non-interactive mode) |
| `--non-interactive` | | off | Force plain-text output instead of the fullscreen TUI (for CI or agentic use) |
| `--status-interval` | | `5s` | Interval between status lines in non-interactive mode |
| `--output` | `-o` | `plain` | Output format in non-interactive mode: `plain` or `json` (JSON Lines on stdout) |
| `--log-dir` | | `.loader` | Directory to write per-run log files into (gets its own timestamped subfolder); also settable via `LOADER_LOG_DIR` |
| `--no-log` | | off | Disable writing per-run log files |
| `--log-env` | | none | Env var name to record in `environment.log` (can be specified multiple times); also settable via `LOADER_LOG_ENV_VARS` or a config file |
Expand Down Expand Up @@ -130,12 +132,17 @@ Once the run finishes, the dashboard stays open showing the final results — pr

## Output

A live status line is printed to stderr during the run:
A status line is printed to stderr on a timer during the run (one per line, not overwritten in
place — so this mode's output stays readable in CI logs or piped to a file):

```
launched=42 running=8 completed=34 failed=0
[5s] launched=42 running=8 completed=34 failed=0
```

A line is only printed when the counters changed since the last one, except every 6th tick, which
is always printed so a log/agent watching for liveness still sees regular output even once nothing
is changing (e.g. while a handful of stragglers finish).

A summary is printed on exit:

```
Expand All @@ -144,11 +151,47 @@ Launched: 100
Completed: 100
Successes: 98
Failures: 2
Duration:
Duration (OK):
min: 142ms
avg: 187ms
p50: 183ms
p95: 241ms
p99: 267ms
max: 312ms
Duration (FAIL):
min: 95ms
avg: 101ms
p50: 101ms
p95: 108ms
p99: 108ms
max: 108ms
```

### JSON output (`-o json`)

With `-o json`, non-interactive mode emits [JSON Lines](https://jsonlines.org/) on stdout instead
of the text above: one self-contained JSON object per line, each with a `type` field. The config
header and signal-handling notices ("Stopping launch loop...", "Waiting for running
processes...") are text-only concepts with no place in that schema, so they're suppressed
entirely — stdout is a clean stream a script or agent can parse directly.

A `status` line is emitted on the same timer, and with the same change-suppression/heartbeat
rules, as the plain status line:

```json
{"type":"status","elapsed_seconds":5.02,"launched":42,"running":8,"completed":34,"failed":0}
```

A `log` line is emitted for each process error, as it occurs (the same events the plain driver
prints as `[procID] text`):

```json
{"type":"log","elapsed_seconds":4.31,"proc_id":37,"text":"error after 812ms: exit status 1"}
```

A single `summary` line is emitted once at the end. `duration_ok`/`duration_fail` are omitted
when there's no data for that bucket (e.g. no failures):

```json
{"type":"summary","launched":100,"completed":100,"successes":98,"failures":2,"duration_ok":{"count":98,"min_ms":142,"avg_ms":187,"p50_ms":183,"p95_ms":241,"p99_ms":267,"max_ms":312},"duration_fail":{"count":2,"min_ms":95,"avg_ms":101,"p50_ms":101,"p95_ms":108,"p99_ms":108,"max_ms":108}}
```
11 changes: 10 additions & 1 deletion engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ func (w *lineWriter) Flush() {
}

// Engine runs a load test: launching cfg.Args repeatedly in parallel at
// cfg.Rate, up to cfg.MaxParallel concurrent processes, honoring
// cfg.Rate, up to cfg.MaxParallel concurrent processes, honouring
// cfg.MaxCount/cfg.TestDuration, and tracking results. It is display-agnostic
// — plain.go and tui.go both drive it the same way.
type Engine struct {
Expand Down Expand Up @@ -548,6 +548,15 @@ func (e *Engine) emitLog(l LogLine) {
}
}

// Elapsed returns the time since Run started (or zero, before it has),
// without Snapshot's cost of computing percentiles and copying state.
func (e *Engine) Elapsed() time.Duration {
if e.startTime.IsZero() {
return 0
}
return time.Since(e.startTime)
}

// Snapshot returns a race-free, point-in-time view of the engine's state.
// Live percentiles are computed from at most the most recent
// livePercentileSampleCap samples, to keep this cheap to call frequently
Expand Down
28 changes: 24 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func isInteractiveTerminal() bool {
}

func run(ctx context.Context, cmd *cli.Command) error {
interactive := isInteractiveTerminal() && !cmd.Bool("no-tui")
interactive := isInteractiveTerminal() && !cmd.Bool("non-interactive")

cfg, err := buildConfig(cmd, interactive)
if err != nil {
Expand All @@ -145,7 +145,16 @@ func run(ctx context.Context, cmd *cli.Command) error {
if interactive {
return runTUI(cfg)
}
return runPlain(cfg)

format, err := ParseOutputFormat(cmd.String("output"))
if err != nil {
return err
}
statusInterval := cmd.Duration("status-interval")
if statusInterval <= 0 {
return fmt.Errorf("--status-interval must be greater than 0")
}
return runPlain(cfg, statusInterval, format)
}

func main() {
Expand Down Expand Up @@ -184,8 +193,19 @@ func main() {
Usage: "show stdout/stderr from each process",
},
&cli.BoolFlag{
Name: "no-tui",
Usage: "force plain-text output instead of the fullscreen TUI",
Name: "non-interactive",
Usage: "force plain-text output instead of the fullscreen TUI (for CI or agentic use)",
},
&cli.DurationFlag{
Name: "status-interval",
Usage: "interval between status lines in non-interactive mode",
Value: 5 * time.Second,
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: `output format in non-interactive mode: "plain" or "json" (JSON Lines on stdout)`,
Value: string(OutputFormatPlain),
},
&cli.StringFlag{
Name: "log-dir",
Expand Down
Loading
Loading