diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c9762e7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,21 @@
+name: ci
+
+on:
+ push:
+ branches: ['**']
+ pull_request:
+
+jobs:
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ runner: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
+ - run: go test -race ./...
+ - run: go vet ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 21aa86f..4948e6d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,15 +12,46 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
with:
go-version-file: go.mod
- - run: go test ./...
+ - run: go test -race ./...
+ - run: go vet ./...
- release:
+ validate-release:
needs: test
runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { goos: linux, goarch: amd64, cgo: '0', runner: ubuntu-latest }
+ - { goos: linux, goarch: arm64, cgo: '0', runner: ubuntu-latest }
+ - { goos: linux, goarch: arm, goarm: '7', cgo: '0', runner: ubuntu-latest }
+ - { goos: linux, goarch: '386', cgo: '0', runner: ubuntu-latest }
+ - { goos: darwin, goarch: amd64, cgo: '1', runner: macos-26-intel }
+ - { goos: darwin, goarch: arm64, cgo: '1', runner: macos-latest }
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
+ with:
+ go-version-file: go.mod
+ - name: Test native platform
+ if: matrix.goos == 'darwin' || matrix.goarch == 'amd64'
+ run: go test ./...
+ - name: Validate release build
+ env:
+ CGO_ENABLED: ${{ matrix.cgo }}
+ GOOS: ${{ matrix.goos }}
+ GOARCH: ${{ matrix.goarch }}
+ GOARM: ${{ matrix.goarm }}
+ MACOSX_DEPLOYMENT_TARGET: '12.0'
+ run: go build -trimpath -o /tmp/snailrace ./cmd/snailrace
+
+ release:
+ needs: validate-release
+ runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
@@ -32,8 +63,8 @@ jobs:
- { goos: darwin, goarch: amd64, cgo: '1', runner: macos-26-intel, label: darwin-amd64 }
- { goos: darwin, goarch: arm64, cgo: '1', runner: macos-latest, label: darwin-arm64 }
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/checkout@v7
+ - uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Test Darwin
@@ -45,7 +76,7 @@ jobs:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
- MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos_target || '12.0' }}
+ MACOSX_DEPLOYMENT_TARGET: '12.0'
run: |
go build -trimpath \
-ldflags="-s -w -X github.com/shellcell/snailrace/internal/app.Version=${GITHUB_REF_NAME}" \
@@ -72,7 +103,7 @@ jobs:
nfpm package -f /tmp/nfpm.yaml -p "$fmt" -t pkgs
done
- name: Upload to release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
files: |
${{ env.ARCHIVE }}
diff --git a/README.md b/README.md
index 7e20701..a129be3 100644
--- a/README.md
+++ b/README.md
@@ -60,12 +60,14 @@ Compare shell commands:
./snailrace -n 20 -c 'grep needle data.txt' -c 'rg needle data.txt'
```
-Save an HTML report:
+An HTML report is saved to the current directory by default. Choose another directory:
```sh
./snailrace -format html -output ./reports -- ./my-program --flag
```
+Use `-no-save` when only stdout output is wanted.
+
Measure an interactive TUI:
```sh
@@ -85,8 +87,9 @@ Measure an interactive TUI:
| `-interval` | Sampling interval; default 10 ms |
| `-index` | Index dimensions: `time,cpu,ram,disk` |
| `-baseline` | 1-based baseline; `0` selects the winner |
-| `-f`, `-format` | `html`, `svg`, `markdown`, `json`, or `text` |
-| `-o`, `-output` | Report directory |
+| `-f`, `-format` | Saved format; default `html` |
+| `-o`, `-output` | Report directory; default current directory |
+| `-no-save` | Do not save report files |
| `-verbose` | Full statistical tables on stdout |
| `-show-output` | Forward command output to stderr |
| `tui` | Run inside a pseudo-terminal |
@@ -106,6 +109,8 @@ Measure an interactive TUI:
Raw runs and summary statistics are available in JSON. Reports include mean,
sample standard deviation, median, p95, range, and a 95% Student's t interval.
+Every format identifies the dimensions actually included in the balanced index
+and records sampling or metric-availability caveats.
## Method
@@ -113,21 +118,32 @@ sample standard deviation, median, p95, range, and a 95% Student's t interval.
- The default index combines normalized time, CPU, and RAM costs.
- RAM cost combines mean and peak RSS.
- Sampling-limited RAM is excluded from the index.
+- Commands with non-zero measured exits continue running but are excluded from rankings.
+- If none of the selected dimensions is usable, reports mark the balanced ranking unavailable.
- Ctrl+C keeps completed comparison rounds.
- Command output is discarded unless `-show-output` is set.
+- Native executables run through the inspected artifact and are identity/hash-checked after measurement.
+- Scripts preserve their original invocation path so `$0`-relative behavior is unchanged.
+
+Saved reports are staged before publication and receive a numeric suffix rather
+than overwriting an existing report with the same timestamp.
## Platform Notes
-- Linux reads `/proc` and follows each process's children.
+- Linux reads `/proc` and measures every member of the command's process group,
+ including descendants reparented while the benchmark is running.
- macOS uses cgo with `proc_listpgrppids`, `proc_pidinfo`, and
`proc_pid_rusage`.
- macOS physical footprint estimates memory pressure charged to each process.
+- Physical-footprint validity is tracked per run; unavailable samples are not treated as zero.
- Tree RSS can double-count shared pages.
- Sampled peaks can miss activity shorter than the interval.
- macOS does not report file descriptor counts.
## Disk Footprint
+- For a simple external `-c` command, disk footprint describes its leading executable;
+ shell builtins describe the shell itself.
- Disk-backed `@rpath`, `@loader_path`, and `@executable_path` libraries are
resolved recursively.
- macOS dyld shared-cache libraries are listed but excluded from byte totals.
diff --git a/completions/_snailrace b/completions/_snailrace
index fbfb435..a56dd9f 100644
--- a/completions/_snailrace
+++ b/completions/_snailrace
@@ -13,6 +13,7 @@ _snailrace() {
'-baseline[set 1-based baseline]:index:' \
'*'{-f,-format}'[select report format]:format:(html svg markdown json text)' \
'(-o -output)'{-o,-output}'[set report directory]:directory:_directories' \
+ '-no-save[do not save report files]' \
'-verbose[print full statistical tables]' \
'-show-output[forward command output]' \
'(-d -duration)'{-d,-duration}'[set fixed TUI duration]:duration:' \
diff --git a/completions/snailrace.bash b/completions/snailrace.bash
index af2dbe5..f902e05 100644
--- a/completions/snailrace.bash
+++ b/completions/snailrace.bash
@@ -2,7 +2,7 @@ _snailrace() {
local cur prev options command_seen expect_value after_separator word i
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
- options="-c -command -label -prepare -n -runs -warmups -interval -index -baseline -f -format -o -output -verbose -show-output -d -duration -width -height -v -version -h -help"
+ options="-c -command -label -prepare -n -runs -warmups -interval -index -baseline -f -format -o -output -no-save -verbose -show-output -d -duration -width -height -v -version -h -help"
command_seen=0
expect_value=0
diff --git a/completions/snailrace.fish b/completions/snailrace.fish
index 910554d..d44b441 100644
--- a/completions/snailrace.fish
+++ b/completions/snailrace.fish
@@ -8,6 +8,7 @@ complete -c snailrace -l index -r -a 'time cpu ram disk' -d 'Set index dimension
complete -c snailrace -l baseline -r -d 'Set 1-based baseline'
complete -c snailrace -s f -l format -r -a 'html svg markdown json text' -d 'Select report format'
complete -c snailrace -s o -l output -r -a '(__fish_complete_directories)' -d 'Set report directory'
+complete -c snailrace -l no-save -d 'Do not save report files'
complete -c snailrace -l verbose -d 'Print full statistical tables'
complete -c snailrace -l show-output -d 'Forward command output'
complete -c snailrace -s d -l duration -r -d 'Set fixed TUI duration'
diff --git a/docs/snailrace.1 b/docs/snailrace.1
index 7b1a744..a1c6c07 100644
--- a/docs/snailrace.1
+++ b/docs/snailrace.1
@@ -29,6 +29,15 @@ Commands are given either after a
separator or with repeated
.Fl c
flags.
+.Pp
+Commands with non-zero measured exits continue through all configured rounds,
+but are reported as failures and excluded from rankings.
+If none of the selected dimensions is usable, the balanced ranking is marked
+unavailable instead of selecting a winner.
+.Pp
+For simple external shell commands, executable metadata and disk footprint
+describe the leading command. Shell builtins describe the shell itself.
+Scripts retain their original invocation path.
The
.Cm tui
subcommand renders a live terminal view while measuring.
@@ -71,8 +80,14 @@ Saved report format:
.Ql markdown ,
.Ql json ,
.Ql text .
+The default is
+.Ql html .
.It Fl o , Fl output Ar string
-Directory for a saved report.
+Directory for a saved report; defaults to the current directory.
+Reports are staged before publication and receive a numeric suffix instead of
+overwriting an existing report with the same name.
+.It Fl no-save
+Do not save report files; write the text report to standard output only.
.It Fl verbose
Print full statistical tables to stdout.
.It Fl show-output
@@ -92,7 +107,7 @@ TUI rows; default inherits the terminal.
.It Fl v , Fl version
Print version and exit.
.It Fl h , Fl help
-Show help.
+Show help and exit successfully.
.El
.Sh EXAMPLES
Benchmark one command with warmups:
diff --git a/go.mod b/go.mod
index caa53ef..f69df60 100644
--- a/go.mod
+++ b/go.mod
@@ -1,10 +1,12 @@
module github.com/shellcell/snailrace
-go 1.22
+go 1.25.0
+
+toolchain go1.25.12
require (
github.com/creack/pty v1.1.24
- golang.org/x/term v0.21.0
+ golang.org/x/term v0.45.0
)
-require golang.org/x/sys v0.21.0 // indirect
+require golang.org/x/sys v0.47.0
diff --git a/go.sum b/go.sum
index 7fb5fea..59d547f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,6 @@
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
-golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
-golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
-golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
diff --git a/internal/analysis/ranking.go b/internal/analysis/ranking.go
index 1d90b9c..97c2af0 100644
--- a/internal/analysis/ranking.go
+++ b/internal/analysis/ranking.go
@@ -8,18 +8,20 @@ import (
)
type Ranking struct {
- Rows []RankingRow
- RAMAvailable bool
- RAMPresent bool
- BestOverall float64
- FixedTUI bool
- PrimaryRatio bool
- CPURatio bool
- FootprintRatio bool
- IndexPrimary bool
- IndexCPU bool
- IndexRAM bool
- IndexFootprint bool
+ Rows []RankingRow
+ Available bool
+ UnavailableReason string
+ RAMAvailable bool
+ RAMPresent bool
+ BestOverall float64
+ FixedTUI bool
+ PrimaryRatio bool
+ CPURatio bool
+ FootprintRatio bool
+ IndexPrimary bool
+ IndexCPU bool
+ IndexRAM bool
+ IndexFootprint bool
}
type RankingRow struct {
@@ -42,16 +44,22 @@ type RankingRow struct {
func Calculate(config model.Config, benchmarks []model.Benchmark) Ranking {
count := len(benchmarks)
- result := Ranking{Rows: make([]RankingRow, count)}
+ result := Ranking{}
if count == 0 {
+ result.UnavailableReason = "no benchmarks"
return result
}
- result.FixedTUI = config.Mode == "tui" && config.DurationSeconds > 0
- result.RAMAvailable = samplesReliable(config, benchmarks)
+ result.FixedTUI = config.FixedDurationTUI()
+ eligible := make([]bool, count)
+ eligibleCount := 0
primary, cpu := make([]float64, count), make([]float64, count)
meanRAM, peakRAM := make([]float64, count), make([]float64, count)
footprint := make([]float64, count)
for index, benchmark := range benchmarks {
+ eligible[index] = benchmark.EligibleForRanking()
+ if eligible[index] {
+ eligibleCount++
+ }
primary[index] = benchmark.Summary.WallSeconds.Mean
cpu[index] = benchmark.Summary.CPUTotalSeconds.Mean
if result.FixedTUI {
@@ -62,24 +70,36 @@ func Calculate(config model.Config, benchmarks []model.Benchmark) Ranking {
peakRAM[index] = benchmark.Summary.PeakResidentBytes.Mean
footprint[index] = float64(benchmark.Tool.DiskFootprintBytes)
}
- if !hasPositive(meanRAM) || !hasPositive(peakRAM) {
+ result.RAMAvailable = samplesReliable(config, benchmarks, eligible)
+ if !hasPositive(meanRAM, eligible) || !hasPositive(peakRAM, eligible) {
result.RAMAvailable = false
}
- result.RAMPresent = allPositive(meanRAM) && allPositive(peakRAM)
+ result.RAMPresent = allPositive(meanRAM, eligible) && allPositive(peakRAM, eligible)
result.RAMAvailable = result.RAMAvailable && result.RAMPresent
- result.PrimaryRatio = allPositive(primary)
- result.CPURatio = allPositive(cpu)
- result.FootprintRatio = allPositive(footprint)
- primaryScore, cpuScore := normalized(primary), normalized(cpu)
- ramScore := normalizedPair(meanRAM, peakRAM, result.RAMAvailable)
- footprintScore := normalized(footprint)
+ result.PrimaryRatio = allPositive(primary, eligible)
+ result.CPURatio = allPositive(cpu, eligible)
+ result.FootprintRatio = allPositive(footprint, eligible)
+ primaryScore, cpuScore := normalized(primary, eligible), normalized(cpu, eligible)
+ ramScore := normalizedPair(meanRAM, peakRAM, eligible, result.RAMAvailable)
+ footprintScore := normalized(footprint, eligible)
included := indexSet(config.IndexDimensions)
result.IndexPrimary = included["time"] && !result.FixedTUI && result.PrimaryRatio
result.IndexCPU = (included["cpu"] || (result.FixedTUI && included["time"])) &&
result.CPURatio
result.IndexRAM = included["ram"] && result.RAMAvailable
result.IndexFootprint = included["disk"] && result.FootprintRatio
- for index := range result.Rows {
+ result.Available = eligibleCount > 0 && (result.IndexPrimary || result.IndexCPU ||
+ result.IndexRAM || result.IndexFootprint)
+ if eligibleCount == 0 {
+ result.UnavailableReason = "no successful measured runs"
+ } else if !result.Available {
+ result.UnavailableReason = "none of the selected dimensions has usable positive values"
+ }
+ result.Rows = make([]RankingRow, 0, eligibleCount)
+ for index := range benchmarks {
+ if !eligible[index] {
+ continue
+ }
var scores []float64
if result.IndexPrimary {
scores = append(scores, primaryScore[index])
@@ -97,39 +117,45 @@ func Calculate(config model.Config, benchmarks []model.Benchmark) Ranking {
if result.RAMPresent {
ramValue = geometricMean([]float64{meanRAM[index], peakRAM[index]})
}
- result.Rows[index] = RankingRow{
- Benchmark: index, OverallScore: geometricMean(scores),
+ overallScore := 0.0
+ if result.Available {
+ overallScore = geometricMean(scores)
+ }
+ result.Rows = append(result.Rows, RankingRow{
+ Benchmark: index, OverallScore: overallScore,
PrimaryScore: primaryScore[index], CPUScore: cpuScore[index],
RAMScore: ramScore[index], FootprintScore: footprintScore[index],
PrimaryValue: primary[index], CPUValue: cpu[index],
RAMValue: ramValue, FootprintValue: footprint[index],
- }
+ })
}
- applyRanks(result.Rows, primaryScore, cpuScore, ramScore, footprintScore)
- result.BestOverall = math.Inf(1)
- for _, row := range result.Rows {
- result.BestOverall = math.Min(result.BestOverall, row.OverallScore)
+ applyRanks(
+ result.Rows, eligible, result.Available,
+ primaryScore, cpuScore, ramScore, footprintScore,
+ )
+ if result.Available {
+ result.BestOverall = math.Inf(1)
+ for _, row := range result.Rows {
+ result.BestOverall = math.Min(result.BestOverall, row.OverallScore)
+ }
}
return result
}
-func AutomaticBaseline(config model.Config, benchmarks []model.Benchmark) int {
- ranking := Calculate(config, benchmarks)
- if len(ranking.Rows) == 0 {
- return 1
- }
- return ranking.Rows[0].Benchmark + 1
-}
+var indexDimensions = [...]string{"time", "cpu", "ram", "disk"}
+var defaultIndexDimensions = [...]string{"time", "cpu", "ram"}
-// IndexDimensions are the cost categories that may compose the balanced index.
-var IndexDimensions = []string{"time", "cpu", "ram", "disk"}
+func IndexDimensions() []string {
+ return append([]string(nil), indexDimensions[:]...)
+}
-// DefaultIndexDimensions is the balanced index used when none is configured.
-var DefaultIndexDimensions = []string{"time", "cpu", "ram"}
+func DefaultIndexDimensions() []string {
+ return append([]string(nil), defaultIndexDimensions[:]...)
+}
// ValidIndexDimension reports whether token names a known index dimension.
func ValidIndexDimension(token string) bool {
- for _, dimension := range IndexDimensions {
+ for _, dimension := range indexDimensions {
if token == dimension {
return true
}
@@ -139,7 +165,7 @@ func ValidIndexDimension(token string) bool {
func indexSet(dimensions []string) map[string]bool {
if len(dimensions) == 0 {
- dimensions = DefaultIndexDimensions
+ dimensions = defaultIndexDimensions[:]
}
set := make(map[string]bool, len(dimensions))
for _, dimension := range dimensions {
@@ -157,19 +183,41 @@ func BalancedIndexes(config model.Config, benchmarks []model.Benchmark) []float6
return indexes
}
-func applyRanks(rows []RankingRow, primary, cpu, ram, footprint []float64) {
- overall := make([]float64, len(rows))
- for index := range rows {
- overall[index] = rows[index].OverallScore
+func applyRanks(
+ rows []RankingRow,
+ eligible []bool,
+ overallAvailable bool,
+ primary, cpu, ram, footprint []float64,
+) {
+ overall := make([]float64, len(eligible))
+ for index := range overall {
+ overall[index] = math.Inf(1)
}
+ for _, row := range rows {
+ overall[row.Benchmark] = row.OverallScore
+ }
+ overallRanks := make([]int, len(eligible))
+ if overallAvailable {
+ overallRanks = ranks(overall, eligible)
+ }
+ primaryRanks := ranks(primary, eligible)
+ cpuRanks := ranks(cpu, eligible)
+ ramRanks := ranks(ram, eligible)
+ footprintRanks := ranks(footprint, eligible)
for index := range rows {
- rows[index].OverallRank = rankOf(overall, index)
- rows[index].PrimaryRank = rankOf(primary, index)
- rows[index].CPURank = rankOf(cpu, index)
- rows[index].RAMRank = rankOf(ram, index)
- rows[index].FootprintRank = rankOf(footprint, index)
+ benchmark := rows[index].Benchmark
+ if overallAvailable {
+ rows[index].OverallRank = overallRanks[benchmark]
+ }
+ rows[index].PrimaryRank = primaryRanks[benchmark]
+ rows[index].CPURank = cpuRanks[benchmark]
+ rows[index].RAMRank = ramRanks[benchmark]
+ rows[index].FootprintRank = footprintRanks[benchmark]
}
sort.SliceStable(rows, func(i, j int) bool {
+ if !overallAvailable {
+ return rows[i].Benchmark < rows[j].Benchmark
+ }
return rows[i].OverallRank < rows[j].OverallRank
})
}
diff --git a/internal/analysis/ranking_math.go b/internal/analysis/ranking_math.go
index c1a7a59..1b32900 100644
--- a/internal/analysis/ranking_math.go
+++ b/internal/analysis/ranking_math.go
@@ -2,14 +2,18 @@ package analysis
import (
"math"
+ "sort"
"github.com/shellcell/snailrace/internal/model"
)
-func normalized(values []float64) []float64 {
+func normalized(values []float64, eligible []bool) []float64 {
minimum := math.Inf(1)
hasZero := false
- for _, value := range values {
+ for index, value := range values {
+ if !eligible[index] {
+ continue
+ }
hasZero = hasZero || value == 0
if value > 0 && value < minimum {
minimum = value
@@ -18,6 +22,8 @@ func normalized(values []float64) []float64 {
result := make([]float64, len(values))
for index, value := range values {
switch {
+ case !eligible[index]:
+ result[index] = math.Inf(1)
case value == 0:
result[index] = 1
case math.IsInf(minimum, 1) || value < 0:
@@ -31,7 +37,7 @@ func normalized(values []float64) []float64 {
return result
}
-func normalizedPair(first, second []float64, available bool) []float64 {
+func normalizedPair(first, second []float64, eligible []bool, available bool) []float64 {
result := make([]float64, len(first))
if !available {
for index := range result {
@@ -39,11 +45,11 @@ func normalizedPair(first, second []float64, available bool) []float64 {
}
return result
}
- one, two := normalized(first), normalized(second)
+ one, two := normalized(first, eligible), normalized(second, eligible)
for index := range result {
result[index] = geometricMean([]float64{one[index], two[index]})
}
- return normalized(result)
+ return normalized(result, eligible)
}
func geometricMean(values []float64) float64 {
@@ -60,49 +66,66 @@ func geometricMean(values []float64) float64 {
return math.Exp(total / float64(count))
}
-func samplesReliable(config model.Config, benchmarks []model.Benchmark) bool {
+func samplesReliable(config model.Config, benchmarks []model.Benchmark, eligible []bool) bool {
minimum := config.IntervalMS / 1000 * 2
if minimum <= 0 {
return true
}
- for _, benchmark := range benchmarks {
- for _, run := range benchmark.Runs {
- if run.WallSeconds < minimum || run.SampleCount < 2 ||
- run.SampleCoverageSeconds < config.IntervalMS/1000 {
- return false
- }
+ for index, benchmark := range benchmarks {
+ if !eligible[index] {
+ continue
+ }
+ if !model.SamplingReliable(benchmark, config.IntervalMS/1000) {
+ return false
}
}
return true
}
-func hasPositive(values []float64) bool {
- for _, value := range values {
- if value > 0 {
+func hasPositive(values []float64, eligible []bool) bool {
+ for index, value := range values {
+ if eligible[index] && value > 0 {
return true
}
}
return false
}
-func allPositive(values []float64) bool {
- if len(values) == 0 {
- return false
- }
- for _, value := range values {
+func allPositive(values []float64, eligible []bool) bool {
+ found := false
+ for index, value := range values {
+ if !eligible[index] {
+ continue
+ }
+ found = true
if value <= 0 || math.IsInf(value, 0) || math.IsNaN(value) {
return false
}
}
- return true
+ return found
}
-func rankOf(values []float64, target int) int {
- rank := 1
+func ranks(values []float64, eligible []bool) []int {
+ type item struct {
+ index int
+ value float64
+ }
+ items := make([]item, 0, len(values))
for index, value := range values {
- if index != target && value < values[target] {
- rank++
+ if eligible[index] && !math.IsInf(value, 0) && !math.IsNaN(value) {
+ items = append(items, item{index: index, value: value})
+ }
+ }
+ sort.SliceStable(items, func(left, right int) bool {
+ return items[left].value < items[right].value
+ })
+ result := make([]int, len(values))
+ rank := 0
+ for position, current := range items {
+ if position == 0 || current.value != items[position-1].value {
+ rank = position + 1
}
+ result[current.index] = rank
}
- return rank
+ return result
}
diff --git a/internal/analysis/ranking_test.go b/internal/analysis/ranking_test.go
index 2af36c8..62c75fd 100644
--- a/internal/analysis/ranking_test.go
+++ b/internal/analysis/ranking_test.go
@@ -1,6 +1,7 @@
package analysis
import (
+ "math"
"testing"
"github.com/shellcell/snailrace/internal/model"
@@ -12,15 +13,62 @@ func TestDefaultIndexExcludesDiskFootprint(t *testing.T) {
indexBenchmark("fast-but-fat", 1, 1, 100, 100, 1_000_000),
}
// Default index (time, cpu, ram) ranks the fast tool first despite its size.
- if got := AutomaticBaseline(model.Config{Mode: "command"}, benchmarks); got != 2 {
- t.Fatalf("default baseline = %d, want fast tool (2)", got)
+ ranking := Calculate(model.Config{Mode: "command"}, benchmarks)
+ if !ranking.Available || ranking.Rows[0].Benchmark+1 != 2 {
+ t.Fatalf("default baseline = %d, want fast tool (2)", ranking.Rows[0].Benchmark+1)
}
// Including disk lets the huge footprint sink the fast tool.
withDisk := model.Config{
Mode: "command", IndexDimensions: []string{"time", "cpu", "ram", "disk"},
}
- if got := AutomaticBaseline(withDisk, benchmarks); got != 1 {
- t.Fatalf("disk baseline = %d, want compact tool (1)", got)
+ ranking = Calculate(withDisk, benchmarks)
+ if !ranking.Available || ranking.Rows[0].Benchmark+1 != 1 {
+ t.Fatalf("disk baseline = %d, want compact tool (1)", ranking.Rows[0].Benchmark+1)
+ }
+}
+
+func TestUnavailableRankingUsesExplicitState(t *testing.T) {
+ benchmarks := []model.Benchmark{indexBenchmark("short", 1, 1, 100, 100, 10)}
+ config := model.Config{
+ Mode: "command", IntervalMS: 10, IndexDimensions: []string{"ram"},
+ }
+ ranking := Calculate(config, benchmarks)
+ if ranking.Available || ranking.UnavailableReason == "" {
+ t.Fatalf("ranking = %+v, want explicit unavailable state", ranking)
+ }
+ if len(ranking.Rows) != 1 || ranking.Rows[0].OverallRank != 0 ||
+ math.IsInf(ranking.Rows[0].OverallScore, 0) || math.IsNaN(ranking.Rows[0].OverallScore) {
+ t.Fatalf("unavailable row contains invalid ranking values: %+v", ranking.Rows)
+ }
+}
+
+func TestFailedBenchmarkIsExcludedFromRanking(t *testing.T) {
+ failed := indexBenchmark("failed", 0.001, 0.001, 100, 100, 10)
+ failed.Runs[0].ExitCode = 7
+ failed.Summary = model.Summarize(failed.Runs)
+ success := indexBenchmark("success", 1, 1, 200, 200, 20)
+ config := model.Config{
+ Mode: "command", IndexDimensions: []string{"time", "cpu"},
+ }
+ ranking := Calculate(config, []model.Benchmark{failed, success})
+ if !ranking.Available || len(ranking.Rows) != 1 || ranking.Rows[0].Benchmark != 1 {
+ t.Fatalf("failed tool was not excluded: %+v", ranking)
+ }
+ if ranking.Rows[0].Benchmark+1 != 2 {
+ t.Fatalf("baseline = %d, want successful tool 2", ranking.Rows[0].Benchmark+1)
+ }
+}
+
+func TestDimensionDefaultsCannotBeMutatedByCallers(t *testing.T) {
+ dimensions := DefaultIndexDimensions()
+ dimensions[0] = "disk"
+ if got := DefaultIndexDimensions()[0]; got != "time" {
+ t.Fatalf("mutated default dimension = %q", got)
+ }
+ all := IndexDimensions()
+ all[0] = "disk"
+ if got := IndexDimensions()[0]; got != "time" {
+ t.Fatalf("mutated known dimension = %q", got)
}
}
diff --git a/internal/app/app.go b/internal/app/app.go
index e4f4d88..1a16b9f 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -3,10 +3,10 @@ package app
import (
"context"
"errors"
+ "flag"
"fmt"
"io"
"os"
- "os/exec"
"os/signal"
"runtime"
"syscall"
@@ -26,6 +26,9 @@ var Version = "dev"
func Run(arguments []string, stdout, stderr io.Writer) error {
options, err := parseOptions(arguments, stderr)
if err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return nil
+ }
return err
}
if options.version {
@@ -85,7 +88,7 @@ func Run(arguments []string, stdout, stderr io.Writer) error {
WarmupOrder: warmupOrder,
}, runner.Options{
ShowOutput: options.showOutput, TUI: options.tui,
- Output: stderr,
+ Output: stderr, Input: os.Stdin, TerminalOut: os.Stdout,
Duration: options.duration, Width: width, Height: height,
Interactive: interactiveTUI,
FollowResize: followResize,
@@ -100,10 +103,29 @@ func Run(arguments []string, stdout, stderr io.Writer) error {
if err != nil && !interrupted {
return err
}
+ rankingNote := ""
+ if config.Baseline == 0 {
+ ranking := analysis.Calculate(config, benchmarks)
+ if ranking.Available && len(ranking.Rows) > 0 {
+ config.Baseline = ranking.Rows[0].Benchmark + 1
+ } else {
+ config.Baseline = firstEligibleBenchmark(benchmarks) + 1
+ config.BaselineAutomatic = false
+ rankingNote = fmt.Sprintf(
+ "Balanced ranking unavailable: %s. %s is used only as the comparison baseline.",
+ ranking.UnavailableReason,
+ benchmarks[config.Baseline-1].Tool.Name,
+ )
+ }
+ }
notes := platformNotes(
options.tui, options.duration, len(options.specs) > 1,
config.BaselineAutomatic, config.OrderSeed, config.OutputMode,
)
+ if rankingNote != "" {
+ fmt.Fprintln(stderr, rankingNote)
+ notes = append([]string{rankingNote}, notes...)
+ }
if interrupted {
completed := 0
if len(benchmarks) > 0 {
@@ -125,21 +147,31 @@ func Run(arguments []string, stdout, stderr io.Writer) error {
fmt.Fprintln(stderr, hint)
notes = append(notes, hint)
}
- if config.Baseline == 0 {
- config.Baseline = analysis.AutomaticBaseline(config, benchmarks)
- }
_, host.MemoryAfterBytes = platform.Memory()
result := model.Report{
MeasuredAt: measuredAt, Config: config, Host: host,
Benchmarks: benchmarks, Verbose: options.verbose,
Notes: notes,
}
+ outputDirectory := options.output
+ if options.noSave {
+ outputDirectory = ""
+ }
if err := writeResult(
- stdout, stderr, options.output, options.formats, result,
+ stdout, stderr, outputDirectory, options.formats, result,
); err != nil {
return err
}
- return checkExitCodes(benchmarks)
+ return nil
+}
+
+func firstEligibleBenchmark(benchmarks []model.Benchmark) int {
+ for index, benchmark := range benchmarks {
+ if benchmark.EligibleForRanking() {
+ return index
+ }
+ }
+ return 0
}
func oneBasedOrder(order [][]int) [][]int {
@@ -170,9 +202,7 @@ func prepare(ctx context.Context, command string, output io.Writer) error {
if command == "" {
return nil
}
- cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
- cmd.Stdout, cmd.Stderr = output, output
- if err := cmd.Run(); err != nil {
+ if err := runner.RunPreparation(ctx, command, output); err != nil {
return fmt.Errorf("prepare command: %w", err)
}
return nil
@@ -184,27 +214,15 @@ func writeResult(
formats []string,
result model.Report,
) error {
- if err := report.Write(stdout, "text", result); err != nil {
- return err
- }
- if directory == "" {
- return nil
- }
- return saveReportFormats(stderr, directory, formats, result)
-}
-
-func checkExitCodes(benchmarks []model.Benchmark) error {
- for _, benchmark := range benchmarks {
- for _, run := range benchmark.Runs {
- if run.ExitCode != 0 && run.StopReason != "duration" {
- return fmt.Errorf(
- "%q exited unsuccessfully in one or more runs",
- benchmark.Tool.Name,
- )
- }
- }
+ renderer := report.NewRenderer(result)
+ var saveErr error
+ if directory != "" {
+ saveErr = saveReportFormats(
+ stderr, directory, formats, renderer,
+ )
}
- return nil
+ textErr := renderer.Write(stdout, "text")
+ return errors.Join(saveErr, textErr)
}
func modeName(tui bool) string {
diff --git a/internal/app/filename.go b/internal/app/filename.go
index c6b3b0e..f5c93a3 100644
--- a/internal/app/filename.go
+++ b/internal/app/filename.go
@@ -4,39 +4,45 @@ import (
"fmt"
"path/filepath"
"strings"
+ "time"
"unicode"
"github.com/shellcell/snailrace/internal/model"
+ "github.com/shellcell/snailrace/internal/report"
)
-func reportPath(directory, format string, report model.Report) string {
+func reportPathWithStem(directory, format, stem string) string {
extension := strings.ToLower(format)
- switch extension {
- case "text":
- extension = "txt"
- case "markdown":
- extension = "md"
+ if info, ok := report.LookupFormat(format); ok {
+ extension = info.Extension
}
- return filepath.Join(directory, reportStem(report)+"."+extension)
+ return filepath.Join(directory, stem+"."+extension)
}
func reportStem(report model.Report) string {
- tool := reportLabel(report.Benchmarks)
- timestamp := report.MeasuredAt.Format("20060102-150405")
+ names := make([]string, len(report.Benchmarks))
+ for index, benchmark := range report.Benchmarks {
+ names[index] = benchmark.Tool.Name
+ }
+ return reportStemFor(report.MeasuredAt, names)
+}
+
+func reportStemFor(measuredAt time.Time, toolNames []string) string {
+ tool := reportLabelNames(toolNames)
+ timestamp := measuredAt.Format("20060102-150405")
return fmt.Sprintf("snail-%s-%s", tool, timestamp)
}
-func reportLabel(benchmarks []model.Benchmark) string {
- if len(benchmarks) == 0 {
+func reportLabelNames(names []string) string {
+ if len(names) == 0 {
return "unknown"
}
- if len(benchmarks) == 1 {
- return slug(benchmarks[0].Tool.Name)
+ if len(names) == 1 {
+ return slug(names[0])
}
- label := slug(benchmarks[0].Tool.Name) + "-vs-" +
- slug(benchmarks[1].Tool.Name)
- if len(benchmarks) > 2 {
- label += fmt.Sprintf("-and-%d", len(benchmarks)-2)
+ label := slug(names[0]) + "-vs-" + slug(names[1])
+ if len(names) > 2 {
+ label += fmt.Sprintf("-and-%d", len(names)-2)
}
return label
}
diff --git a/internal/app/filename_test.go b/internal/app/filename_test.go
index 6c636fb..490c7b4 100644
--- a/internal/app/filename_test.go
+++ b/internal/app/filename_test.go
@@ -15,7 +15,7 @@ func TestReportPathContainsToolAndMeasurementTime(t *testing.T) {
Tool: model.ToolInfo{Name: "My Tool!"},
}},
}
- got := reportPath("reports", "markdown", report)
+ got := reportPathWithStem("reports", "markdown", reportStem(report))
want := filepath.Join("reports", "snail-my-tool-20260711-140509.md")
if got != want {
t.Fatalf("path = %q, want %q", got, want)
@@ -31,7 +31,7 @@ func TestComparisonReportPathNamesMeasuredTools(t *testing.T) {
{Tool: model.ToolInfo{Name: "awk"}},
},
}
- got := filepath.Base(reportPath(".", "json", report))
+ got := filepath.Base(reportPathWithStem(".", "json", reportStem(report)))
want := "snail-grep-vs-ripgrep-and-1-20260711-140509.json"
if got != want {
t.Fatalf("name = %q, want %q", got, want)
diff --git a/internal/app/formats.go b/internal/app/formats.go
index 050df14..1fe4b1b 100644
--- a/internal/app/formats.go
+++ b/internal/app/formats.go
@@ -1,6 +1,10 @@
package app
-import "strings"
+import (
+ "strings"
+
+ "github.com/shellcell/snailrace/internal/report"
+)
type formatValues struct {
values []string
@@ -23,6 +27,9 @@ func (formats *formatValues) Set(value string) error {
for _, item := range strings.Split(value, ",") {
item = strings.ToLower(strings.TrimSpace(item))
if item != "" {
+ if info, ok := report.LookupFormat(item); ok {
+ item = string(info.Name)
+ }
formats.values = append(formats.values, item)
}
}
diff --git a/internal/app/formats_test.go b/internal/app/formats_test.go
index 9d334c1..f2dcd89 100644
--- a/internal/app/formats_test.go
+++ b/internal/app/formats_test.go
@@ -30,4 +30,19 @@ func TestDefaultFormatIsHTML(t *testing.T) {
if !reflect.DeepEqual(options.formats, []string{"html"}) {
t.Fatalf("formats = %#v, want html", options.formats)
}
+ if options.output != "." {
+ t.Fatalf("output = %q, want current directory", options.output)
+ }
+}
+
+func TestEmptyOutputRequiresNoSave(t *testing.T) {
+ if _, err := parseOptions([]string{"-o", "", "--", "true"}, io.Discard); err == nil {
+ t.Fatal("empty output should require -no-save")
+ }
+ options, err := parseOptions(
+ []string{"-no-save", "-o", "", "--", "true"}, io.Discard,
+ )
+ if err != nil || !options.noSave {
+ t.Fatalf("no-save options = %+v, error = %v", options, err)
+ }
}
diff --git a/internal/app/index.go b/internal/app/index.go
index e59b874..35dd732 100644
--- a/internal/app/index.go
+++ b/internal/app/index.go
@@ -13,7 +13,7 @@ type indexValues struct {
}
func newIndexValues() indexValues {
- return indexValues{values: append([]string(nil), analysis.DefaultIndexDimensions...)}
+ return indexValues{values: analysis.DefaultIndexDimensions()}
}
func (index *indexValues) String() string {
@@ -33,7 +33,7 @@ func (index *indexValues) Set(value string) error {
if !analysis.ValidIndexDimension(item) {
return fmt.Errorf(
"unknown index dimension %q; choose from %s",
- item, strings.Join(analysis.IndexDimensions, ", "),
+ item, strings.Join(analysis.IndexDimensions(), ", "),
)
}
if !contains(index.values, item) {
diff --git a/internal/app/legend.go b/internal/app/legend.go
index e0e0f9f..8b65fa6 100644
--- a/internal/app/legend.go
+++ b/internal/app/legend.go
@@ -10,6 +10,7 @@ import (
"golang.org/x/term"
"github.com/shellcell/snailrace/internal/runner"
+ "github.com/shellcell/snailrace/internal/style"
)
// printCommandLegend lists each tool's label and full command before measurement
@@ -45,7 +46,7 @@ func printCommandLegend(writer io.Writer, specs []runner.Spec) {
func specCommand(spec runner.Spec) string {
if spec.Shell != "" {
- return spec.Shell
+ return style.CommandText([]string{spec.Shell}, true)
}
- return strings.Join(spec.Args, " ")
+ return style.CommandText(spec.Args, false)
}
diff --git a/internal/app/options.go b/internal/app/options.go
index a640097..7e8937f 100644
--- a/internal/app/options.go
+++ b/internal/app/options.go
@@ -31,6 +31,7 @@ type options struct {
index []string
verbose bool
showOutput bool
+ noSave bool
version bool
tui bool
width, height uint16
@@ -80,8 +81,9 @@ func parseOptions(arguments []string, stderr io.Writer) (options, error) {
"saved format; repeat or comma-separate: html, svg, markdown, json, text",
)
flags.Var(&formats, "f", "saved format (shorthand)")
- flags.StringVar(&result.output, "output", "", "directory for a saved report")
- flags.StringVar(&result.output, "o", "", "directory for a saved report (shorthand)")
+ flags.StringVar(&result.output, "output", ".", "report directory; default current directory")
+ flags.StringVar(&result.output, "o", ".", "report directory (shorthand)")
+ flags.BoolVar(&result.noSave, "no-save", false, "do not save report files")
flags.BoolVar(&result.verbose, "verbose", false, "print full statistical tables to stdout")
flags.BoolVar(&result.showOutput, "show-output", false, "show command output")
// TUI.
@@ -150,7 +152,8 @@ func printUsage(stderr io.Writer) {
}},
{"Output", []string{
"-f, -format list saved format: html, svg, markdown, json, text",
- "-o, -output string directory for a saved report",
+ "-o, -output string report directory; default current directory",
+ "-no-save do not save report files",
"-verbose print full statistical tables to stdout",
"-show-output forward command output to stderr",
}},
diff --git a/internal/app/options_test.go b/internal/app/options_test.go
index 252e076..8d4a159 100644
--- a/internal/app/options_test.go
+++ b/internal/app/options_test.go
@@ -2,6 +2,7 @@ package app
import (
"io"
+ "strings"
"testing"
)
@@ -21,6 +22,16 @@ func TestParseDirectCommand(t *testing.T) {
}
}
+func TestHelpReturnsSuccess(t *testing.T) {
+ var output strings.Builder
+ if err := Run([]string{"-h"}, io.Discard, &output); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(output.String(), "Usage: snailrace") {
+ t.Fatal("help output is missing usage")
+ }
+}
+
func TestRejectsLabelCountMismatch(t *testing.T) {
_, err := parseOptions(
[]string{"-label", "x", "-c", "true", "-c", "false"},
@@ -31,6 +42,25 @@ func TestRejectsLabelCountMismatch(t *testing.T) {
}
}
+func TestRejectsEmptyAndDuplicateLabels(t *testing.T) {
+ for _, arguments := range [][]string{
+ {"-label", " ", "--", "true"},
+ {"-label", "same", "-label", "same", "-c", "true", "-c", "false"},
+ } {
+ if _, err := parseOptions(arguments, io.Discard); err == nil {
+ t.Fatalf("arguments %q should reject ambiguous labels", arguments)
+ }
+ }
+}
+
+func TestRejectsEmptyShellCommand(t *testing.T) {
+ for _, command := range []string{"", " \t\n"} {
+ if _, err := parseOptions([]string{"-c", command}, io.Discard); err == nil {
+ t.Fatalf("command %q should be rejected", command)
+ }
+ }
+}
+
func TestSingleLabelNamesPositionalCommand(t *testing.T) {
options, err := parseOptions(
[]string{"-label", "build", "--", "sleep", "0.1"},
diff --git a/internal/app/output_bundle.go b/internal/app/output_bundle.go
index f97f570..c7858b1 100644
--- a/internal/app/output_bundle.go
+++ b/internal/app/output_bundle.go
@@ -1,12 +1,14 @@
package app
import (
+ "errors"
"fmt"
"io"
"os"
"path/filepath"
+ "strings"
+ "time"
- "github.com/shellcell/snailrace/internal/model"
"github.com/shellcell/snailrace/internal/report"
)
@@ -14,56 +16,162 @@ func saveReportFormats(
stderr io.Writer,
directory string,
formats []string,
- result model.Report,
+ renderer *report.Renderer,
) error {
if err := os.MkdirAll(directory, 0o755); err != nil {
return err
}
formats = uniqueFormats(formats)
- needsCharts := containsFormat(formats, "svg") ||
- containsFormat(formats, "markdown") || containsFormat(formats, "md")
+ needsCharts := false
+ includeCommandCharts := false
+ for _, format := range formats {
+ info, _ := report.LookupFormat(format)
+ needsCharts = needsCharts || info.NeedsCharts
+ includeCommandCharts = includeCommandCharts || info.Name == report.FormatSVG
+ }
+ metadata := renderer.Metadata()
+ stem, release, err := reserveReportStem(
+ directory, reportStemFor(metadata.MeasuredAt, metadata.ToolNames),
+ )
+ if err != nil {
+ return err
+ }
+ defer release()
+ staging, err := os.MkdirTemp(directory, "."+stem+".tmp-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(staging)
+
var charts []report.ChartArtifact
- bundleDirectory := filepath.Join(directory, reportStem(result))
+ stagedBundle := filepath.Join(staging, stem)
+ finalBundle := filepath.Join(directory, stem)
if needsCharts {
- chartDirectory := filepath.Join(bundleDirectory, "charts")
- var err error
- charts, err = report.WriteChartFiles(
- chartDirectory, result, containsFormat(formats, "svg"),
+ chartDirectory := filepath.Join(stagedBundle, "charts")
+ charts, err = renderer.WriteChartFiles(
+ chartDirectory, includeCommandCharts,
)
if err != nil {
return err
}
}
+ type stagedOutput struct{ staged, final string }
+ var outputs []stagedOutput
+ var announcements []string
for _, format := range formats {
switch format {
case "svg":
- if err := announce(stderr, filepath.Join(bundleDirectory, "charts")); err != nil {
- return err
- }
- case "markdown", "md":
- path := filepath.Join(bundleDirectory, "report.md")
- if err := writeMarkdownFile(path, result, charts); err != nil {
- return err
- }
- if err := announce(stderr, path); err != nil {
+ announcements = append(announcements, filepath.Join(finalBundle, "charts"))
+ case "markdown":
+ path := filepath.Join(stagedBundle, "report.md")
+ if err := writeMarkdownFile(path, renderer, charts); err != nil {
return err
}
+ announcements = append(announcements, filepath.Join(finalBundle, "report.md"))
default:
- path := reportPath(directory, format, result)
- if err := writeReportFile(path, format, result); err != nil {
+ stagedPath := reportPathWithStem(staging, format, stem)
+ if err := writeReportFile(stagedPath, format, renderer); err != nil {
return err
}
- if err := announce(stderr, path); err != nil {
- return err
+ finalPath := reportPathWithStem(directory, format, stem)
+ outputs = append(outputs, stagedOutput{staged: stagedPath, final: finalPath})
+ announcements = append(announcements, finalPath)
+ }
+ }
+ if needsCharts {
+ outputs = append(outputs, stagedOutput{staged: stagedBundle, final: finalBundle})
+ }
+ var published []string
+ for _, output := range outputs {
+ if err := renameNoReplace(output.staged, output.final); err != nil {
+ for _, path := range published {
+ os.RemoveAll(path)
}
+ return err
+ }
+ published = append(published, output.final)
+ }
+ for _, path := range announcements {
+ if err := announce(stderr, path); err != nil {
+ return err
}
}
return nil
}
+const maxReportNameAttempts = 10000
+
+func reserveReportStem(
+ directory string,
+ base string,
+) (string, func(), error) {
+ for suffix := 1; suffix <= maxReportNameAttempts; suffix++ {
+ stem := base
+ if suffix > 1 {
+ stem = fmt.Sprintf("%s-%d", base, suffix)
+ }
+ release, reserved, err := tryReserveStem(directory, stem)
+ if err != nil {
+ return "", nil, err
+ }
+ if reserved {
+ return stem, release, nil
+ }
+ }
+ return "", nil, fmt.Errorf("no available report name for %q in %s", base, directory)
+}
+
+func tryReserveStem(directory, stem string) (func(), bool, error) {
+ lockPath := filepath.Join(directory, "."+stem+".lock")
+ for attempt := range 2 {
+ lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if errors.Is(err, os.ErrExist) {
+ // Reclaim a lock leaked by a crashed run, then retry this stem once.
+ if attempt == 0 && staleLockFile(lockPath) && os.Remove(lockPath) == nil {
+ continue
+ }
+ return nil, false, nil
+ }
+ if err != nil {
+ return nil, false, err
+ }
+ if err := lock.Close(); err != nil {
+ os.Remove(lockPath)
+ return nil, false, err
+ }
+ exists, err := reportStemExists(directory, stem)
+ if err != nil || exists {
+ os.Remove(lockPath)
+ return nil, false, err
+ }
+ return func() { os.Remove(lockPath) }, true, nil
+ }
+ return nil, false, nil
+}
+
+// Locks are held only while a report is being written, so anything old enough
+// to predate the current run by an hour was leaked by a crashed process.
+func staleLockFile(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && time.Since(info.ModTime()) > time.Hour
+}
+
+func reportStemExists(directory, stem string) (bool, error) {
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ return false, err
+ }
+ for _, entry := range entries {
+ if entry.Name() == stem || strings.HasPrefix(entry.Name(), stem+".") {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
func writeMarkdownFile(
path string,
- result model.Report,
+ renderer *report.Renderer,
charts []report.ChartArtifact,
) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
@@ -73,31 +181,39 @@ func writeMarkdownFile(
if err != nil {
return err
}
- writeErr := report.WriteMarkdownWithCharts(file, result, charts, "charts")
- closeErr := file.Close()
- if writeErr != nil {
- return writeErr
+ writeErr := renderer.WriteMarkdownWithCharts(file, charts, "charts")
+ syncErr := error(nil)
+ if writeErr == nil {
+ syncErr = file.Sync()
}
- return closeErr
+ closeErr := file.Close()
+ return errors.Join(writeErr, syncErr, closeErr)
}
-func writeReportFile(path, format string, result model.Report) error {
+func writeReportFile(path, format string, renderer *report.Renderer) error {
file, err := os.Create(path)
if err != nil {
return err
}
- writeErr := report.Write(file, format, result)
- closeErr := file.Close()
- if writeErr != nil {
- return writeErr
+ writeErr := renderer.Write(file, format)
+ syncErr := error(nil)
+ if writeErr == nil {
+ syncErr = file.Sync()
}
- return closeErr
+ closeErr := file.Close()
+ return errors.Join(writeErr, syncErr, closeErr)
}
func uniqueFormats(formats []string) []string {
seen := make(map[string]bool)
result := make([]string, 0, len(formats))
for _, format := range formats {
+ info, ok := report.LookupFormat(format)
+ if !ok {
+ format = strings.ToLower(strings.TrimSpace(format))
+ } else {
+ format = string(info.Name)
+ }
if !seen[format] {
seen[format] = true
result = append(result, format)
@@ -106,15 +222,6 @@ func uniqueFormats(formats []string) []string {
return result
}
-func containsFormat(formats []string, target string) bool {
- for _, format := range formats {
- if format == target {
- return true
- }
- }
- return false
-}
-
func announce(writer io.Writer, path string) error {
_, err := fmt.Fprintf(writer, "Report saved to %s\n", path)
return err
diff --git a/internal/app/output_test.go b/internal/app/output_test.go
index 0b12ed9..defb697 100644
--- a/internal/app/output_test.go
+++ b/internal/app/output_test.go
@@ -2,11 +2,17 @@ package app
import (
"bytes"
+ "errors"
+ "io"
"os"
+ "path/filepath"
"strings"
+ "sync"
"testing"
+ "time"
"github.com/shellcell/snailrace/internal/model"
+ reportpkg "github.com/shellcell/snailrace/internal/report"
)
func TestSavingAlsoWritesCompleteReportToStdout(t *testing.T) {
@@ -39,6 +45,29 @@ func TestSavingAlsoWritesCompleteReportToStdout(t *testing.T) {
}
}
+func TestStdoutFailureDoesNotPreventSaving(t *testing.T) {
+ directory := t.TempDir()
+ report := model.Report{
+ Config: model.Config{Mode: "command", Baseline: 1},
+ Benchmarks: []model.Benchmark{{Tool: model.ToolInfo{Name: "tool"}}},
+ }
+ if err := writeResult(
+ failingWriter{}, io.Discard, directory, []string{"html"}, report,
+ ); err == nil {
+ t.Fatal("stdout failure should be returned")
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil || len(entries) != 1 {
+ t.Fatalf("saved entries = %v, error = %v", entries, err)
+ }
+}
+
+type failingWriter struct{}
+
+func (failingWriter) Write([]byte) (int, error) {
+ return 0, errors.New("write failed")
+}
+
func TestSavedFormatDoesNotReplaceTextStdout(t *testing.T) {
directory := t.TempDir()
var stdout, stderr bytes.Buffer
@@ -80,7 +109,7 @@ func TestMarkdownAndSVGSavedAsChartBundle(t *testing.T) {
{Tool: model.ToolInfo{Name: "second"}, Runs: runs, Summary: model.Summarize(runs)},
},
}
- if err := saveReportFormats(
+ if err := saveReportFormatsForTest(
&stderr, directory, []string{"markdown", "svg"}, result,
); err != nil {
t.Fatal(err)
@@ -103,3 +132,248 @@ func TestMarkdownAndSVGSavedAsChartBundle(t *testing.T) {
t.Fatalf("chart files = %v, error = %v", charts, err)
}
}
+
+func TestRunIgnoresNonZeroCommandExit(t *testing.T) {
+ t.Chdir(t.TempDir())
+ var stdout, stderr bytes.Buffer
+ if err := Run(
+ []string{"-n", "2", "-warmups", "0", "-c", "exit 7"},
+ &stdout, &stderr,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(stdout.String(), "FAILED") ||
+ !strings.Contains(stdout.String(), "excluded from rankings") {
+ t.Fatal("stdout does not report the failed command")
+ }
+}
+
+func TestRunSavesDefaultHTMLInCurrentDirectory(t *testing.T) {
+ directory := t.TempDir()
+ t.Chdir(directory)
+ var stdout, stderr bytes.Buffer
+ if err := Run(
+ []string{"-n", "1", "-warmups", "0", "--", "true"},
+ &stdout, &stderr,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".html") {
+ t.Fatalf("saved entries = %v, want one HTML report", entries)
+ }
+ if !strings.Contains(stderr.String(), "Report saved to") {
+ t.Fatal("stderr does not announce the default report path")
+ }
+}
+
+func TestRunNoSaveLeavesCurrentDirectoryEmpty(t *testing.T) {
+ directory := t.TempDir()
+ t.Chdir(directory)
+ if err := Run(
+ []string{"-no-save", "-n", "1", "-warmups", "0", "--", "true"},
+ io.Discard, io.Discard,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil || len(entries) != 0 {
+ t.Fatalf("no-save entries = %v, error = %v", entries, err)
+ }
+}
+
+func TestSavingSameReportUsesUniqueNames(t *testing.T) {
+ directory := t.TempDir()
+ result := model.Report{
+ Config: model.Config{Mode: "command", Baseline: 1},
+ Benchmarks: []model.Benchmark{{
+ Tool: model.ToolInfo{Name: "same"},
+ }},
+ }
+ const saves = 6
+ errors := make(chan error, saves)
+ var wait sync.WaitGroup
+ for index := 0; index < saves; index++ {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ errors <- saveReportFormatsForTest(io.Discard, directory, []string{"html"}, result)
+ }()
+ }
+ wait.Wait()
+ close(errors)
+ for err := range errors {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != saves {
+ t.Fatalf("saved entries = %d, want %d unique reports", len(entries), saves)
+ }
+}
+
+func TestReserveReportStemFailsInUnreadableDirectory(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("directory permissions are not enforced for root")
+ }
+ directory := t.TempDir()
+ if err := os.Chmod(directory, 0o333); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(directory, 0o755) })
+ if _, _, err := reserveReportStem(directory, "report"); err == nil {
+ t.Fatal("unreadable directory should fail instead of retrying forever")
+ }
+}
+
+func TestReserveReportStemReclaimsStaleLock(t *testing.T) {
+ directory := t.TempDir()
+ lockPath := filepath.Join(directory, ".report.lock")
+ if err := os.WriteFile(lockPath, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ leaked := time.Now().Add(-2 * time.Hour)
+ if err := os.Chtimes(lockPath, leaked, leaked); err != nil {
+ t.Fatal(err)
+ }
+ stem, release, err := reserveReportStem(directory, "report")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if stem != "report" {
+ t.Fatalf("stem = %q, want the stale lock reclaimed for %q", stem, "report")
+ }
+}
+
+func TestReserveReportStemSkipsHeldLock(t *testing.T) {
+ directory := t.TempDir()
+ if err := os.WriteFile(filepath.Join(directory, ".report.lock"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ stem, release, err := reserveReportStem(directory, "report")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer release()
+ if stem != "report-2" {
+ t.Fatalf("stem = %q, want a fresh lock to skip to %q", stem, "report-2")
+ }
+}
+
+func TestSavingDifferentFormatsDoesNotReuseExistingStem(t *testing.T) {
+ directory := t.TempDir()
+ result := model.Report{
+ Config: model.Config{Mode: "command", Baseline: 1},
+ Benchmarks: []model.Benchmark{{Tool: model.ToolInfo{Name: "same"}}},
+ }
+ if err := saveReportFormatsForTest(io.Discard, directory, []string{"html"}, result); err != nil {
+ t.Fatal(err)
+ }
+ if err := saveReportFormatsForTest(io.Discard, directory, []string{"markdown"}, result); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 2 || entries[0].Name() == entries[1].Name() {
+ t.Fatalf("saved entries reused a report stem: %v", entries)
+ }
+}
+
+func TestFailedSavePublishesNoPartialReports(t *testing.T) {
+ directory := t.TempDir()
+ result := model.Report{
+ Config: model.Config{Mode: "command", Baseline: 1},
+ Benchmarks: []model.Benchmark{{Tool: model.ToolInfo{Name: "tool"}}},
+ }
+ if err := saveReportFormatsForTest(
+ io.Discard, directory, []string{"html", "unknown"}, result,
+ ); err == nil {
+ t.Fatal("invalid staged format should fail")
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("failed save left partial entries: %v", entries)
+ }
+}
+
+func TestPublishingNeverReplacesExistingDestination(t *testing.T) {
+ directory := t.TempDir()
+ staged := directory + "/staged"
+ final := directory + "/final"
+ if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(final, []byte("existing"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := renameNoReplace(staged, final); err == nil {
+ t.Fatal("publication replaced an existing destination")
+ }
+ data, err := os.ReadFile(final)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "existing" {
+ t.Fatalf("destination content = %q, want existing", data)
+ }
+}
+
+func TestSavedSVGBundleIncludesCommandFailures(t *testing.T) {
+ directory := t.TempDir()
+ failedRun := model.Run{ExitCode: 7, StopReason: "exited", WallSeconds: 0.001}
+ result := model.Report{
+ Config: model.Config{Mode: "command", Baseline: 1},
+ Benchmarks: []model.Benchmark{{
+ Tool: model.ToolInfo{Name: "failed"},
+ Runs: []model.Run{failedRun}, Summary: model.Summarize([]model.Run{failedRun}),
+ }},
+ }
+ if err := saveReportFormatsForTest(io.Discard, directory, []string{"svg"}, result); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(directory)
+ if err != nil || len(entries) != 1 {
+ t.Fatalf("bundle entries = %v, error = %v", entries, err)
+ }
+ charts, err := os.ReadDir(directory + "/" + entries[0].Name() + "/charts")
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, chart := range charts {
+ if strings.Contains(chart.Name(), "command-failures") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("SVG charts do not include command failures: %v", charts)
+ }
+}
+
+func TestFormatAliasesAreCanonicalizedBeforeSaving(t *testing.T) {
+ formats := uniqueFormats([]string{"text", "txt", "markdown", "md"})
+ if len(formats) != 2 || formats[0] != "text" || formats[1] != "markdown" {
+ t.Fatalf("canonical formats = %v", formats)
+ }
+}
+
+func saveReportFormatsForTest(
+ stderr io.Writer, directory string, formats []string, result model.Report,
+) error {
+ return saveReportFormats(
+ stderr, directory, formats, reportpkg.NewRenderer(result),
+ )
+}
diff --git a/internal/app/progress.go b/internal/app/progress.go
index 527ba8c..71fe75c 100644
--- a/internal/app/progress.go
+++ b/internal/app/progress.go
@@ -13,6 +13,7 @@ import (
"golang.org/x/term"
"github.com/shellcell/snailrace/internal/runner"
+ "github.com/shellcell/snailrace/internal/style"
)
func progressRenderer(
@@ -23,7 +24,7 @@ func progressRenderer(
return nil
}
var mutex sync.Mutex
- renderedLines := 0
+ var renderedLines []string
cursorHidden := false
return func(event runner.ProgressEvent) {
mutex.Lock()
@@ -32,20 +33,20 @@ func progressRenderer(
if cursorHidden {
fmt.Fprint(writer, "\x1b[?25h\r\n")
}
- renderedLines = 0
+ renderedLines = nil
return
}
if !cursorHidden {
fmt.Fprint(writer, "\x1b[?25l")
cursorHidden = true
}
- clearProgress(writer, renderedLines)
width, _, _ := term.GetSize(int(file.Fd()))
+ clearProgress(writer, progressRenderedRows(renderedLines, width))
lines := append(
[]string{progressStatus(event, nameWidth)}, progressRaceLines(event, width)...,
)
fmt.Fprint(writer, strings.Join(lines, "\n"))
- renderedLines = len(lines)
+ renderedLines = lines
}
}
@@ -121,6 +122,51 @@ func clearProgress(writer io.Writer, lines int) {
}
}
+func progressRenderedRows(lines []string, terminalWidth int) int {
+ if terminalWidth <= 0 {
+ return len(lines)
+ }
+ rows := 0
+ for _, line := range lines {
+ width := progressDisplayWidth(line)
+ lineRows := (width + terminalWidth - 1) / terminalWidth
+ if lineRows == 0 {
+ lineRows = 1
+ }
+ rows += lineRows
+ }
+ return rows
+}
+
+func progressDisplayWidth(value string) int {
+ width := 0
+ for index := 0; index < len(value); {
+ if value[index] == '\x1b' {
+ next := progressANSIEscapeEnd(value[index:])
+ if next > 0 {
+ index += next
+ continue
+ }
+ }
+ character, size := utf8.DecodeRuneInString(value[index:])
+ index += size
+ width += style.RuneWidth(character)
+ }
+ return width
+}
+
+func progressANSIEscapeEnd(value string) int {
+ if len(value) < 2 || value[0] != '\x1b' || value[1] != '[' {
+ return 0
+ }
+ for index := 2; index < len(value); index++ {
+ if value[index] >= '@' && value[index] <= '~' {
+ return index + 1
+ }
+ }
+ return 0
+}
+
func progressDuration(seconds float64) string {
if seconds < 0.001 {
return fmt.Sprintf("%.3g us", seconds*1e6)
diff --git a/internal/app/progress_race.go b/internal/app/progress_race.go
index 4d3c044..2741273 100644
--- a/internal/app/progress_race.go
+++ b/internal/app/progress_race.go
@@ -78,14 +78,14 @@ func progressScores(event runner.ProgressEvent) []float64 {
benchmarks := make([]model.Benchmark, 0, len(event.Estimates))
positions := make([]int, 0, len(event.Estimates))
for index, tool := range event.Estimates {
- if !tool.HasEstimate {
+ if !tool.HasEstimate || tool.Estimate == nil {
continue
}
benchmarks = append(benchmarks, model.Benchmark{
Tool: model.ToolInfo{
Name: tool.ToolName, DiskFootprintBytes: tool.DiskFootprintBytes,
},
- Runs: tool.Runs, Summary: tool.Estimate,
+ Runs: tool.Runs, Summary: *tool.Estimate,
})
positions = append(positions, index)
}
@@ -105,7 +105,7 @@ func progressTrailCharacters(tools []runner.ProgressEstimate) []string {
minimum := math.Inf(1)
values := make([]float64, len(tools))
for index, tool := range tools {
- if !tool.HasEstimate {
+ if !tool.HasEstimate || tool.Estimate == nil {
continue
}
mean := tool.Estimate.MeanResidentBytes.Mean
diff --git a/internal/app/progress_race_test.go b/internal/app/progress_race_test.go
index 2b95f2b..5b3deb9 100644
--- a/internal/app/progress_race_test.go
+++ b/internal/app/progress_race_test.go
@@ -1,12 +1,15 @@
package app
import (
+ "bytes"
"math"
"regexp"
"strings"
"testing"
"time"
+ "github.com/creack/pty"
+
"github.com/shellcell/snailrace/internal/model"
"github.com/shellcell/snailrace/internal/runner"
)
@@ -15,7 +18,7 @@ var progressColorSequence = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func TestProgressRaceAdvancesBetterExpectedTool(t *testing.T) {
event := runner.ProgressEvent{
- Completed: 2, Total: 4, Elapsed: time.Second, ETA: time.Second,
+ Completed: 2, Total: 4, ETA: time.Second,
Estimates: []runner.ProgressEstimate{
progressEstimate("better", 1),
progressEstimate("worse", 2),
@@ -135,12 +138,71 @@ func TestProgressStatusAlignsNumbersAndName(t *testing.T) {
}
}
+func TestProgressRenderedRowsCountsWrappedLines(t *testing.T) {
+ lines := []string{"12345678901", "short"}
+ if got := progressRenderedRows(lines, 10); got != 3 {
+ t.Fatalf("rendered rows = %d, want 3", got)
+ }
+}
+
+func TestProgressRenderedRowsIgnoresANSIAndCountsKnownEmojiWidth(t *testing.T) {
+ line := "\x1b[38;2;136;192;208mπ\x1b[0m123456789"
+ if got := progressRenderedRows([]string{line}, 10); got != 2 {
+ t.Fatalf("rendered rows = %d, want 2", got)
+ }
+}
+
+func TestProgressRendererRecalculatesRowsAfterResize(t *testing.T) {
+ primary, terminal, err := pty.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer primary.Close()
+ defer terminal.Close()
+ if err := pty.Setsize(terminal, &pty.Winsize{Cols: 200, Rows: 24}); err != nil {
+ t.Fatal(err)
+ }
+ output := progressTerminalBuffer{fd: terminal.Fd()}
+ render := progressRenderer(&output, true, 0)
+ if render == nil {
+ t.Fatal("progress renderer disabled for PTY")
+ }
+ event := runner.ProgressEvent{
+ ToolName: "first", Tool: 1, ToolCount: 1,
+ Iteration: 1, Iterations: 10, Total: 10,
+ }
+ render(event)
+ redrawStart := output.Len()
+ if err := pty.Setsize(terminal, &pty.Winsize{Cols: 40, Rows: 24}); err != nil {
+ t.Fatal(err)
+ }
+ render(event)
+
+ previous := []string{progressStatus(event, 0)}
+ wantRows := progressRenderedRows(previous, 40)
+ redraw := output.String()[redrawStart:]
+ if got := strings.Count(redraw, "\x1b[1A") + 1; got != wantRows {
+ t.Fatalf("cleared rows after resize = %d, want %d", got, wantRows)
+ }
+}
+
+type progressTerminalBuffer struct {
+ bytes.Buffer
+ fd uintptr
+}
+
+func (buffer *progressTerminalBuffer) Fd() uintptr { return buffer.fd }
+
func progressEstimate(name string, scale float64) runner.ProgressEstimate {
stats := model.Stats{Mean: scale}
return runner.ProgressEstimate{
ToolName: name, Completed: 1, Total: 2, HasEstimate: true,
+ Runs: []model.Run{{
+ WallSeconds: scale, CPUUserSeconds: scale,
+ MeanResidentBytes: scale, PeakResidentBytes: scale,
+ }},
DiskFootprintBytes: int64(scale * 100),
- Estimate: model.Summary{
+ Estimate: &model.Summary{
WallSeconds: stats, CPUTotalSeconds: stats,
MeanResidentBytes: stats, PeakResidentBytes: stats,
},
diff --git a/internal/app/publish_darwin.go b/internal/app/publish_darwin.go
new file mode 100644
index 0000000..3f897dd
--- /dev/null
+++ b/internal/app/publish_darwin.go
@@ -0,0 +1,9 @@
+//go:build darwin
+
+package app
+
+import "golang.org/x/sys/unix"
+
+func renameNoReplace(oldPath, newPath string) error {
+ return unix.RenamexNp(oldPath, newPath, unix.RENAME_EXCL)
+}
diff --git a/internal/app/publish_linux.go b/internal/app/publish_linux.go
new file mode 100644
index 0000000..481c18d
--- /dev/null
+++ b/internal/app/publish_linux.go
@@ -0,0 +1,11 @@
+//go:build linux
+
+package app
+
+import "golang.org/x/sys/unix"
+
+func renameNoReplace(oldPath, newPath string) error {
+ return unix.Renameat2(
+ unix.AT_FDCWD, oldPath, unix.AT_FDCWD, newPath, unix.RENAME_NOREPLACE,
+ )
+}
diff --git a/internal/app/specs.go b/internal/app/specs.go
index d125890..a171cc8 100644
--- a/internal/app/specs.go
+++ b/internal/app/specs.go
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/shellcell/snailrace/internal/runner"
+ "github.com/shellcell/snailrace/internal/style"
)
func makeSpecs(commands, arguments []string, labels []string) []runner.Spec {
@@ -14,6 +15,7 @@ func makeSpecs(commands, arguments []string, labels []string) []runner.Spec {
if len(labels) > 0 {
name = labels[0]
}
+ name = style.SafeText(name)
return []runner.Spec{{Name: name, Args: arguments}}
}
defaultLabels := make([]string, len(commands))
@@ -32,6 +34,7 @@ func makeSpecs(commands, arguments []string, labels []string) []runner.Spec {
seen[label]++
label += " #" + fmt.Sprint(seen[label])
}
+ label = style.SafeText(label)
result = append(result, runner.Spec{Name: label, Shell: command})
}
return result
diff --git a/internal/app/validation.go b/internal/app/validation.go
index 271758a..968608f 100644
--- a/internal/app/validation.go
+++ b/internal/app/validation.go
@@ -3,6 +3,7 @@ package app
import (
"errors"
"fmt"
+ "strings"
"time"
"github.com/shellcell/snailrace/internal/report"
@@ -27,6 +28,9 @@ func validateOptions(result options, commands []string, positional int) error {
if len(result.formats) == 0 {
return errors.New("at least one output format is required")
}
+ if !result.noSave && strings.TrimSpace(result.output) == "" {
+ return errors.New("output directory cannot be empty; use -no-save")
+ }
for _, format := range result.formats {
if !report.ValidFormat(format) {
return fmt.Errorf("unknown report format %q", format)
@@ -41,6 +45,11 @@ func validateOptions(result options, commands []string, positional int) error {
if len(commands) == 0 && positional == 0 {
return errors.New("no command specified")
}
+ for _, command := range commands {
+ if strings.TrimSpace(command) == "" {
+ return errors.New("command cannot be empty")
+ }
+ }
commandCount := len(commands)
if positional > 0 {
commandCount = 1
@@ -74,6 +83,16 @@ func validateLabels(labels, commands []string) error {
if len(labels) == 0 {
return nil
}
+ seen := make(map[string]bool, len(labels))
+ for _, label := range labels {
+ if strings.TrimSpace(label) == "" {
+ return errors.New("labels cannot be empty")
+ }
+ if seen[label] {
+ return fmt.Errorf("duplicate label %q", label)
+ }
+ seen[label] = true
+ }
if len(commands) > 0 {
if len(labels) != len(commands) {
return errors.New("label count must match command count")
diff --git a/internal/model/model.go b/internal/model/model.go
index 167a18e..c664ed4 100644
--- a/internal/model/model.go
+++ b/internal/model/model.go
@@ -22,6 +22,17 @@ type Config struct {
OutputMode string `json:"output_mode"`
}
+const (
+ ModeCommand = "command"
+ ModeTUI = "tui"
+)
+
+func (config Config) IsTUI() bool { return config.Mode == ModeTUI }
+
+func (config Config) FixedDurationTUI() bool {
+ return config.IsTUI() && config.DurationSeconds > 0
+}
+
type HostInfo struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
@@ -38,6 +49,9 @@ type HostInfo struct {
type ToolInfo struct {
Name string `json:"name"`
Command []string `json:"command"`
+ ShellCommand bool `json:"shell_command"`
+ ShellTarget bool `json:"-"`
+ ProvenanceVerified bool `json:"provenance_verified"`
Executable string `json:"executable,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
SHA256 string `json:"sha256,omitempty"`
@@ -61,6 +75,7 @@ type Run struct {
AverageCPUPercent float64 `json:"average_cpu_percent"`
PeakResidentBytes float64 `json:"peak_resident_bytes"`
PeakPhysicalFootprintBytes float64 `json:"peak_physical_footprint_bytes,omitempty"`
+ PhysicalFootprintValid bool `json:"physical_footprint_valid"`
OSMaxRSSBytes float64 `json:"os_max_rss_bytes"`
MeanResidentBytes float64 `json:"mean_resident_bytes"`
PeakVirtualBytes float64 `json:"peak_virtual_bytes"`
@@ -72,6 +87,10 @@ type Run struct {
SampleCoverageSeconds float64 `json:"sample_coverage_seconds"`
}
+func (run Run) Failed() bool {
+ return run.StopReason != "duration" && run.ExitCode != 0
+}
+
type Stats struct {
N int `json:"n"`
Min float64 `json:"min"`
@@ -116,6 +135,20 @@ type Benchmark struct {
Summary Summary `json:"summary"`
}
+func (benchmark Benchmark) FailedRunCount() int {
+ count := 0
+ for _, run := range benchmark.Runs {
+ if run.Failed() {
+ count++
+ }
+ }
+ return count
+}
+
+func (benchmark Benchmark) EligibleForRanking() bool {
+ return len(benchmark.Runs) > 0 && benchmark.FailedRunCount() == 0
+}
+
type Report struct {
MeasuredAt time.Time `json:"measured_at"`
Config Config `json:"config"`
diff --git a/internal/model/model_json.go b/internal/model/model_json.go
new file mode 100644
index 0000000..965c6fa
--- /dev/null
+++ b/internal/model/model_json.go
@@ -0,0 +1,50 @@
+package model
+
+import (
+ "encoding/json"
+ "path/filepath"
+)
+
+func (tool *ToolInfo) UnmarshalJSON(data []byte) error {
+ type plain ToolInfo
+ decoded := struct {
+ plain
+ ShellCommand *bool `json:"shell_command"`
+ ProvenanceVerified *bool `json:"provenance_verified"`
+ }{}
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ return err
+ }
+ *tool = ToolInfo(decoded.plain)
+ if decoded.ShellCommand != nil {
+ tool.ShellCommand = *decoded.ShellCommand
+ } else {
+ tool.ShellCommand = len(tool.Command) == 1 &&
+ tool.Command[0] != tool.Executable &&
+ tool.Command[0] != filepath.Base(tool.Executable)
+ }
+ if decoded.ProvenanceVerified != nil {
+ tool.ProvenanceVerified = *decoded.ProvenanceVerified
+ } else {
+ tool.ProvenanceVerified = tool.SHA256 != ""
+ }
+ return nil
+}
+
+func (run *Run) UnmarshalJSON(data []byte) error {
+ type plain Run
+ decoded := struct {
+ plain
+ PhysicalFootprintValid *bool `json:"physical_footprint_valid"`
+ }{}
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ return err
+ }
+ *run = Run(decoded.plain)
+ if decoded.PhysicalFootprintValid != nil {
+ run.PhysicalFootprintValid = *decoded.PhysicalFootprintValid
+ } else {
+ run.PhysicalFootprintValid = run.PeakPhysicalFootprintBytes > 0
+ }
+ return nil
+}
diff --git a/internal/model/model_json_test.go b/internal/model/model_json_test.go
new file mode 100644
index 0000000..a803914
--- /dev/null
+++ b/internal/model/model_json_test.go
@@ -0,0 +1,50 @@
+package model
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestLegacyRunInfersPhysicalFootprintValidity(t *testing.T) {
+ var run Run
+ if err := json.Unmarshal(
+ []byte(`{"peak_physical_footprint_bytes":123}`), &run,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if !run.PhysicalFootprintValid {
+ t.Fatal("legacy positive physical footprint should remain valid")
+ }
+}
+
+func TestLegacyToolInfersShellCommand(t *testing.T) {
+ var tool ToolInfo
+ if err := json.Unmarshal([]byte(`{"command":["grep foo file"]}`), &tool); err != nil {
+ t.Fatal(err)
+ }
+ if !tool.ShellCommand {
+ t.Fatal("legacy one-element command should retain shell rendering")
+ }
+ data, err := json.Marshal(ToolInfo{Command: []string{"/tmp/my tool"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(data, &tool); err != nil {
+ t.Fatal(err)
+ }
+ if tool.ShellCommand {
+ t.Fatal("current direct command should retain argv rendering")
+ }
+}
+
+func TestLegacyToolInfersDirectSingleArgumentAndVerifiedHash(t *testing.T) {
+ var tool ToolInfo
+ if err := json.Unmarshal([]byte(
+ `{"command":["/tmp/my tool"],"executable":"/tmp/my tool","sha256":"abc"}`,
+ ), &tool); err != nil {
+ t.Fatal(err)
+ }
+ if tool.ShellCommand || !tool.ProvenanceVerified {
+ t.Fatalf("legacy direct tool = %+v", tool)
+ }
+}
diff --git a/internal/model/sampling.go b/internal/model/sampling.go
new file mode 100644
index 0000000..8e199a3
--- /dev/null
+++ b/internal/model/sampling.go
@@ -0,0 +1,18 @@
+package model
+
+func SamplingReliable(benchmark Benchmark, intervalSeconds float64) bool {
+ if len(benchmark.Runs) == 0 {
+ return false
+ }
+ if intervalSeconds <= 0 {
+ return true
+ }
+ minimumDuration := intervalSeconds * 2
+ for _, run := range benchmark.Runs {
+ if run.WallSeconds < minimumDuration || run.SampleCount < 2 ||
+ run.SampleCoverageSeconds < intervalSeconds {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/model/sampling_test.go b/internal/model/sampling_test.go
new file mode 100644
index 0000000..89f146a
--- /dev/null
+++ b/internal/model/sampling_test.go
@@ -0,0 +1,26 @@
+package model
+
+import "testing"
+
+func TestSamplingReliableBoundaries(t *testing.T) {
+ interval := 0.01
+ tests := []struct {
+ name string
+ runs []Run
+ want bool
+ }{
+ {"no runs", nil, false},
+ {"reliable", []Run{{WallSeconds: 0.02, SampleCount: 2, SampleCoverageSeconds: 0.01}}, true},
+ {"short", []Run{{WallSeconds: 0.019, SampleCount: 2, SampleCoverageSeconds: 0.01}}, false},
+ {"few samples", []Run{{WallSeconds: 0.02, SampleCount: 1, SampleCoverageSeconds: 0.01}}, false},
+ {"low coverage", []Run{{WallSeconds: 0.02, SampleCount: 2, SampleCoverageSeconds: 0.009}}, false},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ benchmark := Benchmark{Runs: test.runs}
+ if got := SamplingReliable(benchmark, interval); got != test.want {
+ t.Fatalf("reliable = %v, want %v", got, test.want)
+ }
+ })
+ }
+}
diff --git a/internal/model/stats.go b/internal/model/stats.go
index 69f8e68..6d34e9a 100644
--- a/internal/model/stats.go
+++ b/internal/model/stats.go
@@ -6,96 +6,104 @@ import (
)
func Summarize(runs []Run) Summary {
- values := func(pick func(Run) float64) []float64 {
- result := make([]float64, len(runs))
- for i, run := range runs {
- result[i] = pick(run)
- }
- return result
- }
- physicalValues := values(func(r Run) float64 { return r.PeakPhysicalFootprintBytes })
- var physicalStats *Stats
- for _, value := range physicalValues {
- if value > 0 {
- value := stats(physicalValues)
- physicalStats = &value
- break
- }
- }
- return Summary{
- WallSeconds: stats(values(func(r Run) float64 { return r.WallSeconds })),
- CPUTotalSeconds: stats(values(func(r Run) float64 {
- return r.CPUUserSeconds + r.CPUSystemSeconds
- })),
- CPUUserSeconds: stats(values(func(r Run) float64 {
- return r.CPUUserSeconds
- })),
- CPUSystemSeconds: stats(values(func(r Run) float64 {
- return r.CPUSystemSeconds
- })),
- AverageCPUPercent: stats(values(func(r Run) float64 {
- return r.AverageCPUPercent
- })),
- PeakResidentBytes: stats(values(func(r Run) float64 {
- return r.PeakResidentBytes
- })),
- PeakPhysicalFootprintBytes: physicalStats,
- OSMaxRSSBytes: stats(values(func(r Run) float64 {
- return r.OSMaxRSSBytes
- })),
- MeanResidentBytes: stats(values(func(r Run) float64 {
- return r.MeanResidentBytes
- })),
- PeakVirtualBytes: stats(values(func(r Run) float64 {
- return r.PeakVirtualBytes
- })),
- PeakProcesses: stats(values(func(r Run) float64 { return r.PeakProcesses })),
- PeakThreads: stats(values(func(r Run) float64 { return r.PeakThreads })),
- PeakFileDescriptors: stats(values(func(r Run) float64 {
- return r.PeakFileDescriptors
- })),
- ValidSampleCount: stats(values(func(r Run) float64 {
- return float64(r.SampleCount)
- })),
- SampleCoverageSeconds: stats(values(func(r Run) float64 {
- return r.SampleCoverageSeconds
- })),
+ accumulator := NewSummaryAccumulator(len(runs))
+ for _, run := range runs {
+ accumulator.Add(run)
}
+ return accumulator.Snapshot()
}
func CalculateStats(values []float64) Stats { return stats(values) }
+type RunningStats struct {
+ values []float64
+ moments statsMoments
+}
+
+func NewRunningStats(capacity int) RunningStats {
+ return RunningStats{values: make([]float64, 0, capacity)}
+}
+
+func (running *RunningStats) Add(value float64) {
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return
+ }
+ position := sort.SearchFloat64s(running.values, value)
+ running.values = append(running.values, 0)
+ copy(running.values[position+1:], running.values[position:])
+ running.values[position] = value
+ running.moments.add(value)
+}
+
+func (running RunningStats) Snapshot() Stats {
+ return statsFromSorted(running.values, running.moments)
+}
+
func stats(values []float64) Stats {
- if len(values) == 0 {
+ finite := make([]float64, 0, len(values))
+ var moments statsMoments
+ for _, value := range values {
+ if !math.IsNaN(value) && !math.IsInf(value, 0) {
+ finite = append(finite, value)
+ moments.add(value)
+ }
+ }
+ if len(finite) == 0 {
return Stats{}
}
- sorted := append([]float64(nil), values...)
- sort.Float64s(sorted)
- total := 0.0
- for _, value := range sorted {
- total += value
+ sort.Float64s(finite)
+ return statsFromSorted(finite, moments)
+}
+
+type statsMoments struct {
+ count int
+ mean float64
+ m2 float64
+ varianceOverflow bool
+}
+
+func (moments *statsMoments) add(value float64) {
+ moments.count++
+ count := float64(moments.count)
+ previousMean := moments.mean
+ moments.mean = previousMean*(1-1/count) + value/count
+ if math.IsInf(moments.mean, 0) {
+ moments.mean = math.Copysign(math.MaxFloat64, moments.mean)
}
- mean := total / float64(len(sorted))
- variance := 0.0
- for _, value := range sorted {
- delta := value - mean
- variance += delta * delta
+ delta := value - previousMean
+ term := delta * (value - moments.mean)
+ if math.IsInf(term, 0) || math.IsNaN(term) || math.MaxFloat64-moments.m2 < term {
+ moments.varianceOverflow = true
+ moments.m2 = math.MaxFloat64
+ } else if term > 0 {
+ moments.m2 += term
}
- if len(sorted) > 1 {
- variance /= float64(len(sorted) - 1)
+}
+
+func statsFromSorted(sorted []float64, moments statsMoments) Stats {
+ if len(sorted) == 0 {
+ return Stats{}
+ }
+ standardDeviation := 0.0
+ if len(sorted) > 1 && moments.varianceOverflow {
+ standardDeviation = math.MaxFloat64
+ } else if len(sorted) > 1 {
+ standardDeviation = math.Sqrt(moments.m2 / float64(len(sorted)-1))
}
- standardDeviation := math.Sqrt(variance)
margin := 0.0
validInterval := len(sorted) > 1
if len(sorted) > 1 {
- margin = tCritical95(len(sorted)-1) * standardDeviation /
- math.Sqrt(float64(len(sorted)))
+ margin = standardDeviation / math.Sqrt(float64(len(sorted))) *
+ tCritical95(len(sorted)-1)
+ if math.IsInf(margin, 0) {
+ margin = math.MaxFloat64
+ }
}
return Stats{
- N: len(sorted), Min: sorted[0], Max: sorted[len(sorted)-1], Mean: mean,
+ N: len(sorted), Min: sorted[0], Max: sorted[len(sorted)-1], Mean: moments.mean,
StdDev: standardDeviation, Median: percentile(sorted, 0.5),
- P95: percentile(sorted, 0.95), CI95Low: mean - margin,
- CI95High: mean + margin, CI95Valid: validInterval,
+ P95: percentile(sorted, 0.95), CI95Low: finiteDifference(moments.mean, margin),
+ CI95High: finiteSum(moments.mean, margin), CI95Valid: validInterval,
}
}
@@ -106,7 +114,23 @@ func percentile(sorted []float64, percentile float64) float64 {
position := percentile * float64(len(sorted)-1)
lower, upper := int(math.Floor(position)), int(math.Ceil(position))
weight := position - float64(lower)
- return sorted[lower]*(1-weight) + sorted[upper]*weight
+ return finiteSum(sorted[lower]*(1-weight), sorted[upper]*weight)
+}
+
+func finiteSum(left, right float64) float64 {
+ result := left + right
+ if math.IsInf(result, 0) {
+ return math.Copysign(math.MaxFloat64, result)
+ }
+ return result
+}
+
+func finiteDifference(left, right float64) float64 {
+ result := left - right
+ if math.IsInf(result, 0) {
+ return math.Copysign(math.MaxFloat64, result)
+ }
+ return result
}
// Two-sided 95% Student's t critical values for 1..30 degrees of freedom.
diff --git a/internal/model/stats_bench_test.go b/internal/model/stats_bench_test.go
new file mode 100644
index 0000000..61a68b1
--- /dev/null
+++ b/internal/model/stats_bench_test.go
@@ -0,0 +1,22 @@
+package model
+
+import "testing"
+
+func BenchmarkSummarize1000Runs(b *testing.B) {
+ runs := make([]Run, 1000)
+ for index := range runs {
+ value := float64((index * 7919) % len(runs))
+ runs[index] = Run{
+ WallSeconds: value, CPUUserSeconds: value, CPUSystemSeconds: value,
+ AverageCPUPercent: value, PeakResidentBytes: value,
+ OSMaxRSSBytes: value, MeanResidentBytes: value, PeakVirtualBytes: value,
+ PeakProcesses: value, PeakThreads: value, PeakFileDescriptors: value,
+ SampleCount: index, SampleCoverageSeconds: value,
+ }
+ }
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ _ = Summarize(runs)
+ }
+}
diff --git a/internal/model/stats_json.go b/internal/model/stats_json.go
index bd05ef1..3722e35 100644
--- a/internal/model/stats_json.go
+++ b/internal/model/stats_json.go
@@ -1,6 +1,9 @@
package model
-import "encoding/json"
+import (
+ "encoding/json"
+ "math"
+)
func (stats Stats) MarshalJSON() ([]byte, error) {
type plain Stats
@@ -14,3 +17,32 @@ func (stats Stats) MarshalJSON() ([]byte, error) {
CI95High *float64 `json:"ci95_high,omitempty"`
}{plain(stats), low, high})
}
+
+func (stats *Stats) UnmarshalJSON(data []byte) error {
+ type plain Stats
+ decoded := struct {
+ plain
+ CI95Low *float64 `json:"ci95_low"`
+ CI95High *float64 `json:"ci95_high"`
+ }{}
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ return err
+ }
+ *stats = Stats(decoded.plain)
+ if stats.N < 2 {
+ stats.CI95Valid = false
+ return nil
+ }
+ if !stats.CI95Valid {
+ return nil
+ }
+ if decoded.CI95Low == nil || decoded.CI95High == nil {
+ margin := stats.StdDev / math.Sqrt(float64(stats.N)) * tCritical95(stats.N-1)
+ stats.CI95Low = finiteDifference(stats.Mean, margin)
+ stats.CI95High = finiteSum(stats.Mean, margin)
+ return nil
+ }
+ stats.CI95Low = *decoded.CI95Low
+ stats.CI95High = *decoded.CI95High
+ return nil
+}
diff --git a/internal/model/stats_test.go b/internal/model/stats_test.go
index ba32945..f6958fe 100644
--- a/internal/model/stats_test.go
+++ b/internal/model/stats_test.go
@@ -3,6 +3,7 @@ package model
import (
"encoding/json"
"math"
+ "reflect"
"strings"
"testing"
)
@@ -53,6 +54,146 @@ func TestUnavailablePhysicalFootprintIsOmittedFromJSON(t *testing.T) {
}
}
+func TestPhysicalFootprintUsesExplicitPerRunValidity(t *testing.T) {
+ summary := Summarize([]Run{
+ {PeakPhysicalFootprintBytes: 0, PhysicalFootprintValid: true},
+ {PeakPhysicalFootprintBytes: 10, PhysicalFootprintValid: true},
+ {PeakPhysicalFootprintBytes: 1000, PhysicalFootprintValid: false},
+ })
+ if summary.PeakPhysicalFootprintBytes == nil {
+ t.Fatal("valid zero-valued physical footprint should be retained")
+ }
+ if got := summary.PeakPhysicalFootprintBytes; got.N != 2 || got.Mean != 5 {
+ t.Fatalf("physical footprint stats = %+v, want N=2 mean=5", *got)
+ }
+}
+
+func TestStatsRemainFiniteForExtremeInputs(t *testing.T) {
+ result := CalculateStats([]float64{
+ math.MaxFloat64, -math.MaxFloat64, math.NaN(), math.Inf(1),
+ })
+ if result.N != 2 {
+ t.Fatalf("N = %d, want two finite observations", result.N)
+ }
+ for name, value := range map[string]float64{
+ "mean": result.Mean, "stddev": result.StdDev, "median": result.Median,
+ "p95": result.P95, "ci low": result.CI95Low, "ci high": result.CI95High,
+ } {
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ t.Fatalf("%s is not finite: %v", name, value)
+ }
+ }
+ if _, err := json.Marshal(result); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStatsJSONRoundTripPreservesConfidenceBounds(t *testing.T) {
+ original := CalculateStats([]float64{1, 2, 4, 8})
+ data, err := json.Marshal(original)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded Stats
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ t.Fatal(err)
+ }
+ if decoded != original {
+ t.Fatalf("round trip = %+v, want %+v", decoded, original)
+ }
+}
+
+func TestStatsUnmarshalAcceptsLegacyConfidenceInterval(t *testing.T) {
+ var decoded Stats
+ if err := json.Unmarshal(
+ []byte(`{"n":2,"mean":3,"stddev":1,"ci95_valid":true}`), &decoded,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if !decoded.CI95Valid || decoded.CI95Low == 0 || decoded.CI95High == 0 {
+ t.Fatalf("legacy confidence interval = %+v", decoded)
+ }
+}
+
+func TestStatsUnmarshalRejectsSingleObservationInterval(t *testing.T) {
+ var decoded Stats
+ if err := json.Unmarshal(
+ []byte(`{"n":1,"mean":3,"ci95_valid":true,"ci95_low":2,"ci95_high":4}`),
+ &decoded,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ if decoded.CI95Valid {
+ t.Fatal("one observation cannot have a confidence interval")
+ }
+}
+
+func TestSummaryAccumulatorMatchesBatchSummaryAtEveryPrefix(t *testing.T) {
+ runs := []Run{
+ {WallSeconds: 1, CPUUserSeconds: 2, CPUSystemSeconds: 3,
+ AverageCPUPercent: 4, PeakResidentBytes: 5, OSMaxRSSBytes: 6,
+ MeanResidentBytes: 7, PeakVirtualBytes: 8, PeakProcesses: 9,
+ PeakThreads: 10, PeakFileDescriptors: 11, SampleCount: 12,
+ SampleCoverageSeconds: 13},
+ {WallSeconds: 14, CPUUserSeconds: 15, CPUSystemSeconds: 16,
+ AverageCPUPercent: 17, PeakResidentBytes: 18,
+ PeakPhysicalFootprintBytes: 19, PhysicalFootprintValid: true,
+ OSMaxRSSBytes: 20, MeanResidentBytes: 21, PeakVirtualBytes: 22,
+ PeakProcesses: 23, PeakThreads: 24, PeakFileDescriptors: 25,
+ SampleCount: 26, SampleCoverageSeconds: 27},
+ }
+ accumulator := NewSummaryAccumulator(len(runs))
+ for index, run := range runs {
+ accumulator.Add(run)
+ got, want := accumulator.Snapshot(), independentSummary(runs[:index+1])
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("prefix %d summary mismatch:\ngot %+v\nwant %+v", index+1, got, want)
+ }
+ }
+}
+
+func independentSummary(runs []Run) Summary {
+ values := func(pick func(Run) float64) []float64 {
+ result := make([]float64, len(runs))
+ for index, run := range runs {
+ result[index] = pick(run)
+ }
+ return result
+ }
+ physicalValues := make([]float64, 0, len(runs))
+ for _, run := range runs {
+ if run.PhysicalFootprintValid {
+ physicalValues = append(physicalValues, run.PeakPhysicalFootprintBytes)
+ }
+ }
+ var physical *Stats
+ if len(physicalValues) > 0 {
+ value := CalculateStats(physicalValues)
+ physical = &value
+ }
+ return Summary{
+ WallSeconds: CalculateStats(values(func(run Run) float64 { return run.WallSeconds })),
+ CPUTotalSeconds: CalculateStats(values(func(run Run) float64 {
+ return run.CPUUserSeconds + run.CPUSystemSeconds
+ })),
+ CPUUserSeconds: CalculateStats(values(func(run Run) float64 { return run.CPUUserSeconds })),
+ CPUSystemSeconds: CalculateStats(values(func(run Run) float64 { return run.CPUSystemSeconds })),
+ AverageCPUPercent: CalculateStats(values(func(run Run) float64 { return run.AverageCPUPercent })),
+ PeakResidentBytes: CalculateStats(values(func(run Run) float64 { return run.PeakResidentBytes })),
+ PeakPhysicalFootprintBytes: physical,
+ OSMaxRSSBytes: CalculateStats(values(func(run Run) float64 { return run.OSMaxRSSBytes })),
+ MeanResidentBytes: CalculateStats(values(func(run Run) float64 { return run.MeanResidentBytes })),
+ PeakVirtualBytes: CalculateStats(values(func(run Run) float64 { return run.PeakVirtualBytes })),
+ PeakProcesses: CalculateStats(values(func(run Run) float64 { return run.PeakProcesses })),
+ PeakThreads: CalculateStats(values(func(run Run) float64 { return run.PeakThreads })),
+ PeakFileDescriptors: CalculateStats(values(func(run Run) float64 { return run.PeakFileDescriptors })),
+ ValidSampleCount: CalculateStats(values(func(run Run) float64 { return float64(run.SampleCount) })),
+ SampleCoverageSeconds: CalculateStats(values(func(run Run) float64 {
+ return run.SampleCoverageSeconds
+ })),
+ }
+}
+
func assertClose(t *testing.T, got, want float64) {
t.Helper()
if math.Abs(got-want) > 1e-9 {
diff --git a/internal/model/summary_accumulator.go b/internal/model/summary_accumulator.go
new file mode 100644
index 0000000..79335b2
--- /dev/null
+++ b/internal/model/summary_accumulator.go
@@ -0,0 +1,66 @@
+package model
+
+type SummaryAccumulator struct {
+ wall, cpuTotal, cpuUser, cpuSystem RunningStats
+ averageCPU RunningStats
+ peakResident, physical, osMaxRSS RunningStats
+ meanResident, peakVirtual RunningStats
+ peakProcesses, peakThreads RunningStats
+ peakFileDescriptors RunningStats
+ validSampleCount, sampleCoverage RunningStats
+}
+
+func NewSummaryAccumulator(capacity int) SummaryAccumulator {
+ metric := func() RunningStats { return NewRunningStats(capacity) }
+ return SummaryAccumulator{
+ wall: metric(), cpuTotal: metric(), cpuUser: metric(), cpuSystem: metric(),
+ averageCPU: metric(), peakResident: metric(), physical: metric(),
+ osMaxRSS: metric(), meanResident: metric(), peakVirtual: metric(),
+ peakProcesses: metric(), peakThreads: metric(), peakFileDescriptors: metric(),
+ validSampleCount: metric(), sampleCoverage: metric(),
+ }
+}
+
+func (summary *SummaryAccumulator) Add(run Run) {
+ summary.wall.Add(run.WallSeconds)
+ summary.cpuTotal.Add(run.CPUUserSeconds + run.CPUSystemSeconds)
+ summary.cpuUser.Add(run.CPUUserSeconds)
+ summary.cpuSystem.Add(run.CPUSystemSeconds)
+ summary.averageCPU.Add(run.AverageCPUPercent)
+ summary.peakResident.Add(run.PeakResidentBytes)
+ if run.PhysicalFootprintValid {
+ summary.physical.Add(run.PeakPhysicalFootprintBytes)
+ }
+ summary.osMaxRSS.Add(run.OSMaxRSSBytes)
+ summary.meanResident.Add(run.MeanResidentBytes)
+ summary.peakVirtual.Add(run.PeakVirtualBytes)
+ summary.peakProcesses.Add(run.PeakProcesses)
+ summary.peakThreads.Add(run.PeakThreads)
+ summary.peakFileDescriptors.Add(run.PeakFileDescriptors)
+ summary.validSampleCount.Add(float64(run.SampleCount))
+ summary.sampleCoverage.Add(run.SampleCoverageSeconds)
+}
+
+func (summary SummaryAccumulator) Snapshot() Summary {
+ result := Summary{
+ WallSeconds: summary.wall.Snapshot(),
+ CPUTotalSeconds: summary.cpuTotal.Snapshot(),
+ CPUUserSeconds: summary.cpuUser.Snapshot(),
+ CPUSystemSeconds: summary.cpuSystem.Snapshot(),
+ AverageCPUPercent: summary.averageCPU.Snapshot(),
+ PeakResidentBytes: summary.peakResident.Snapshot(),
+ OSMaxRSSBytes: summary.osMaxRSS.Snapshot(),
+ MeanResidentBytes: summary.meanResident.Snapshot(),
+ PeakVirtualBytes: summary.peakVirtual.Snapshot(),
+ PeakProcesses: summary.peakProcesses.Snapshot(),
+ PeakThreads: summary.peakThreads.Snapshot(),
+ PeakFileDescriptors: summary.peakFileDescriptors.Snapshot(),
+ ValidSampleCount: summary.validSampleCount.Snapshot(),
+ SampleCoverageSeconds: summary.sampleCoverage.Snapshot(),
+ }
+ physical := summary.physical.Snapshot()
+ if physical.N > 0 {
+ result.PeakPhysicalFootprintBytes = &physical
+ }
+ return result
+}
diff --git a/internal/platform/dependencies_darwin.go b/internal/platform/dependencies_darwin.go
index 534ea86..e79ff99 100644
--- a/internal/platform/dependencies_darwin.go
+++ b/internal/platform/dependencies_darwin.go
@@ -3,31 +3,40 @@ package platform
import (
"bufio"
"bytes"
+ "context"
"os"
"os/exec"
"path/filepath"
"strings"
)
-func LinkedFiles(executable string) []LinkedDependency {
+func LinkedFiles(ctx context.Context, executable string) ([]LinkedDependency, error) {
type queuedLibrary struct {
path string
rpaths []string
}
- executableRPaths := loadRPaths(executable, executable, executable)
+ executableRPaths := loadRPaths(ctx, executable, executable, executable)
queue := []queuedLibrary{{path: executable, rpaths: executableRPaths}}
seen := map[string]bool{executable: true}
var result []LinkedDependency
for len(queue) > 0 {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
current := queue[0]
queue = queue[1:]
- output, err := exec.Command("/usr/bin/otool", "-L", current.path).Output()
+ output, err := exec.CommandContext(ctx, "/usr/bin/otool", "-L", current.path).Output()
if err != nil {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
continue
}
- rpaths := append(loadRPaths(current.path, executable, current.path), current.rpaths...)
+ rpaths := append(
+ loadRPaths(ctx, current.path, executable, current.path), current.rpaths...,
+ )
for _, name := range parseOtool(output) {
- dependency, ok := resolveDylib(name, executable, current.path, rpaths)
+ dependency, ok := resolveDylib(ctx, name, executable, current.path, rpaths)
if !ok || seen[dependency.Path] {
continue
}
@@ -38,23 +47,26 @@ func LinkedFiles(executable string) []LinkedDependency {
}
}
}
- return result
+ return result, nil
}
func parseOtool(output []byte) []string {
lines := strings.Split(string(output), "\n")
result := make([]string, 0, len(lines))
for _, line := range lines[1:] {
- fields := strings.Fields(line)
- if len(fields) > 0 {
- result = append(result, fields[0])
+ line = strings.TrimSpace(line)
+ if metadata := strings.LastIndex(line, " (compatibility version "); metadata >= 0 {
+ line = line[:metadata]
+ }
+ if line != "" {
+ result = append(result, line)
}
}
return result
}
-func loadRPaths(path, executable, loader string) []string {
- output, err := exec.Command("/usr/bin/otool", "-l", path).Output()
+func loadRPaths(ctx context.Context, path, executable, loader string) []string {
+ output, err := exec.CommandContext(ctx, "/usr/bin/otool", "-l", path).Output()
if err != nil {
return nil
}
@@ -70,16 +82,21 @@ func loadRPaths(path, executable, loader string) []string {
if !wantPath || !strings.HasPrefix(line, "path ") {
continue
}
- fields := strings.Fields(line)
- if len(fields) >= 2 {
- result = append(result, expandDylibPath(fields[1], executable, loader))
+ path := strings.TrimSpace(strings.TrimPrefix(line, "path "))
+ if metadata := strings.LastIndex(path, " (offset "); metadata >= 0 {
+ path = path[:metadata]
+ }
+ if path != "" {
+ result = append(result, expandDylibPath(path, executable, loader))
}
wantPath = false
}
return result
}
-func resolveDylib(name, executable, loader string, rpaths []string) (LinkedDependency, bool) {
+func resolveDylib(
+ ctx context.Context, name, executable, loader string, rpaths []string,
+) (LinkedDependency, bool) {
var candidates []string
if suffix, found := strings.CutPrefix(name, "@rpath/"); found {
for _, rpath := range rpaths {
@@ -101,7 +118,7 @@ func resolveDylib(name, executable, loader string, rpaths []string) (LinkedDepen
sharedCacheCandidate = resolved
}
}
- if sharedCacheCandidate != "" && inSharedCache(sharedCacheCandidate) {
+ if sharedCacheCandidate != "" && inSharedCache(ctx, sharedCacheCandidate) {
return LinkedDependency{Path: sharedCacheCandidate, SharedCache: true}, true
}
return LinkedDependency{}, false
@@ -117,6 +134,6 @@ func isSharedCachePath(path string) bool {
strings.HasPrefix(path, "/System/Library/")
}
-func inSharedCache(path string) bool {
- return exec.Command("/usr/bin/dyld_info", "-dependents", path).Run() == nil
+func inSharedCache(ctx context.Context, path string) bool {
+ return exec.CommandContext(ctx, "/usr/bin/dyld_info", "-dependents", path).Run() == nil
}
diff --git a/internal/platform/dependencies_darwin_test.go b/internal/platform/dependencies_darwin_test.go
index 41f9c59..9780f1a 100644
--- a/internal/platform/dependencies_darwin_test.go
+++ b/internal/platform/dependencies_darwin_test.go
@@ -1,6 +1,7 @@
package platform
import (
+ "context"
"os"
"path/filepath"
"reflect"
@@ -18,6 +19,16 @@ func TestParseOtool(t *testing.T) {
}
}
+func TestParseOtoolPreservesPathsWithSpaces(t *testing.T) {
+ output := []byte("/tmp/tool:\n" +
+ "\t/Applications/My Tool/lib helper.dylib " +
+ "(compatibility version 1.0.0, current version 1.0.0)\n")
+ want := []string{"/Applications/My Tool/lib helper.dylib"}
+ if got := parseOtool(output); !reflect.DeepEqual(got, want) {
+ t.Fatalf("dependencies = %#v, want %#v", got, want)
+ }
+}
+
func TestResolveDylibUsesRPathAndIdentifiesSharedCache(t *testing.T) {
directory := t.TempDir()
library := filepath.Join(directory, "liblocal.dylib")
@@ -26,12 +37,14 @@ func TestResolveDylibUsesRPathAndIdentifiesSharedCache(t *testing.T) {
}
library, _ = filepath.EvalSymlinks(library)
dependency, ok := resolveDylib(
+ context.Background(),
"@rpath/liblocal.dylib", "/tmp/tool", "/tmp/tool", []string{directory},
)
if !ok || dependency.Path != library || dependency.SharedCache {
t.Fatalf("resolved dependency = %+v, valid=%v", dependency, ok)
}
dependency, ok = resolveDylib(
+ context.Background(),
"/usr/lib/libSystem.B.dylib", "/tmp/tool", "/tmp/tool", nil,
)
if !ok || !dependency.SharedCache {
diff --git a/internal/platform/dependencies_linux.go b/internal/platform/dependencies_linux.go
index 38a20a9..9d2c449 100644
--- a/internal/platform/dependencies_linux.go
+++ b/internal/platform/dependencies_linux.go
@@ -1,19 +1,27 @@
package platform
import (
+ "context"
+ "fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
-func LinkedFiles(executable string) []LinkedDependency {
- output, _ := exec.Command("ldd", executable).CombinedOutput()
+func LinkedFiles(ctx context.Context, executable string) ([]LinkedDependency, error) {
+ output, err := runLDD(ctx, executable)
+ if err != nil {
+ return nil, err
+ }
files := parseLDD(output)
if len(files) == 0 {
- if interpreter := shebangInterpreter(executable); interpreter != "" {
+ for _, interpreter := range shebangExecutables(executable) {
files = append(files, interpreter)
- output, _ = exec.Command("ldd", interpreter).CombinedOutput()
+ output, err = runLDD(ctx, interpreter)
+ if err != nil {
+ return nil, err
+ }
files = append(files, parseLDD(output)...)
}
}
@@ -22,18 +30,35 @@ func LinkedFiles(executable string) []LinkedDependency {
for index, path := range paths {
result[index] = LinkedDependency{Path: path}
}
- return result
+ return result, nil
+}
+
+func runLDD(ctx context.Context, executable string) ([]byte, error) {
+ output, err := exec.CommandContext(ctx, "ldd", executable).CombinedOutput()
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ if err == nil {
+ return output, nil
+ }
+ message := strings.ToLower(string(output))
+ if strings.Contains(message, "not a dynamic executable") ||
+ strings.Contains(message, "statically linked") {
+ return output, nil
+ }
+ return nil, fmt.Errorf("inspect linked files for %q: %w: %s",
+ executable, err, strings.TrimSpace(string(output)))
}
func parseLDD(output []byte) []string {
var files []string
for _, line := range strings.Split(string(output), "\n") {
- fields := strings.Fields(line)
- candidate := ""
- if len(fields) >= 3 && fields[1] == "=>" {
- candidate = fields[2]
- } else if len(fields) >= 1 && filepath.IsAbs(fields[0]) {
- candidate = fields[0]
+ candidate := strings.TrimSpace(line)
+ if _, after, found := strings.Cut(candidate, "=>"); found {
+ candidate = strings.TrimSpace(after)
+ }
+ if metadata := strings.LastIndex(candidate, " ("); metadata >= 0 {
+ candidate = strings.TrimSpace(candidate[:metadata])
}
if filepath.IsAbs(candidate) {
files = append(files, candidate)
@@ -42,17 +67,63 @@ func parseLDD(output []byte) []string {
return files
}
-func shebangInterpreter(path string) string {
- data, err := os.ReadFile(path)
- if err != nil || !strings.HasPrefix(string(data), "#!") {
- return ""
+func shebangExecutables(path string) []string {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil
+ }
+ defer file.Close()
+ buffer := make([]byte, 4096)
+ count, err := file.Read(buffer)
+ if err != nil && count == 0 {
+ return nil
}
- line, _, _ := strings.Cut(string(data[2:]), "\n")
+ data := string(buffer[:count])
+ if !strings.HasPrefix(data, "#!") {
+ return nil
+ }
+ line, _, _ := strings.Cut(data[2:], "\n")
fields := strings.Fields(line)
if len(fields) == 0 || !filepath.IsAbs(fields[0]) {
- return ""
+ return nil
+ }
+ result := []string{fields[0]}
+ if filepath.Base(fields[0]) != "env" {
+ return result
}
- return fields[0]
+ for index := 1; index < len(fields); index++ {
+ field := fields[index]
+ switch field {
+ case "-u", "--unset", "-C", "--chdir", "-a", "--argv0":
+ index++
+ continue
+ case "-S", "--split-string", "--":
+ continue
+ }
+ if strings.HasPrefix(field, "-S") && len(field) > 2 {
+ field = field[2:]
+ } else if split, found := strings.CutPrefix(field, "--split-string="); found {
+ field = split
+ } else if strings.HasPrefix(field, "--unset=") ||
+ strings.HasPrefix(field, "--chdir=") ||
+ strings.HasPrefix(field, "--argv0=") {
+ continue
+ }
+ if strings.HasPrefix(field, "-") || strings.Contains(field, "=") {
+ continue
+ }
+ if split := strings.Fields(field); len(split) > 0 {
+ field = split[0]
+ }
+ if executable, err := exec.LookPath(field); err == nil {
+ if absolute, absErr := filepath.Abs(executable); absErr == nil {
+ executable = absolute
+ }
+ result = append(result, executable)
+ }
+ break
+ }
+ return result
}
func uniqueFiles(paths []string, executable string) []string {
diff --git a/internal/platform/dependencies_linux_test.go b/internal/platform/dependencies_linux_test.go
index cba74f6..7065fea 100644
--- a/internal/platform/dependencies_linux_test.go
+++ b/internal/platform/dependencies_linux_test.go
@@ -1,6 +1,9 @@
package platform
import (
+ "os"
+ "os/exec"
+ "path/filepath"
"reflect"
"testing"
)
@@ -19,3 +22,55 @@ libmissing.so => not found
t.Fatalf("files = %#v, want %#v", got, want)
}
}
+
+func TestParseLDDPreservesPathsWithSpaces(t *testing.T) {
+ output := []byte("libfoo.so => /tmp/lib dir/libfoo.so (0x123)\n" +
+ "/tmp/loader dir/ld.so (0x456)\n")
+ want := []string{"/tmp/lib dir/libfoo.so", "/tmp/loader dir/ld.so"}
+ if got := parseLDD(output); !reflect.DeepEqual(got, want) {
+ t.Fatalf("files = %#v, want %#v", got, want)
+ }
+}
+
+func TestShebangExecutablesResolvesEnvInterpreter(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "script")
+ if err := os.WriteFile(path, []byte("#!/usr/bin/env -S sh -eu\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ got := shebangExecutables(path)
+ sh, err := exec.LookPath("sh")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sh, _ = filepath.Abs(sh)
+ want := []string{"/usr/bin/env", sh}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("interpreters = %#v, want %#v", got, want)
+ }
+}
+
+func TestShebangExecutablesSkipsEnvOptionOperands(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "script")
+ if err := os.WriteFile(
+ path, []byte("#!/usr/bin/env -u UNUSED -C /tmp sh -eu\n"), 0o700,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ got := shebangExecutables(path)
+ if len(got) != 2 || filepath.Base(got[1]) != "sh" {
+ t.Fatalf("interpreters = %#v, want env and sh", got)
+ }
+}
+
+func TestShebangExecutablesHandlesAttachedSplitString(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "script")
+ if err := os.WriteFile(
+ path, []byte("#!/usr/bin/env -a custom --split-string=sh -eu\n"), 0o700,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ got := shebangExecutables(path)
+ if len(got) != 2 || filepath.Base(got[1]) != "sh" {
+ t.Fatalf("interpreters = %#v, want env and sh", got)
+ }
+}
diff --git a/internal/platform/sampler_darwin.go b/internal/platform/sampler_darwin.go
index e484419..394fe2e 100644
--- a/internal/platform/sampler_darwin.go
+++ b/internal/platform/sampler_darwin.go
@@ -13,34 +13,52 @@ static int sample_process(pid_t pid, struct proc_taskinfo *task,
}
if (proc_pid_rusage(pid, RUSAGE_INFO_V4, (rusage_info_t *)usage) != 0) {
usage->ri_phys_footprint = 0;
+ return 1;
}
- return 1;
+ return 2;
}
*/
import "C"
import (
- "time"
+ "syscall"
"unsafe"
)
-func DefaultInterval() time.Duration { return 10 * time.Millisecond }
+// GroupSampler owns the scratch buffers for one monitoring goroutine, so the
+// per-tick sampling loop does not generate garbage while a run is measured.
+// It is not safe for concurrent use.
+type GroupSampler struct {
+ pids []C.pid_t
+}
+
+func NewGroupSampler() *GroupSampler { return &GroupSampler{} }
-func SampleImmediately() bool { return true }
+func SampleProcessGroup(rootPID int) (Metrics, bool) {
+ return NewGroupSampler().Sample(rootPID)
+}
-func SampleTree(rootPID int) (Metrics, bool) {
- pids := processGroupPIDs(rootPID)
+func (sampler *GroupSampler) Sample(rootPID int) (Metrics, bool) {
+ pids := sampler.processGroupPIDs(rootPID)
var total Metrics
foundRoot := false
+ physicalFootprintValid := true
for _, pid := range pids {
if pid <= 0 {
continue
}
var task C.struct_proc_taskinfo
var usage C.struct_rusage_info_v4
- if C.sample_process(pid, &task, &usage) == 0 {
+ sampled := C.sample_process(pid, &task, &usage)
+ if sampled == 0 {
+ if syscall.Kill(int(pid), 0) == nil {
+ physicalFootprintValid = false
+ }
continue
}
+ if sampled < 2 {
+ physicalFootprintValid = false
+ }
if int(pid) == rootPID {
foundRoot = true
}
@@ -50,23 +68,34 @@ func SampleTree(rootPID int) (Metrics, bool) {
total.Processes++
total.Threads += uint64(task.pti_threadnum)
}
+ total.PhysicalFootprintValid = total.Processes > 0 && physicalFootprintValid
+ total.PhysicalFootprintInvalid = !total.PhysicalFootprintValid &&
+ (foundRoot || syscall.Kill(rootPID, 0) == nil)
return total, foundRoot
}
-func processGroupPIDs(groupID int) []C.pid_t {
+func (sampler *GroupSampler) processGroupPIDs(groupID int) []C.pid_t {
bytes := C.proc_listpgrppids(C.pid_t(groupID), nil, 0)
if bytes <= 0 {
return nil
}
- // Leave room for processes created between the sizing and data calls.
- count := int(bytes) + 16
- pids := make([]C.pid_t, count)
- count = int(C.proc_listpgrppids(
- C.pid_t(groupID), unsafe.Pointer(&pids[0]), C.int(len(pids))*C.sizeof_pid_t,
- ))
- if count <= 0 {
- return nil
+ // Leave room for process churn, and retry if the result fills the buffer.
+ capacity := int(bytes) + 16
+ for range 3 {
+ if cap(sampler.pids) < capacity {
+ sampler.pids = make([]C.pid_t, capacity)
+ }
+ pids := sampler.pids[:capacity]
+ count := int(C.proc_listpgrppids(
+ C.pid_t(groupID), unsafe.Pointer(&pids[0]), C.int(len(pids))*C.sizeof_pid_t,
+ ))
+ if count <= 0 {
+ return nil
+ }
+ if count < len(pids) {
+ return pids[:count]
+ }
+ capacity *= 2
}
- count = min(count, len(pids))
- return pids[:count]
+ return nil
}
diff --git a/internal/platform/sampler_darwin_test.go b/internal/platform/sampler_darwin_test.go
index efcff2a..5636b10 100644
--- a/internal/platform/sampler_darwin_test.go
+++ b/internal/platform/sampler_darwin_test.go
@@ -6,7 +6,7 @@ import (
"testing"
)
-func TestSampleTreeReadsProcessGroup(t *testing.T) {
+func TestSampleProcessGroupReadsProcessGroup(t *testing.T) {
cmd := exec.Command("/bin/sleep", "1")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
@@ -17,7 +17,7 @@ func TestSampleTreeReadsProcessGroup(t *testing.T) {
_ = cmd.Wait()
}()
- metrics, valid := SampleTree(cmd.Process.Pid)
+ metrics, valid := SampleProcessGroup(cmd.Process.Pid)
if !valid {
t.Fatal("process group leader was not sampled")
}
diff --git a/internal/platform/sampler_linux.go b/internal/platform/sampler_linux.go
index a5c1d98..0b1e719 100644
--- a/internal/platform/sampler_linux.go
+++ b/internal/platform/sampler_linux.go
@@ -1,97 +1,270 @@
package platform
import (
+ "bytes"
"errors"
"io"
+ "math"
"os"
"path/filepath"
+ "runtime"
"strconv"
- "strings"
- "time"
-)
+ "unsafe"
-func DefaultInterval() time.Duration { return 10 * time.Millisecond }
+ "golang.org/x/sys/unix"
+)
-func SampleImmediately() bool { return true }
+// GroupSampler owns the scratch buffers for one monitoring goroutine, so the
+// per-tick sampling loop does not generate garbage while a run is measured.
+// It is not safe for concurrent use.
+type GroupSampler struct {
+ statBuffer []byte
+ directoryBuffer []byte
+}
-func SampleTree(rootPID int) (Metrics, bool) {
- seen := make(map[int]bool)
- queue := []int{rootPID}
- var total Metrics
- for len(queue) > 0 {
- pid := queue[0]
- queue = queue[1:]
- if seen[pid] {
- continue
- }
- seen[pid] = true
- record, err := readProcess(pid)
- if err != nil {
- if pid == rootPID {
- return Metrics{}, false
- }
- continue
- }
- total.ResidentBytes += record.ResidentBytes
- total.VirtualBytes += record.VirtualBytes
- total.Processes++
- total.Threads += record.Threads
- total.FileDescriptors += record.FileDescriptors
- queue = append(queue, readChildren(pid)...)
+func NewGroupSampler() *GroupSampler {
+ return &GroupSampler{
+ statBuffer: make([]byte, 4096),
+ directoryBuffer: make([]byte, 32*1024),
}
- return total, true
}
-func readChildren(pid int) []int {
- taskDirectory := filepath.Join("/proc", strconv.Itoa(pid), "task")
- tasks, err := os.ReadDir(taskDirectory)
+func SampleProcessGroup(rootPID int) (Metrics, bool) {
+ return NewGroupSampler().Sample(rootPID)
+}
+
+func (sampler *GroupSampler) Sample(rootPID int) (Metrics, bool) {
+ directory, err := unix.Open("/proc", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
- return nil
+ return Metrics{}, false
}
- seen := make(map[int]bool)
- children := make([]int, 0, 2)
- for _, task := range tasks {
- data, readErr := os.ReadFile(filepath.Join(taskDirectory, task.Name(), "children"))
+ defer unix.Close(directory)
+ var total Metrics
+ rootFound := false
+ for {
+ count, readErr := unix.ReadDirent(directory, sampler.directoryBuffer)
if readErr != nil {
- continue
+ return Metrics{}, false
}
- for _, field := range strings.Fields(string(data)) {
- child, parseErr := strconv.Atoi(field)
- if parseErr == nil && !seen[child] {
- seen[child] = true
- children = append(children, child)
+ if count == 0 {
+ return total, rootFound
+ }
+ validDirents := scanProcDirents(sampler.directoryBuffer[:count], func(pid int) {
+ record, err := readProcessAt(
+ directory, pid, rootPID, sampler.statBuffer,
+ )
+ if err != nil || record.GroupID != rootPID {
+ return
+ }
+ if record.PID == rootPID {
+ rootFound = true
}
+ total.ResidentBytes += record.ResidentBytes
+ total.VirtualBytes += record.VirtualBytes
+ total.Processes++
+ total.Threads += record.Threads
+ total.FileDescriptors += record.FileDescriptors
+ })
+ if !validDirents {
+ return Metrics{}, false
}
}
- return children
}
-func readProcess(pid int) (Process, error) {
+func readProcess(pid int) (process, error) {
base := filepath.Join("/proc", strconv.Itoa(pid))
data, err := os.ReadFile(filepath.Join(base, "stat"))
if err != nil {
- return Process{}, err
+ return process{}, err
}
- closeParen := strings.LastIndexByte(string(data), ')')
+ record, err := parseProcessStat(data, pid)
+ if err != nil {
+ return process{}, err
+ }
+ record.FileDescriptors = countDirectory(filepath.Join(base, "fd"))
+ return record, nil
+}
+
+func readProcessAt(
+ procFD, pid, groupID int, buffer []byte,
+) (process, error) {
+ file, err := openProcessStat(procFD, pid)
+ if err != nil {
+ return process{}, err
+ }
+ count, readErr := unix.Read(file, buffer)
+ _ = unix.Close(file)
+ if readErr != nil {
+ return process{}, readErr
+ }
+ if count == len(buffer) {
+ return process{}, errors.New("process stat exceeds buffer")
+ }
+ record, err := parseProcessStat(buffer[:count], pid)
+ if err != nil {
+ return process{}, err
+ }
+ if record.GroupID == groupID {
+ record.FileDescriptors = countDirectory(
+ "/proc/" + strconv.Itoa(pid) + "/fd",
+ )
+ }
+ return record, nil
+}
+
+func openProcessStat(procFD, pid int) (int, error) {
+ var storage [32]byte
+ path := strconv.AppendInt(storage[:0], int64(pid), 10)
+ path = append(path, '/', 's', 't', 'a', 't', 0)
+ file, _, errno := unix.Syscall6(
+ unix.SYS_OPENAT,
+ uintptr(procFD), uintptr(unsafe.Pointer(&path[0])),
+ uintptr(unix.O_RDONLY|unix.O_CLOEXEC), 0, 0, 0,
+ )
+ runtime.KeepAlive(path)
+ if errno != 0 {
+ return -1, errno
+ }
+ return int(file), nil
+}
+
+func parseProcessStat(data []byte, pid int) (process, error) {
+ closeParen := bytes.LastIndexByte(data, ')')
if closeParen < 0 || closeParen+2 >= len(data) {
- return Process{}, errors.New("malformed process stat")
- }
- fields := strings.Fields(string(data[closeParen+2:]))
- if len(fields) < 22 {
- return Process{}, errors.New("short process stat")
- }
- ppid, _ := strconv.Atoi(fields[1])
- threads, _ := strconv.ParseUint(fields[17], 10, 64)
- virtual, _ := strconv.ParseUint(fields[20], 10, 64)
- rssPages, _ := strconv.ParseInt(fields[21], 10, 64)
- if rssPages < 0 {
- rssPages = 0
- }
- return Process{
- PID: pid, PPID: ppid, Threads: threads, VirtualBytes: virtual,
- ResidentBytes: uint64(rssPages) * uint64(os.Getpagesize()),
- FileDescriptors: countDirectory(filepath.Join(base, "fd")),
- }, nil
+ return process{}, errors.New("malformed process stat")
+ }
+ fields := data[closeParen+2:]
+ var result process
+ result.PID = pid
+ field := 0
+ for offset := 0; offset < len(fields); {
+ for offset < len(fields) && (fields[offset] == ' ' || fields[offset] == '\n') {
+ offset++
+ }
+ start := offset
+ for offset < len(fields) && fields[offset] != ' ' && fields[offset] != '\n' {
+ offset++
+ }
+ if start == offset {
+ break
+ }
+ value := fields[start:offset]
+ var err error
+ switch field {
+ case 1:
+ result.PPID, err = parsePositiveInt(value)
+ case 2:
+ result.GroupID, err = parsePositiveInt(value)
+ case 17:
+ result.Threads, err = parseUint(value)
+ case 20:
+ result.VirtualBytes, err = parseUint(value)
+ case 21:
+ var pages int64
+ pages, err = parseInt64(value)
+ if pages > 0 {
+ pageSize := uint64(os.Getpagesize())
+ if uint64(pages) > math.MaxUint64/pageSize {
+ err = errors.New("resident size overflows")
+ } else {
+ result.ResidentBytes = uint64(pages) * pageSize
+ }
+ }
+ }
+ if err != nil {
+ return process{}, err
+ }
+ field++
+ if field > 21 {
+ return result, nil
+ }
+ }
+ return process{}, errors.New("short process stat")
+}
+
+func scanProcDirents(data []byte, visit func(int)) bool {
+ // linux_dirent64 stores d_reclen at byte 16 and d_name at byte 19.
+ const recordHeader = 19
+ for offset := 0; offset < len(data); {
+ if len(data)-offset < recordHeader {
+ return false
+ }
+ recordLength := int(data[offset+16]) | int(data[offset+17])<<8
+ if recordLength < recordHeader || recordLength%8 != 0 ||
+ recordLength > len(data)-offset {
+ return false
+ }
+ name := data[offset+recordHeader : offset+recordLength]
+ nameLength := bytes.IndexByte(name, 0)
+ if nameLength < 0 {
+ return false
+ }
+ if pid, ok := parsePIDBytes(name[:nameLength]); ok {
+ visit(pid)
+ }
+ offset += recordLength
+ }
+ return true
+}
+
+func parsePIDBytes(value []byte) (int, bool) {
+ if len(value) == 0 {
+ return 0, false
+ }
+ result := 0
+ for _, character := range value {
+ digit := character - '0'
+ if digit > 9 || result > (math.MaxInt-int(digit))/10 {
+ return 0, false
+ }
+ result = result*10 + int(digit)
+ }
+ return result, result > 0
+}
+
+func parsePositiveInt(value []byte) (int, error) {
+ parsed, err := parseUint(value)
+ if err != nil || parsed > uint64(math.MaxInt) {
+ return 0, errors.New("invalid process integer")
+ }
+ return int(parsed), nil
+}
+
+func parseUint(value []byte) (uint64, error) {
+ if len(value) == 0 {
+ return 0, errors.New("empty process integer")
+ }
+ var result uint64
+ for _, character := range value {
+ digit := character - '0'
+ if digit > 9 || result > (math.MaxUint64-uint64(digit))/10 {
+ return 0, errors.New("invalid process integer")
+ }
+ result = result*10 + uint64(digit)
+ }
+ return result, nil
+}
+
+func parseInt64(value []byte) (int64, error) {
+ if len(value) == 0 {
+ return 0, errors.New("empty process integer")
+ }
+ negative := value[0] == '-'
+ if negative {
+ value = value[1:]
+ }
+ parsed, err := parseUint(value)
+ if err != nil || (!negative && parsed > math.MaxInt64) ||
+ (negative && parsed > uint64(math.MaxInt64)+1) {
+ return 0, errors.New("invalid process integer")
+ }
+ if negative {
+ if parsed == uint64(math.MaxInt64)+1 {
+ return math.MinInt64, nil
+ }
+ return -int64(parsed), nil
+ }
+ return int64(parsed), nil
}
func countDirectory(path string) uint64 {
diff --git a/internal/platform/sampler_linux_bench_test.go b/internal/platform/sampler_linux_bench_test.go
new file mode 100644
index 0000000..9519172
--- /dev/null
+++ b/internal/platform/sampler_linux_bench_test.go
@@ -0,0 +1,26 @@
+package platform
+
+import (
+ "os/exec"
+ "syscall"
+ "testing"
+)
+
+func BenchmarkSampleProcessGroup(b *testing.B) {
+ command := exec.Command("sleep", "60")
+ command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ if err := command.Start(); err != nil {
+ b.Fatal(err)
+ }
+ b.Cleanup(func() {
+ _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
+ _ = command.Wait()
+ })
+ b.ReportAllocs()
+ b.ResetTimer()
+ for range b.N {
+ if _, valid := SampleProcessGroup(command.Process.Pid); !valid {
+ b.Fatal("sampled process disappeared")
+ }
+ }
+}
diff --git a/internal/platform/sampler_linux_test.go b/internal/platform/sampler_linux_test.go
index 493042e..035daa7 100644
--- a/internal/platform/sampler_linux_test.go
+++ b/internal/platform/sampler_linux_test.go
@@ -3,9 +3,15 @@
package platform
import (
+ "fmt"
"os"
+ "os/exec"
"path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
"testing"
+ "time"
)
func TestCountDirectoryDoesNotRequireSortedEntries(t *testing.T) {
@@ -21,7 +27,105 @@ func TestCountDirectoryDoesNotRequireSortedEntries(t *testing.T) {
}
func TestMissingRootIsNotAZeroSample(t *testing.T) {
- if _, valid := SampleTree(1 << 30); valid {
+ if _, valid := SampleProcessGroup(1 << 30); valid {
t.Fatal("missing root process should produce an invalid sample")
}
}
+
+func TestScanProcDirentsVisitsOnlyNumericNames(t *testing.T) {
+ var data []byte
+ for _, name := range []string{".", "self", "123", "98765"} {
+ length := (19 + len(name) + 1 + 7) &^ 7
+ record := make([]byte, length)
+ record[16], record[17] = byte(length), byte(length>>8)
+ copy(record[19:], name)
+ data = append(data, record...)
+ }
+ var pids []int
+ if !scanProcDirents(data, func(pid int) { pids = append(pids, pid) }) {
+ t.Fatal("valid dirents rejected")
+ }
+ if fmt.Sprint(pids) != "[123 98765]" {
+ t.Fatalf("PIDs = %v", pids)
+ }
+ if scanProcDirents(data[:len(data)-1], func(int) {}) {
+ t.Fatal("truncated dirent should be rejected")
+ }
+ missingNUL := make([]byte, 24)
+ missingNUL[16] = byte(len(missingNUL))
+ copy(missingNUL[19:], "12345")
+ if scanProcDirents(missingNUL, func(int) {}) {
+ t.Fatal("dirent without a NUL-terminated name should be rejected")
+ }
+ misaligned := make([]byte, 23)
+ misaligned[16] = byte(len(misaligned))
+ if scanProcDirents(misaligned, func(int) {}) {
+ t.Fatal("unaligned dirent should be rejected")
+ }
+}
+
+func TestSampleProcessGroupIncludesReparentedMembers(t *testing.T) {
+ childFile := filepath.Join(t.TempDir(), "child-pid")
+ cmd := exec.Command(
+ "sh", "-c",
+ "(sleep 30 & printf '%s' $! > \"$1\"); exec sleep 30",
+ "sh", childFile,
+ )
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ _ = cmd.Wait()
+ })
+
+ var child process
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ data, err := os.ReadFile(childFile)
+ if err == nil {
+ childPID, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
+ if parseErr == nil {
+ child, err = readProcess(childPID)
+ if err == nil && child.PPID != cmd.Process.Pid {
+ break
+ }
+ }
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if child.PID == 0 || child.PPID == cmd.Process.Pid || child.GroupID != cmd.Process.Pid {
+ t.Fatalf("child process was not reparented in group: %+v", child)
+ }
+ metrics, valid := SampleProcessGroup(cmd.Process.Pid)
+ if !valid || metrics.Processes < 2 {
+ t.Fatalf("group metrics = %+v, valid = %v", metrics, valid)
+ }
+}
+
+func TestParseProcessStatValidatesRequiredNumbers(t *testing.T) {
+ fields := make([]string, 22)
+ for index := range fields {
+ fields[index] = "0"
+ }
+ fields[0], fields[1], fields[2] = "S", "42", "123"
+ fields[17], fields[20], fields[21] = "7", "4096", "2"
+ data := []byte(fmt.Sprintf("99 (name with ) parenthesis) %s", strings.Join(fields, " ")))
+ process, err := parseProcessStat(data, 99)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if process.PPID != 42 || process.GroupID != 123 || process.Threads != 7 ||
+ process.VirtualBytes != 4096 || process.ResidentBytes == 0 {
+ t.Fatalf("parsed process = %+v", process)
+ }
+ for _, field := range []int{1, 2, 17, 20, 21} {
+ invalid := append([]string(nil), fields...)
+ invalid[field] = "invalid"
+ data = []byte(fmt.Sprintf("99 (name) %s", strings.Join(invalid, " ")))
+ if _, err := parseProcessStat(data, 99); err == nil {
+ t.Fatalf("field %d should reject malformed input", field)
+ }
+ }
+}
diff --git a/internal/platform/types.go b/internal/platform/types.go
index aa90041..9e8f710 100644
--- a/internal/platform/types.go
+++ b/internal/platform/types.go
@@ -1,20 +1,22 @@
package platform
+import "time"
+
+func DefaultInterval() time.Duration { return 10 * time.Millisecond }
+
type Metrics struct {
- ResidentBytes uint64
- PhysicalFootprintBytes uint64
- VirtualBytes uint64
- Processes uint64
- Threads uint64
- FileDescriptors uint64
- ResidentByteSamples uint64
- SampleCount uint64
- ResidentByteSeconds float64
- SampleCoverageSeconds float64
+ ResidentBytes uint64
+ PhysicalFootprintBytes uint64
+ PhysicalFootprintValid bool
+ PhysicalFootprintInvalid bool
+ VirtualBytes uint64
+ Processes uint64
+ Threads uint64
+ FileDescriptors uint64
}
-type Process struct {
- PID, PPID int
+type process struct {
+ PID, PPID, GroupID int
ResidentBytes, VirtualBytes uint64
Threads, FileDescriptors uint64
}
diff --git a/internal/report/chart_artifacts.go b/internal/report/chart_artifacts.go
index fe822c9..4e10614 100644
--- a/internal/report/chart_artifacts.go
+++ b/internal/report/chart_artifacts.go
@@ -4,8 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
-
- "github.com/shellcell/snailrace/internal/model"
)
type ChartArtifact struct {
@@ -15,17 +13,20 @@ type ChartArtifact struct {
Filename string
}
-func WriteChartFiles(
- directory string,
- report model.Report,
- includeCommands bool,
+func (renderer *Renderer) WriteChartFiles(
+ directory string, includeCommands bool,
) ([]ChartArtifact, error) {
+ report := renderer.displayReport
if err := os.MkdirAll(directory, 0o755); err != nil {
return nil, err
}
- charts := reportCharts(report)
+ charts := renderer.reportCharts()
if includeCommands {
- charts = append([]svgChart{commandLegendChart(report)}, charts...)
+ prefix := []svgChart{commandLegendChart(report)}
+ if failures := failureChart(report); failures.height > 0 {
+ prefix = append(prefix, failures)
+ }
+ charts = append(prefix, charts...)
}
artifacts := make([]ChartArtifact, 0, len(charts))
for index, chart := range charts {
diff --git a/internal/report/chart_commands.go b/internal/report/chart_commands.go
index 94dea41..2a23db7 100644
--- a/internal/report/chart_commands.go
+++ b/internal/report/chart_commands.go
@@ -20,7 +20,7 @@ func commandLegendChart(report model.Report) svgChart {
y := height
fmt.Fprintf(
&body, `%d. %s`,
- y, toolColor(report, index), index+1,
+ y, toolColor(index), index+1,
html.EscapeString(reportToolLabel(report, index)),
)
for _, line := range lines {
diff --git a/internal/report/chart_delta.go b/internal/report/chart_delta.go
index 6dfcfa1..6c433e4 100644
--- a/internal/report/chart_delta.go
+++ b/internal/report/chart_delta.go
@@ -5,8 +5,6 @@ import (
"html"
"math"
"strings"
-
- "github.com/shellcell/snailrace/internal/model"
)
type forestRow struct {
@@ -18,33 +16,46 @@ type forestRow struct {
baseline bool
}
-func deltaForestChart(report model.Report, group chartGroup) svgChart {
+func deltaForestChart(renderer *Renderer, group chartGroup) svgChart {
+ report := renderer.displayReport
baselinePosition := baselineIndex(report)
baseline := report.Benchmarks[baselinePosition]
rows := make([]forestRow, 0)
maximum := 0.0
for _, metric := range group.metrics {
- row := metricRows[metric.row]
- if !available(row, report.Host.OS) {
+ row := metric
+ if !availableFor(row, report.Host.OS, baseline.Summary) {
rows = append(rows, forestRow{
- label: metric.name, status: "N/A on " + report.Host.OS,
- class: "neutral", identity: toolColor(report, baselinePosition),
+ label: metric.chartName, status: "N/A on " + report.Host.OS,
+ class: "neutral", identity: toolColor(baselinePosition),
baseline: true,
})
continue
}
- baseStats := metric.stats(baseline)
+ baseStats := metric.stats(baseline.Summary)
baseMean := baseStats.Mean
+ if baseStats.N == 0 || !finiteNumber(baseMean) {
+ rows = append(rows, forestRow{
+ label: metric.chartName, status: "N/A", class: "neutral",
+ identity: toolColor(baselinePosition), baseline: true,
+ })
+ continue
+ }
baselineRow := forestRow{
- label: metric.name + " Β· " + reportToolLabel(report, baselinePosition),
+ label: metric.chartName + " Β· " + reportToolLabel(report, baselinePosition),
status: metric.format(baseMean) + " Β· baseline",
- class: "neutral", identity: toolColor(report, baselinePosition),
+ class: "neutral", identity: toolColor(baselinePosition),
baseline: true,
}
if baseMean != 0 && baseStats.CI95Valid {
baselineRow.low, baselineRow.high = percentInterval(baseStats.CI95Low, baseStats.CI95High, baseMean)
- baselineRow.interval = true
- maximum = math.Max(maximum, math.Max(math.Abs(baselineRow.low), math.Abs(baselineRow.high)))
+ baselineRow.interval = finiteNumber(baselineRow.low) &&
+ finiteNumber(baselineRow.high)
+ if baselineRow.interval {
+ maximum = math.Max(
+ maximum, math.Max(math.Abs(baselineRow.low), math.Abs(baselineRow.high)),
+ )
+ }
}
rows = append(rows, baselineRow)
if baseMean == 0 {
@@ -52,14 +63,19 @@ func deltaForestChart(report model.Report, group chartGroup) svgChart {
if index == baselinePosition {
continue
}
- value := metric.stats(candidate).Mean
- delta := compareMetric(
- baseline, candidate, row, report.Config.IntervalMS/1000,
- )
+ if !availableFor(row, report.Host.OS, candidate.Summary) {
+ rows = append(rows, forestRow{
+ label: metric.chartName + " Β· " + candidate.Tool.Name,
+ status: "N/A", class: "neutral", identity: toolColor(index),
+ })
+ continue
+ }
+ value := metric.stats(candidate.Summary).Mean
+ delta := renderer.comparison(index, metric.id)
rows = append(rows, forestRow{
- label: metric.name + " Β· " + candidate.Tool.Name,
+ label: metric.chartName + " Β· " + candidate.Tool.Name,
status: metric.format(value) + " Β· Ξ% n/a Β· " + delta.status,
- class: delta.class, identity: toolColor(report, index),
+ class: delta.class, identity: toolColor(index),
baseline: true,
})
}
@@ -69,18 +85,32 @@ func deltaForestChart(report model.Report, group chartGroup) svgChart {
if index == baselinePosition {
continue
}
- delta := compareMetric(
- baseline, candidate, row, report.Config.IntervalMS/1000,
- )
- low := delta.difference.CI95Low / baseMean * 100
- high := delta.difference.CI95High / baseMean * 100
- maximum = math.Max(maximum, math.Max(math.Abs(low), math.Abs(high)))
- maximum = math.Max(maximum, math.Abs(delta.percent))
+ if !availableFor(row, report.Host.OS, candidate.Summary) {
+ rows = append(rows, forestRow{
+ label: metric.chartName + " Β· " + candidate.Tool.Name,
+ status: "N/A", class: "neutral", identity: toolColor(index),
+ })
+ continue
+ }
+ delta := renderer.comparison(index, metric.id)
+ low, high := 0.0, 0.0
+ interval := delta.difference.CI95Valid
+ if interval {
+ low = delta.difference.CI95Low / baseMean * 100
+ high = delta.difference.CI95High / baseMean * 100
+ interval = finiteNumber(low) && finiteNumber(high)
+ }
+ if interval {
+ maximum = math.Max(maximum, math.Max(math.Abs(low), math.Abs(high)))
+ }
+ if delta.percentAvailable {
+ maximum = math.Max(maximum, math.Abs(delta.percent))
+ }
rows = append(rows, forestRow{
- label: metric.name + " Β· " + candidate.Tool.Name,
+ label: metric.chartName + " Β· " + candidate.Tool.Name,
point: delta.percent, low: low, high: high,
status: delta.status, class: delta.class,
- identity: toolColor(report, index), interval: delta.difference.CI95Valid,
+ identity: toolColor(index), interval: interval,
})
}
}
diff --git a/internal/report/chart_distribution.go b/internal/report/chart_distribution.go
index 4022c85..2a2c1af 100644
--- a/internal/report/chart_distribution.go
+++ b/internal/report/chart_distribution.go
@@ -10,14 +10,24 @@ import (
)
func absoluteDistributionChart(report model.Report, metric chartMetric) svgChart {
- if !available(metricRows[metric.row], report.Host.OS) {
- return unavailableChart("RUN DISTRIBUTION Β· "+metric.name, report.Host.OS)
+ if !available(metric, report.Host.OS) {
+ return unavailableChart("RUN DISTRIBUTION Β· "+metric.chartName, report.Host.OS)
+ }
+ for _, benchmark := range report.Benchmarks {
+ if !availableFor(metric, report.Host.OS, benchmark.Summary) {
+ return unavailableChart("RUN DISTRIBUTION Β· "+metric.chartName, "metric unavailable")
+ }
}
minimum, maximum := 0.0, 0.0
for _, benchmark := range report.Benchmarks {
- stats := metric.stats(benchmark)
+ stats := metric.stats(benchmark.Summary)
+ if stats.N == 0 || !finiteNumber(stats.Min) ||
+ !finiteNumber(stats.Max) || !finiteNumber(stats.Mean) {
+ return svgChart{}
+ }
maximum = math.Max(maximum, stats.Max)
- if stats.CI95Valid {
+ if stats.CI95Valid && finiteNumber(stats.CI95Low) &&
+ finiteNumber(stats.CI95High) {
minimum = math.Min(minimum, stats.CI95Low)
maximum = math.Max(maximum, stats.CI95High)
}
@@ -31,13 +41,18 @@ func absoluteDistributionChart(report model.Report, metric chartMetric) svgChart
return left + (value-minimum)/(maximum-minimum)*plotWidth
}
var body strings.Builder
+ points := 0
+ for _, benchmark := range report.Benchmarks {
+ points += len(benchmark.Runs)
+ }
+ body.Grow(1024 + len(report.Benchmarks)*384 + points*96)
body.WriteString(svgChartStyle)
fmt.Fprintf(
&body,
``+
`RUN DISTRIBUTION Β· %s`+
`dots = runs Β· diamond = mean Β· whisker = mean 95%% CI`,
- html.EscapeString(metric.name),
+ html.EscapeString(metric.chartName),
)
fmt.Fprintf(
&body,
@@ -45,10 +60,9 @@ func absoluteDistributionChart(report model.Report, metric chartMetric) svgChart
`%s`,
left, metric.format(minimum), left+plotWidth, metric.format(maximum),
)
- baseline := baselineIndex(report)
for index, benchmark := range report.Benchmarks {
y := 76 + index*rowHeight
- color := chartColor(index, baseline)
+ color := toolColor(index)
fmt.Fprintf(
&body,
`%s`+
@@ -57,16 +71,22 @@ func absoluteDistributionChart(report model.Report, metric chartMetric) svgChart
left, y-4, left+plotWidth,
)
for runIndex, run := range benchmark.Runs {
+ if !chartRunAvailable(metric, run) {
+ continue
+ }
jitter := (runIndex%3 - 1) * 5
- fmt.Fprintf(
- &body, ``,
- position(metric.run(run)), y-4+jitter, color,
- )
+ body.WriteString(``)
}
- stats := metric.stats(benchmark)
+ stats := metric.stats(benchmark.Summary)
mean := position(stats.Mean)
- if stats.CI95Valid {
+ if stats.CI95Valid && finiteNumber(stats.CI95Low) &&
+ finiteNumber(stats.CI95High) {
low, high := position(stats.CI95Low), position(stats.CI95High)
fmt.Fprintf(
&body,
@@ -87,8 +107,8 @@ func absoluteDistributionChart(report model.Report, metric chartMetric) svgChart
)
}
return svgChart{
- kind: "distribution", title: metric.name, slug: chartSlug(metric.name),
- description: distributionChartDescription(metric.name),
+ kind: "distribution", title: metric.chartName, slug: chartSlug(metric.chartName),
+ description: distributionChartDescription(metric.chartName),
body: body.String(), height: height,
}
}
diff --git a/internal/report/chart_metrics.go b/internal/report/chart_metrics.go
index f8cc921..fb78043 100644
--- a/internal/report/chart_metrics.go
+++ b/internal/report/chart_metrics.go
@@ -2,22 +2,26 @@ package report
import "github.com/shellcell/snailrace/internal/model"
-func reportCharts(report model.Report) []svgChart {
- groups := chartGroups(report)
+func buildReportCharts(renderer *Renderer) []svgChart {
+ report := renderer.displayReport
+ if len(report.Benchmarks) == 0 {
+ return nil
+ }
+ groups := renderer.chartGroups
var charts []svgChart
if len(report.Benchmarks) > 1 {
- charts = append(charts, rankingCharts(report)...)
+ charts = append(charts, rankingCharts(report, renderer.ranking)...)
for _, group := range groups {
for _, metric := range group.metrics {
- chart := deltaForestChart(report, chartGroup{
- name: metric.name, metrics: []chartMetric{metric},
+ chart := deltaForestChart(renderer, chartGroup{
+ name: metric.chartName, metrics: []chartMetric{metric},
})
if chart.height > 0 {
charts = append(charts, chart)
}
}
}
- charts = append(charts, tradeoffCharts(report)...)
+ charts = append(charts, tradeoffCharts(report, renderer.ranking)...)
}
for _, group := range groups {
for _, metric := range group.metrics {
@@ -34,64 +38,39 @@ func reportCharts(report model.Report) []svgChart {
); chart.height > 0 {
charts = append(charts, chart)
}
- charts = append(charts, measurementTrendCharts(report)...)
+ charts = append(charts, measurementTrendCharts(report, groups)...)
return charts
}
func chartGroups(report model.Report) []chartGroup {
- metrics := chartMetricDefinitions()
groups := []chartGroup{
- {"PERFORMANCE", metrics[0:1]},
- {"CPU COST", metrics[1:5]},
- {"MEMORY COST", metrics[5:10]},
- {"PROCESS STRUCTURE", metrics[10:13]},
+ {"PERFORMANCE", chartMetrics(metricWall)},
+ {"CPU COST", chartMetrics(
+ metricCPUTotal, metricCPUUser, metricCPUSystem, metricAverageCPU,
+ )},
+ {"MEMORY COST", chartMetrics(
+ metricMeanResident, metricPeakResident, metricOSMaxRSS,
+ metricPhysicalFootprint, metricPeakVirtual,
+ )},
+ {"PROCESS STRUCTURE", chartMetrics(
+ metricPeakProcesses, metricPeakThreads, metricPeakFDs,
+ )},
}
- if report.Config.Mode == "tui" && report.Config.DurationSeconds > 0 {
+ if report.Config.FixedDurationTUI() {
groups = groups[1:]
}
return groups
}
-func chartMetricDefinitions() []chartMetric {
- return []chartMetric{
- {"Wall time", formatDuration,
- func(b model.Benchmark) model.Stats { return b.Summary.WallSeconds },
- func(r model.Run) float64 { return r.WallSeconds }, 0},
- {"Total CPU", formatDuration,
- func(b model.Benchmark) model.Stats { return b.Summary.CPUTotalSeconds },
- func(r model.Run) float64 { return r.CPUUserSeconds + r.CPUSystemSeconds }, 1},
- {"User CPU", formatDuration,
- func(b model.Benchmark) model.Stats { return b.Summary.CPUUserSeconds },
- func(r model.Run) float64 { return r.CPUUserSeconds }, 2},
- {"System CPU", formatDuration,
- func(b model.Benchmark) model.Stats { return b.Summary.CPUSystemSeconds },
- func(r model.Run) float64 { return r.CPUSystemSeconds }, 3},
- {"Average CPU", formatPercent,
- func(b model.Benchmark) model.Stats { return b.Summary.AverageCPUPercent },
- func(r model.Run) float64 { return r.AverageCPUPercent }, 4},
- {"Mean RSS", formatBytes,
- func(b model.Benchmark) model.Stats { return b.Summary.MeanResidentBytes },
- func(r model.Run) float64 { return r.MeanResidentBytes }, 5},
- {"Peak RSS", formatBytes,
- func(b model.Benchmark) model.Stats { return b.Summary.PeakResidentBytes },
- func(r model.Run) float64 { return r.PeakResidentBytes }, 6},
- {"OS-reported max RSS", formatBytes,
- func(b model.Benchmark) model.Stats { return b.Summary.OSMaxRSSBytes },
- func(r model.Run) float64 { return r.OSMaxRSSBytes }, 7},
- {"Peak physical footprint", formatBytes,
- func(b model.Benchmark) model.Stats { return b.Summary.PhysicalFootprintStats() },
- func(r model.Run) float64 { return r.PeakPhysicalFootprintBytes }, 8},
- {"Peak virtual memory", formatBytes,
- func(b model.Benchmark) model.Stats { return b.Summary.PeakVirtualBytes },
- func(r model.Run) float64 { return r.PeakVirtualBytes }, 9},
- {"Peak processes", formatCount,
- func(b model.Benchmark) model.Stats { return b.Summary.PeakProcesses },
- func(r model.Run) float64 { return r.PeakProcesses }, 10},
- {"Peak threads", formatCount,
- func(b model.Benchmark) model.Stats { return b.Summary.PeakThreads },
- func(r model.Run) float64 { return r.PeakThreads }, 11},
- {"Peak FD references", formatCount,
- func(b model.Benchmark) model.Stats { return b.Summary.PeakFileDescriptors },
- func(r model.Run) float64 { return r.PeakFileDescriptors }, 12},
+func chartMetrics(ids ...metricID) []chartMetric {
+ metrics := make([]chartMetric, len(ids))
+ for index, id := range ids {
+ metrics[index] = metricCatalog[id]
}
+ return metrics
+}
+
+func chartRunAvailable(metric chartMetric, run model.Run) bool {
+ return (metric.id != metricPhysicalFootprint || run.PhysicalFootprintValid) &&
+ finiteNumber(metric.run(run))
}
diff --git a/internal/report/chart_ranking.go b/internal/report/chart_ranking.go
index 61dc8d1..f29477a 100644
--- a/internal/report/chart_ranking.go
+++ b/internal/report/chart_ranking.go
@@ -4,6 +4,7 @@ import (
"fmt"
"html"
"math"
+ "sort"
"strings"
"github.com/shellcell/snailrace/internal/model"
@@ -16,40 +17,43 @@ type rankingChartMetric struct {
rank func(rankingRow) int
}
-func rankingCharts(report model.Report) []svgChart {
- ranking := calculateRanking(report)
- metrics := []rankingChartMetric{
- {"BALANCED INDEX", formatScore,
- func(row rankingRow) float64 { return row.overallScore },
- func(row rankingRow) int { return row.overallRank }},
- {ranking.primaryLabel, ranking.primaryUnit,
- func(row rankingRow) float64 { return row.primaryValue },
- func(row rankingRow) int { return row.primaryRank }},
- }
- if !(report.Config.Mode == "tui" && report.Config.DurationSeconds > 0) {
+func rankingCharts(report model.Report, ranking rankingData) []svgChart {
+ if len(ranking.Rows) == 0 {
+ return nil
+ }
+ var metrics []rankingChartMetric
+ if ranking.Available {
+ metrics = append(metrics, rankingChartMetric{"BALANCED INDEX", formatScore,
+ func(row rankingRow) float64 { return row.OverallScore },
+ func(row rankingRow) int { return row.OverallRank }})
+ }
+ metrics = append(metrics, rankingChartMetric{ranking.primaryLabel, ranking.primaryUnit,
+ func(row rankingRow) float64 { return row.PrimaryValue },
+ func(row rankingRow) int { return row.PrimaryRank }})
+ if !report.Config.FixedDurationTUI() {
metrics = append(metrics, rankingChartMetric{
"CPU COST", formatDuration,
- func(row rankingRow) float64 { return row.cpuValue },
- func(row rankingRow) int { return row.cpuRank },
+ func(row rankingRow) float64 { return row.CPUValue },
+ func(row rankingRow) int { return row.CPURank },
})
}
- if ranking.ramPresent {
- ramRank := func(row rankingRow) int { return row.ramRank }
- if !ranking.ramAvailable {
+ if ranking.RAMPresent {
+ ramRank := func(row rankingRow) int { return row.RAMRank }
+ if !ranking.RAMAvailable {
// Sampling-limited RAM has no reliable score; rank descriptively by value.
- order := rankByValue(ranking.rows, func(row rankingRow) float64 { return row.ramValue })
- ramRank = func(row rankingRow) int { return order[row.benchmark] }
+ order := rankByValue(ranking.Rows, func(row rankingRow) float64 { return row.RAMValue })
+ ramRank = func(row rankingRow) int { return order[row.Benchmark] }
}
metrics = append(metrics, rankingChartMetric{
"RAM AGGREGATE", formatBytes,
- func(row rankingRow) float64 { return row.ramValue },
+ func(row rankingRow) float64 { return row.RAMValue },
ramRank,
})
}
metrics = append(metrics, rankingChartMetric{
"LINKED SIZE", formatBytes,
- func(row rankingRow) float64 { return row.footprintValue },
- func(row rankingRow) int { return row.footprintRank },
+ func(row rankingRow) float64 { return row.FootprintValue },
+ func(row rankingRow) int { return row.FootprintRank },
})
charts := make([]svgChart, 0, len(metrics))
for _, metric := range metrics {
@@ -60,14 +64,16 @@ func rankingCharts(report model.Report) []svgChart {
func rankByValue(rows []rankingRow, value func(rankingRow) float64) map[int]int {
result := make(map[int]int, len(rows))
- for _, row := range rows {
- rank := 1
- for _, other := range rows {
- if value(other) < value(row) {
- rank++
- }
+ ordered := append([]rankingRow(nil), rows...)
+ sort.SliceStable(ordered, func(left, right int) bool {
+ return value(ordered[left]) < value(ordered[right])
+ })
+ rank := 0
+ for position, row := range ordered {
+ if position == 0 || value(row) != value(ordered[position-1]) {
+ rank = position + 1
}
- result[row.benchmark] = rank
+ result[row.Benchmark] = rank
}
return result
}
@@ -79,17 +85,21 @@ func rankingBarChart(
) svgChart {
const left, plotWidth, rowHeight = 205, 355, 30
maximum := 0.0
- for _, row := range ranking.rows {
- maximum = math.Max(maximum, metric.value(row))
+ for _, row := range ranking.Rows {
+ value := metric.value(row)
+ if !finiteNumber(value) || value < 0 {
+ return svgChart{}
+ }
+ maximum = math.Max(maximum, value)
}
if maximum <= 0 {
maximum = 1
}
- height := 62 + len(ranking.rows)*rowHeight
+ height := 62 + len(ranking.Rows)*rowHeight
var body strings.Builder
body.WriteString(svgChartStyle)
subtitle := "descriptive point estimates Β· outline = category leader"
- if metric.name == "RAM AGGREGATE" && ranking.ramPresent && !ranking.ramAvailable {
+ if metric.name == "RAM AGGREGATE" && ranking.RAMPresent && !ranking.RAMAvailable {
subtitle = "sampling-limited Β· descriptive only Β· excluded from balanced index"
if ranking.samplingInterval != "" {
subtitle += " Β· lower -interval (now " + ranking.samplingInterval + ")"
@@ -102,11 +112,15 @@ func rankingBarChart(
`%s`,
html.EscapeString(metric.name), html.EscapeString(subtitle),
)
- for index, row := range ranking.rows {
+ rows := append([]rankingRow(nil), ranking.Rows...)
+ sort.SliceStable(rows, func(left, right int) bool {
+ return metric.rank(rows[left]) < metric.rank(rows[right])
+ })
+ for index, row := range rows {
y := 62 + index*rowHeight
value := metric.value(row)
width := value / maximum * plotWidth
- color := toolColor(report, row.benchmark)
+ color := toolColor(row.Benchmark)
outline := "none"
outlineWidth := 0
if metric.rank(row) == 1 {
@@ -120,7 +134,7 @@ func rankingBarChart(
`fill="%s" stroke="%s" stroke-width="%d"/>`+
`%s`,
y, color, metric.rank(row),
- html.EscapeString(clip(reportToolLabel(report, row.benchmark), 26)),
+ html.EscapeString(clip(reportToolLabel(report, row.Benchmark), 26)),
left, y-11, width, color, outline, outlineWidth, y, metric.format(value),
)
}
@@ -149,7 +163,7 @@ func rankingChartDescription(metric string, ranking rankingData) string {
"this ranking does not include uncertainty intervals."
case "RAM AGGREGATE":
caveat := ""
- if ranking.ramPresent && !ranking.ramAvailable {
+ if ranking.RAMPresent && !ranking.RAMAvailable {
caveat = " These values are sampling-limited (short runs or too few valid " +
"samples), so they are shown for reference but excluded from the balanced index."
if ranking.samplingInterval != "" {
@@ -172,16 +186,16 @@ func rankingChartDescription(metric string, ranking rankingData) string {
func balancedIndexCategories(ranking rankingData) string {
var categories []string
- if ranking.indexPrimary {
+ if ranking.IndexPrimary {
categories = append(categories, primaryCategoryName(ranking.primaryLabel))
}
- if ranking.indexCPU {
+ if ranking.IndexCPU {
categories = append(categories, "CPU cost")
}
- if ranking.indexRAM {
+ if ranking.IndexRAM {
categories = append(categories, "RAM aggregate")
}
- if ranking.indexFootprint {
+ if ranking.IndexFootprint {
categories = append(categories, "linked size")
}
if len(categories) == 0 {
diff --git a/internal/report/chart_static.go b/internal/report/chart_static.go
index 8b65d8f..cfaa8ae 100644
--- a/internal/report/chart_static.go
+++ b/internal/report/chart_static.go
@@ -15,6 +15,9 @@ func staticCostChart(
pick func(model.Benchmark) float64,
format func(float64) string,
) svgChart {
+ if len(report.Benchmarks) == 0 {
+ return svgChart{}
+ }
maximum := 0.0
for _, benchmark := range report.Benchmarks {
value := pick(benchmark)
@@ -40,12 +43,11 @@ func staticCostChart(
`%s`,
left, left+plotWidth, format(maximum),
)
- baseline := baselineIndex(report)
for index, benchmark := range report.Benchmarks {
y := 72 + index*rowHeight
value := pick(benchmark)
x := left + value/maximum*plotWidth
- color := chartColor(index, baseline)
+ color := toolColor(index)
fmt.Fprintf(
&body,
`%s`+
diff --git a/internal/report/chart_test.go b/internal/report/chart_test.go
index 46c5b6b..90bd960 100644
--- a/internal/report/chart_test.go
+++ b/internal/report/chart_test.go
@@ -1,12 +1,41 @@
package report
import (
+ "math"
"strings"
"testing"
"github.com/shellcell/snailrace/internal/model"
)
+func TestEmptyReportBuildsNoCharts(t *testing.T) {
+ if charts := NewRenderer(model.Report{}).reportCharts(); len(charts) != 0 {
+ t.Fatalf("empty report charts = %d, want 0", len(charts))
+ }
+}
+
+func TestChartsOmitNonFiniteCoordinates(t *testing.T) {
+ runs := []model.Run{
+ {Index: 1, WallSeconds: math.Inf(1)},
+ {Index: 2, WallSeconds: math.NaN()},
+ }
+ report := model.Report{
+ Config: model.Config{Baseline: 1, Mode: "command"},
+ Host: model.HostInfo{OS: "linux"},
+ Benchmarks: []model.Benchmark{{
+ Tool: model.ToolInfo{Name: "invalid", DiskFootprintBytes: 1},
+ Runs: runs, Summary: model.Summarize(runs),
+ }},
+ }
+ for _, chart := range NewRenderer(report).reportCharts() {
+ output := chart.html()
+ if strings.Contains(output, "NaN") || strings.Contains(output, "+Inf") ||
+ strings.Contains(output, "-Inf") {
+ t.Fatalf("chart %q contains non-finite coordinates", chart.title)
+ }
+ }
+}
+
func TestComparisonChartsContainDeltaAndRunViews(t *testing.T) {
report := model.Report{
Config: model.Config{Mode: "command", Baseline: 1},
@@ -15,7 +44,7 @@ func TestComparisonChartsContainDeltaAndRunViews(t *testing.T) {
benchmarkWithWallTimes("candidate", 8, 8, 8),
},
}
- charts := reportCharts(report)
+ charts := NewRenderer(report).reportCharts()
if len(charts) < 2 {
t.Fatalf("charts = %d, want delta and distribution charts", len(charts))
}
@@ -44,13 +73,13 @@ func TestBaselineChartsSeparateEveryMetric(t *testing.T) {
},
}
var titles []string
- for _, chart := range reportCharts(report) {
+ for _, chart := range NewRenderer(report).reportCharts() {
if chart.kind == "baseline" {
titles = append(titles, chart.title)
}
}
- if len(titles) != len(chartMetricDefinitions()) {
- t.Fatalf("baseline charts = %d, want %d: %v", len(titles), len(chartMetricDefinitions()), titles)
+ if len(titles) != len(metricCatalog) {
+ t.Fatalf("baseline charts = %d, want %d: %v", len(titles), len(metricCatalog), titles)
}
for _, expected := range []string{
"Mean RSS", "Peak RSS", "OS-reported max RSS", "Peak virtual memory",
@@ -73,8 +102,8 @@ func TestBaselineChartShowsBaselineConfidenceWhisker(t *testing.T) {
benchmarkWithWallTimes("candidate", 8, 9, 10),
},
}
- chart := deltaForestChart(report, chartGroup{
- name: "Wall time", metrics: []chartMetric{chartMetricDefinitions()[0]},
+ chart := deltaForestChart(NewRenderer(report), chartGroup{
+ name: "Wall time", metrics: chartMetrics(metricWall),
})
if !strings.Contains(chart.body, `class="baseline-ci"`) {
t.Fatalf("baseline confidence whisker missing: %s", chart.body)
@@ -87,16 +116,13 @@ func TestBaselineChartShowsBaselineConfidenceWhisker(t *testing.T) {
func TestToolColorsAreDistinctAndStable(t *testing.T) {
seen := make(map[string]bool)
for index := 0; index < 10; index++ {
- color := chartColor(index, 0)
+ color := toolColor(index)
if seen[color] {
t.Fatalf("tool %d repeats color %s", index, color)
}
seen[color] = true
- if chartColor(index, 3) != color || chartColor(index, 7) != color {
- t.Fatalf("tool %d color changes with baseline", index)
- }
}
- if chartColor(0, 0) != "#88c0d0" {
+ if toolColor(0) != "#88c0d0" {
t.Fatal("first tool should begin with the Frost color")
}
}
@@ -110,9 +136,9 @@ func TestChartLegendsUseToolIdentityColors(t *testing.T) {
},
}
legend := commandLegendChart(report).body
- ranking := rankingCharts(report)[0].body
+ ranking := rankingCharts(report, calculateRanking(report))[0].body
for index := range report.Benchmarks {
- color := toolColor(report, index)
+ color := toolColor(index)
style := `style="fill:` + color + `"`
if !strings.Contains(legend, style) || !strings.Contains(ranking, style) {
t.Fatalf("tool %d color %s missing from chart legend or ranking", index, color)
@@ -120,12 +146,45 @@ func TestChartLegendsUseToolIdentityColors(t *testing.T) {
}
}
+func TestRankingChartsPutBestAtTopAndWorstAtBottom(t *testing.T) {
+ report := model.Report{
+ Config: model.Config{
+ Mode: "command", Baseline: 3,
+ IndexDimensions: []string{"time", "cpu"},
+ },
+ Benchmarks: []model.Benchmark{
+ benchmarkWithResourceCosts("fast", 1, 9, 300, 300, 300),
+ benchmarkWithResourceCosts("middle", 2, 2, 200, 200, 200),
+ benchmarkWithResourceCosts("efficient", 3, 1, 100, 100, 100),
+ },
+ }
+ expected := map[string][]string{
+ "BALANCED INDEX": {"#1 efficient", "#2 middle", "#3 fast"},
+ "TIME": {"#1 fast", "#2 middle", "#3 efficient"},
+ "CPU COST": {"#1 efficient", "#2 middle", "#3 fast"},
+ "RAM AGGREGATE": {"#1 efficient", "#2 middle", "#3 fast"},
+ "LINKED SIZE": {"#1 efficient", "#2 middle", "#3 fast"},
+ }
+ for _, chart := range rankingCharts(report, calculateRanking(report)) {
+ order, ok := expected[chart.title]
+ if !ok {
+ t.Fatalf("missing expected order for %s chart", chart.title)
+ }
+ best := strings.Index(chart.body, order[0])
+ middle := strings.Index(chart.body, order[1])
+ worst := strings.Index(chart.body, order[2])
+ if best < 0 || middle <= best || worst <= middle {
+ t.Fatalf("%s chart does not put best at top and worst at bottom", chart.title)
+ }
+ }
+}
+
func TestSingleRunDistributionOmitsUndefinedConfidenceWhisker(t *testing.T) {
report := model.Report{
Config: model.Config{Mode: "command", Baseline: 1},
Benchmarks: []model.Benchmark{benchmarkWithWallTimes("tool", 1)},
}
- chart := absoluteDistributionChart(report, chartMetricDefinitions()[0])
+ chart := absoluteDistributionChart(report, metricCatalog[metricWall])
if strings.Contains(chart.body, `stroke="#eceff4"`) {
t.Fatal("one observation should not render a confidence whisker")
}
@@ -136,7 +195,7 @@ func TestDistributionSummaryPaintsAboveRawRuns(t *testing.T) {
Config: model.Config{Mode: "command", Baseline: 1},
Benchmarks: []model.Benchmark{benchmarkWithWallTimes("tool", 1, 2)},
}
- body := absoluteDistributionChart(report, chartMetricDefinitions()[0]).body
+ body := absoluteDistributionChart(report, metricCatalog[metricWall]).body
dot := strings.Index(body, `class="run-dot"`)
whisker := strings.Index(body, `class="mean-ci"`)
diamond := strings.Index(body, `class="mean-diamond"`)
@@ -152,9 +211,14 @@ func TestFixedTUIRankingChartUsesCPUUnits(t *testing.T) {
benchmarkWithWallTimes("first", 1), benchmarkWithWallTimes("second", 1),
},
}
- charts := rankingCharts(report)
- if len(charts) < 2 || charts[1].title != "CPU" ||
- !strings.Contains(charts[1].body, "%") {
+ charts := rankingCharts(report, calculateRanking(report))
+ found := false
+ for _, chart := range charts {
+ if chart.title == "CPU" && strings.Contains(chart.body, "%") {
+ found = true
+ }
+ }
+ if !found {
t.Fatal("fixed TUI primary ranking chart should show average CPU percentage")
}
}
@@ -164,7 +228,7 @@ func TestSingleToolReportOmitsRankingCharts(t *testing.T) {
Config: model.Config{Mode: "command", Baseline: 1},
Benchmarks: []model.Benchmark{benchmarkWithTrendRuns("tool")},
}
- for _, chart := range reportCharts(report) {
+ for _, chart := range NewRenderer(report).reportCharts() {
if chart.kind == "ranking" {
t.Fatalf("single-tool report should not include ranking chart %q", chart.title)
}
@@ -181,7 +245,7 @@ func TestBalancedIndexDescriptionListsIncludedCategories(t *testing.T) {
Config: model.Config{Mode: "command", Baseline: 1},
Benchmarks: benchmarks,
}
- description := rankingCharts(report)[0].description
+ description := rankingCharts(report, calculateRanking(report))[0].description
for _, expected := range []string{
"Included here: wall time, CPU cost, RAM aggregate.",
"score = value / best value",
@@ -198,7 +262,7 @@ func TestBalancedIndexDescriptionListsIncludedCategories(t *testing.T) {
},
Benchmarks: benchmarks,
}
- description = rankingCharts(withDisk)[0].description
+ description = rankingCharts(withDisk, calculateRanking(withDisk))[0].description
if !strings.Contains(description, "wall time, CPU cost, RAM aggregate, linked size") {
t.Fatalf("disk index missing linked size: %s", description)
}
@@ -211,7 +275,7 @@ func TestSingleToolReportHasMetricGroupTrendCharts(t *testing.T) {
}
var titles []string
var combined strings.Builder
- for _, chart := range reportCharts(report) {
+ for _, chart := range NewRenderer(report).reportCharts() {
if chart.kind != "trend" {
continue
}
@@ -242,7 +306,7 @@ func TestTrendChartsAreSectionedByTool(t *testing.T) {
},
}
var titles []string
- for _, section := range chartSections(reportCharts(report)) {
+ for _, section := range chartSections(NewRenderer(report).reportCharts()) {
if strings.HasPrefix(section.title, "Measurement trends") {
titles = append(titles, section.title)
}
@@ -263,7 +327,10 @@ func TestTrendLegendValuesDoNotOverlapLabels(t *testing.T) {
Benchmarks: []model.Benchmark{benchmarkWithTrendRuns("tool")},
}
chart := measurementTrendChart(report, 0, chartGroup{
- name: "MEMORY COST", metrics: chartMetricDefinitions()[5:9],
+ name: "MEMORY COST", metrics: chartMetrics(
+ metricMeanResident, metricPeakResident, metricOSMaxRSS,
+ metricPhysicalFootprint,
+ ),
})
if strings.Contains(chart.body, `x="704"`) {
t.Fatalf("trend legend values should not use overlapping right column: %s", chart.body)
@@ -289,7 +356,7 @@ func TestComparisonReportHasPerToolTrendCharts(t *testing.T) {
benchmarkWithTrendRuns("second"),
},
}
- titles := trendChartTitles(reportCharts(report))
+ titles := trendChartTitles(NewRenderer(report).reportCharts())
for _, expected := range []string{
"CPU cost trends Β· first [BASELINE]",
"Memory cost trends Β· first [BASELINE]",
@@ -315,8 +382,8 @@ func TestChartsHaveExplicitDescriptions(t *testing.T) {
Benchmarks: []model.Benchmark{benchmarkWithWallTimes("tool", 1, 2)},
}
chartSets := [][]svgChart{
- append([]svgChart{commandLegendChart(report)}, reportCharts(report)...),
- reportCharts(single),
+ append([]svgChart{commandLegendChart(report)}, NewRenderer(report).reportCharts()...),
+ NewRenderer(single).reportCharts(),
}
for _, charts := range chartSets {
for _, chart := range charts {
diff --git a/internal/report/chart_tradeoff.go b/internal/report/chart_tradeoff.go
index 3ab7a25..d045c7c 100644
--- a/internal/report/chart_tradeoff.go
+++ b/internal/report/chart_tradeoff.go
@@ -6,7 +6,6 @@ import (
"math"
"strings"
- "github.com/shellcell/snailrace/internal/analysis"
"github.com/shellcell/snailrace/internal/model"
)
@@ -16,14 +15,14 @@ type tradeoffMetric struct {
value func(model.Benchmark) float64
}
-func tradeoffCharts(report model.Report) []svgChart {
+func tradeoffCharts(report model.Report, ranking rankingData) []svgChart {
if len(report.Benchmarks) < 2 {
return nil
}
primary := tradeoffMetric{"Time", formatDuration, func(b model.Benchmark) float64 {
return b.Summary.WallSeconds.Mean
}}
- if report.Config.Mode == "tui" && report.Config.DurationSeconds > 0 {
+ if report.Config.FixedDurationTUI() {
primary = tradeoffMetric{"Average CPU", formatPercent, func(b model.Benchmark) float64 {
return b.Summary.AverageCPUPercent.Mean
}}
@@ -37,13 +36,13 @@ func tradeoffCharts(report model.Report) []svgChart {
if mean <= 0 || peak <= 0 {
return 0
}
- return math.Sqrt(mean * peak)
+ return math.Sqrt(mean) * math.Sqrt(peak)
}}
linked := tradeoffMetric{"Linked size", formatBytes, func(b model.Benchmark) float64 {
return float64(b.Tool.DiskFootprintBytes)
}}
pairs := [][2]tradeoffMetric{{primary, cpu}, {primary, linked}}
- if analysis.Calculate(report.Config, report.Benchmarks).RAMAvailable {
+ if ranking.RAMAvailable {
pairs = append(pairs, [2]tradeoffMetric{primary, ram}, [2]tradeoffMetric{cpu, ram})
}
charts := make([]svgChart, 0, len(pairs))
@@ -57,8 +56,13 @@ func tradeoffChart(report model.Report, xMetric, yMetric tradeoffMetric) svgChar
const left, top, plotWidth, plotHeight = 62, 62, 390, 240
xMaximum, yMaximum := 0.0, 0.0
for _, benchmark := range report.Benchmarks {
- xMaximum = math.Max(xMaximum, xMetric.value(benchmark))
- yMaximum = math.Max(yMaximum, yMetric.value(benchmark))
+ xValue, yValue := xMetric.value(benchmark), yMetric.value(benchmark)
+ if !finiteNumber(xValue) || !finiteNumber(yValue) ||
+ xValue < 0 || yValue < 0 {
+ return svgChart{}
+ }
+ xMaximum = math.Max(xMaximum, xValue)
+ yMaximum = math.Max(yMaximum, yValue)
}
if xMaximum <= 0 {
xMaximum = 1
@@ -87,7 +91,7 @@ func tradeoffChart(report model.Report, xMetric, yMetric tradeoffMetric) svgChar
xValue, yValue := xMetric.value(benchmark), yMetric.value(benchmark)
x := left + xValue/xMaximum*plotWidth
y := top + plotHeight - yValue/yMaximum*plotHeight
- color := toolColor(report, index)
+ color := toolColor(index)
legendY := 67 + index*26
fmt.Fprintf(
&body,
diff --git a/internal/report/chart_trend.go b/internal/report/chart_trend.go
index 5513b3c..6e7d8f7 100644
--- a/internal/report/chart_trend.go
+++ b/internal/report/chart_trend.go
@@ -16,11 +16,10 @@ type trendLane struct {
format func(float64) string
}
-func measurementTrendCharts(report model.Report) []svgChart {
+func measurementTrendCharts(report model.Report, groups []chartGroup) []svgChart {
if len(report.Benchmarks) == 0 {
return nil
}
- groups := chartGroups(report)
charts := make([]svgChart, 0, len(report.Benchmarks)*len(groups))
for benchmarkIndex, benchmark := range report.Benchmarks {
if len(benchmark.Runs) < 2 {
@@ -37,7 +36,7 @@ func measurementTrendCharts(report model.Report) []svgChart {
}
func measurementTrendChart(report model.Report, benchmarkIndex int, group chartGroup) svgChart {
- lanes := trendLanes(report, group)
+ lanes := trendLanes(report, benchmarkIndex, group)
if len(lanes) == 0 {
return svgChart{}
}
@@ -48,8 +47,13 @@ func measurementTrendChart(report model.Report, benchmarkIndex int, group chartG
return left + float64(index)/float64(len(runs)-1)*(right-left)
}
var body strings.Builder
+ metricCount := 0
+ for _, lane := range lanes {
+ metricCount += len(lane.metrics)
+ }
+ body.Grow(1536 + len(runs)*metricCount*96)
toolLabel := reportToolLabel(report, benchmarkIndex)
- toolColorHex := toolColor(report, benchmarkIndex)
+ toolColorHex := toolColor(benchmarkIndex)
body.WriteString(svgChartStyle)
fmt.Fprintf(
&body,
@@ -88,34 +92,53 @@ func measurementTrendChart(report model.Report, benchmarkIndex int, group chartG
left, bottom+18, right, bottom+18, len(runs),
)
for metricIndex, metric := range lane.metrics {
- values := trendValues(runs, metric)
- minimum, maximum := valueRange(values)
- color := style.Tool(metric.row).Hex
+ minimum, maximum := trendMetricRange(runs, metric)
+ color := style.Tool(int(metric.id)).Hex
var path strings.Builder
- for index, value := range values {
+ path.Grow(len(runs) * 20)
+ started := false
+ for index, run := range runs {
+ if !chartRunAvailable(metric, run) {
+ started = false
+ continue
+ }
+ value := metric.run(run)
command := "L"
- if index == 0 {
+ if !started {
command = "M"
}
- fmt.Fprintf(&path, "%s %.1f %.1f ", command, x(index), positionY(value))
+ path.WriteString(command)
+ path.WriteByte(' ')
+ writeChartFloat(&path, x(index))
+ path.WriteByte(' ')
+ writeChartFloat(&path, positionY(value))
+ path.WriteByte(' ')
+ started = true
}
fmt.Fprintf(
&body,
``,
path.String(), color,
)
- for index, value := range values {
- fmt.Fprintf(
- &body, ``,
- x(index), positionY(value), color,
- )
+ for index, run := range runs {
+ if !chartRunAvailable(metric, run) {
+ continue
+ }
+ value := metric.run(run)
+ body.WriteString(``)
}
legendY := top + 4 + metricIndex*26
fmt.Fprintf(
&body,
`%s`+
`%s ... %s`,
- legendY, color, html.EscapeString(clip(metric.name, 24)), legendY+12,
+ legendY, color, html.EscapeString(clip(metric.chartName, 24)), legendY+12,
metric.format(minimum), metric.format(maximum),
)
}
@@ -128,17 +151,13 @@ func measurementTrendChart(report model.Report, benchmarkIndex int, group chartG
}
}
-func trendValues(runs []model.Run, metric chartMetric) []float64 {
- values := make([]float64, len(runs))
- for index, run := range runs {
- values[index] = metric.run(run)
- }
- return values
-}
-
-func valueRange(values []float64) (float64, float64) {
+func trendMetricRange(runs []model.Run, metric chartMetric) (float64, float64) {
minimum, maximum := math.Inf(1), math.Inf(-1)
- for _, value := range values {
+ for _, run := range runs {
+ if !chartRunAvailable(metric, run) {
+ continue
+ }
+ value := metric.run(run)
minimum = math.Min(minimum, value)
maximum = math.Max(maximum, value)
}
@@ -152,6 +171,9 @@ func trendLaneRange(runs []model.Run, metrics []chartMetric) (float64, float64)
minimum, maximum := math.Inf(1), math.Inf(-1)
for _, metric := range metrics {
for _, run := range runs {
+ if !chartRunAvailable(metric, run) {
+ continue
+ }
value := metric.run(run)
minimum = math.Min(minimum, value)
maximum = math.Max(maximum, value)
@@ -163,39 +185,43 @@ func trendLaneRange(runs []model.Run, metrics []chartMetric) (float64, float64)
return minimum, maximum
}
-func trendLanes(report model.Report, group chartGroup) []trendLane {
- metrics := trendMetrics(report, group.metrics)
- byRow := make(map[int]chartMetric, len(metrics))
+func trendLanes(report model.Report, benchmarkIndex int, group chartGroup) []trendLane {
+ metrics := trendMetrics(report, benchmarkIndex, group.metrics)
+ byRow := make(map[metricID]chartMetric, len(metrics))
for _, metric := range metrics {
- byRow[metric.row] = metric
+ byRow[metric.id] = metric
}
switch group.name {
case "PERFORMANCE":
- return trendLaneDefinitions(byRow, trendLaneSpec{"Wall time", []int{0}})
+ return trendLaneDefinitions(byRow, trendLaneSpec{"Wall time", []metricID{metricWall}})
case "CPU COST":
return trendLaneDefinitions(
byRow,
- trendLaneSpec{"CPU time", []int{1, 2, 3}},
- trendLaneSpec{"Average CPU", []int{4}},
+ trendLaneSpec{"CPU time", []metricID{metricCPUTotal, metricCPUUser, metricCPUSystem}},
+ trendLaneSpec{"Average CPU", []metricID{metricAverageCPU}},
)
case "MEMORY COST":
return trendLaneDefinitions(
byRow,
- trendLaneSpec{"Resident memory", []int{7, 6, 5}},
- trendLaneSpec{"Physical footprint", []int{8}},
- trendLaneSpec{"Virtual memory", []int{9}},
+ trendLaneSpec{"Resident memory", []metricID{
+ metricOSMaxRSS, metricPeakResident, metricMeanResident,
+ }},
+ trendLaneSpec{"Physical footprint", []metricID{metricPhysicalFootprint}},
+ trendLaneSpec{"Virtual memory", []metricID{metricPeakVirtual}},
)
case "PROCESS STRUCTURE":
return trendLaneDefinitions(
byRow,
- trendLaneSpec{"Processes and threads", []int{11, 10}},
- trendLaneSpec{"FD references", []int{12}},
+ trendLaneSpec{"Processes and threads", []metricID{
+ metricPeakThreads, metricPeakProcesses,
+ }},
+ trendLaneSpec{"FD references", []metricID{metricPeakFDs}},
)
default:
lanes := make([]trendLane, 0, len(metrics))
for _, metric := range metrics {
lanes = append(lanes, trendLane{
- name: metric.name, metrics: []chartMetric{metric}, format: metric.format,
+ name: metric.chartName, metrics: []chartMetric{metric}, format: metric.format,
})
}
return lanes
@@ -204,10 +230,10 @@ func trendLanes(report model.Report, group chartGroup) []trendLane {
type trendLaneSpec struct {
name string
- rows []int
+ rows []metricID
}
-func trendLaneDefinitions(byRow map[int]chartMetric, specs ...trendLaneSpec) []trendLane {
+func trendLaneDefinitions(byRow map[metricID]chartMetric, specs ...trendLaneSpec) []trendLane {
lanes := make([]trendLane, 0, len(specs))
for _, spec := range specs {
metrics := make([]chartMetric, 0, len(spec.rows))
@@ -224,10 +250,16 @@ func trendLaneDefinitions(byRow map[int]chartMetric, specs ...trendLaneSpec) []t
return lanes
}
-func trendMetrics(report model.Report, metrics []chartMetric) []chartMetric {
+func trendMetrics(
+ report model.Report, benchmarkIndex int, metrics []chartMetric,
+) []chartMetric {
result := make([]chartMetric, 0, len(metrics))
for _, metric := range metrics {
- if available(metricRows[metric.row], report.Host.OS) {
+ stats := metric.stats(report.Benchmarks[benchmarkIndex].Summary)
+ if stats.N > 0 && finiteNumber(stats.Mean) && availableFor(
+ metric, report.Host.OS,
+ report.Benchmarks[benchmarkIndex].Summary,
+ ) {
result = append(result, metric)
}
}
@@ -282,7 +314,7 @@ func trendLaneNames(lanes []trendLane) string {
func trendMetricNames(metrics []chartMetric) string {
names := make([]string, 0, len(metrics))
for _, metric := range metrics {
- names = append(names, metric.name)
+ names = append(names, metric.chartName)
}
return strings.Join(names, ", ")
}
diff --git a/internal/report/chart_types.go b/internal/report/chart_types.go
index 1ffc268..4d819fc 100644
--- a/internal/report/chart_types.go
+++ b/internal/report/chart_types.go
@@ -3,11 +3,12 @@ package report
import (
"fmt"
"html"
+ "io"
"math"
+ "strconv"
"strings"
"unicode"
- "github.com/shellcell/snailrace/internal/model"
"github.com/shellcell/snailrace/internal/style"
)
@@ -30,13 +31,7 @@ type svgChart struct {
height int
}
-type chartMetric struct {
- name string
- format func(float64) string
- stats func(model.Benchmark) model.Stats
- run func(model.Run) float64
- row int
-}
+type chartMetric = metricDefinition
type chartGroup struct {
name string
@@ -44,26 +39,48 @@ type chartGroup struct {
}
func (chart svgChart) html() string {
- return fmt.Sprintf(
+ var output strings.Builder
+ output.Grow(len(chart.body) + 512)
+ chart.writeHTML(&output)
+ return output.String()
+}
+
+func (chart svgChart) writeHTML(writer io.Writer) {
+ fmt.Fprintf(
+ writer,
``,
- chartWidth, chart.height, chartWidth, chart.metadata(), chart.body,
+ `style="max-width:%dpx;font-family:monospace">`,
+ chartWidth, chart.height, chartWidth,
)
+ chart.writeMetadata(writer)
+ writeChartString(writer, chart.body)
+ writeChartString(writer, ``)
}
-func (chart svgChart) embedded(x, y int) string {
- return fmt.Sprintf(
+func (chart svgChart) writeEmbedded(writer io.Writer, x, y int) {
+ fmt.Fprintf(
+ writer,
``,
+ `viewBox="0 0 %d %d">`,
x, y, chartWidth, chart.height, chartWidth, chart.height,
- chart.metadata(), chart.body,
)
+ chart.writeMetadata(writer)
+ writeChartString(writer, chart.body)
+ writeChartString(writer, ``)
+}
+
+func writeChartString(writer io.Writer, value string) {
+ if stringWriter, ok := writer.(io.StringWriter); ok {
+ _, _ = stringWriter.WriteString(value)
+ return
+ }
+ _, _ = fmt.Fprint(writer, value)
}
-func (chart svgChart) metadata() string {
- return fmt.Sprintf(
- `
%s%s`,
+func (chart svgChart) writeMetadata(writer io.Writer) {
+ fmt.Fprintf(
+ writer, `%s%s`,
html.EscapeString(chart.accessibleTitle()), html.EscapeString(chart.explanation()),
)
}
@@ -82,11 +99,7 @@ func (chart svgChart) explanation() string {
return chart.accessibleTitle()
}
-func chartColor(index, _ int) string {
- return style.Tool(index).Hex
-}
-
-func toolColor(_ model.Report, index int) string {
+func toolColor(index int) string {
return style.Tool(index).Hex
}
@@ -103,7 +116,20 @@ func statusColor(class string) string {
}
}
+func writeChartFloat(output *strings.Builder, value float64) {
+ buffer := make([]byte, 0, 24)
+ output.Write(strconv.AppendFloat(buffer, value, 'f', 1, 64))
+}
+
+func writeChartInt(output *strings.Builder, value int) {
+ buffer := make([]byte, 0, 12)
+ output.Write(strconv.AppendInt(buffer, int64(value), 10))
+}
+
func niceDeltaScale(value float64) float64 {
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return 1
+ }
value = math.Max(1, value)
power := math.Pow(10, math.Floor(math.Log10(value)))
normalized := value / power
@@ -119,6 +145,10 @@ func niceDeltaScale(value float64) float64 {
}
}
+func finiteNumber(value float64) bool {
+ return !math.IsNaN(value) && !math.IsInf(value, 0)
+}
+
func balanceCharts(charts []svgChart) ([]svgChart, []svgChart) {
var left, right []svgChart
leftHeight, rightHeight := 0, 0
diff --git a/internal/report/command_legend.go b/internal/report/command_legend.go
index 2db6f2a..b9d2672 100644
--- a/internal/report/command_legend.go
+++ b/internal/report/command_legend.go
@@ -1,11 +1,14 @@
package report
import (
- "strings"
-
"github.com/shellcell/snailrace/internal/model"
+ "github.com/shellcell/snailrace/internal/style"
)
func fullCommand(benchmark model.Benchmark) string {
- return strings.Join(benchmark.Tool.Command, " ")
+ command := benchmark.Tool.Command
+ if len(command) == 0 {
+ return ""
+ }
+ return style.CommandText(command, benchmark.Tool.ShellCommand)
}
diff --git a/internal/report/command_legend_svg.go b/internal/report/command_legend_svg.go
index 7f997e4..8a18a1c 100644
--- a/internal/report/command_legend_svg.go
+++ b/internal/report/command_legend_svg.go
@@ -14,7 +14,7 @@ func svgCommandLegend(output *strings.Builder, report model.Report, y int) int {
for index, benchmark := range report.Benchmarks {
fmt.Fprintf(
output, `%d. %s`,
- y, toolColor(report, index), index+1,
+ y, toolColor(index), index+1,
html.EscapeString(clip(reportToolLabel(report, index), 80)),
)
y += 18
diff --git a/internal/report/command_legend_test.go b/internal/report/command_legend_test.go
new file mode 100644
index 0000000..5369fc3
--- /dev/null
+++ b/internal/report/command_legend_test.go
@@ -0,0 +1,48 @@
+package report
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/shellcell/snailrace/internal/model"
+)
+
+func TestFullCommandQuotesDirectArgumentsFaithfully(t *testing.T) {
+ benchmark := model.Benchmark{Tool: model.ToolInfo{Command: []string{
+ "tool", "two words", "it's", "", "line\nbreak",
+ }}}
+ want := `tool 'two words' 'it'"'"'s' '' "line\nbreak"`
+ if got := fullCommand(benchmark); got != want {
+ t.Fatalf("command = %q, want %q", got, want)
+ }
+}
+
+func TestFullCommandQuotesSingleDirectArgument(t *testing.T) {
+ benchmark := model.Benchmark{Tool: model.ToolInfo{Command: []string{"/tmp/my tool"}}}
+ if got, want := fullCommand(benchmark), `'/tmp/my tool'`; got != want {
+ t.Fatalf("command = %q, want %q", got, want)
+ }
+}
+
+func TestRenderedLabelsContainNoTerminalControls(t *testing.T) {
+ run := model.Run{WallSeconds: 1, StopReason: "exited"}
+ report := model.Report{Verbose: true, Config: model.Config{Baseline: 1},
+ Benchmarks: []model.Benchmark{{
+ Tool: model.ToolInfo{Name: "safe\n\x1b[31munsafe", Command: []string{"true"}},
+ Runs: []model.Run{run}, Summary: model.Summarize([]model.Run{run}),
+ }}}
+ for _, format := range []string{"text", "markdown", "html", "svg"} {
+ var output bytes.Buffer
+ if err := NewRenderer(report).Write(&output, format); err != nil {
+ t.Fatalf("%s: %v", format, err)
+ }
+ if strings.ContainsAny(output.String(), "\n\x1b") &&
+ strings.Contains(output.String(), "safe\n\x1b") {
+ t.Fatalf("%s retained label control characters", format)
+ }
+ if !strings.Contains(output.String(), `safe\n\x1b[31munsafe`) {
+ t.Fatalf("%s does not contain readable escaped label: %q", format, output.String())
+ }
+ }
+}
diff --git a/internal/report/comparison.go b/internal/report/comparison.go
index f53898d..b943122 100644
--- a/internal/report/comparison.go
+++ b/internal/report/comparison.go
@@ -17,6 +17,8 @@ const (
type deltaResult struct {
percent float64
percentAvailable bool
+ baselineMean float64
+ candidateMean float64
difference model.Stats
status string
class string
@@ -33,17 +35,32 @@ func baselineIndex(report model.Report) int {
func compareMetric(
baseline model.Benchmark,
candidate model.Benchmark,
- row metricRow,
+ row metricDefinition,
intervalSeconds float64,
) deltaResult {
- baseMean := row.stats(baseline.Summary).Mean
- candidateMean := row.stats(candidate.Summary).Mean
- result := deltaResult{}
+ return compareMetricWithBaselineIndex(
+ baseline, indexMetricValues(baseline.Runs, row), candidate, row, intervalSeconds,
+ )
+}
+
+func compareMetricWithBaselineIndex(
+ baseline model.Benchmark,
+ baselineValues map[int]float64,
+ candidate model.Benchmark,
+ row metricDefinition,
+ intervalSeconds float64,
+) deltaResult {
+ baseMean, candidateMean, differences := pairedDifferences(
+ baselineValues, candidate.Runs, row,
+ )
+ result := deltaResult{baselineMean: baseMean, candidateMean: candidateMean}
if baseMean != 0 {
- result.percent = (candidateMean/baseMean - 1) * 100
- result.percentAvailable = true
+ percent := (candidateMean/baseMean - 1) * 100
+ if finiteNumber(percent) {
+ result.percent = percent
+ result.percentAvailable = true
+ }
}
- differences := pairedDifferences(baseline.Runs, candidate.Runs, row.run)
result.difference = model.CalculateStats(differences)
result.status, result.class = comparisonStatus(result.difference, row.direction)
if row.sampled && !samplingReliable(baseline, candidate, intervalSeconds) {
@@ -52,6 +69,20 @@ func compareMetric(
return result
}
+func indexMetricValues(runs []model.Run, row metricDefinition) map[int]float64 {
+ values := make(map[int]float64, len(runs))
+ for _, run := range runs {
+ if row.id == metricPhysicalFootprint && !run.PhysicalFootprintValid {
+ continue
+ }
+ value := row.run(run)
+ if finiteNumber(value) {
+ values[run.Index] = value
+ }
+ }
+ return values
+}
+
func samplingReliable(
baseline, candidate model.Benchmark,
intervalSeconds float64,
@@ -60,21 +91,7 @@ func samplingReliable(
return true
}
for _, benchmark := range []model.Benchmark{baseline, candidate} {
- if !benchmarkSamplesReliable(benchmark, intervalSeconds) {
- return false
- }
- }
- return true
-}
-
-func benchmarkSamplesReliable(benchmark model.Benchmark, intervalSeconds float64) bool {
- if intervalSeconds <= 0 {
- return true
- }
- minimum := intervalSeconds * 2
- for _, run := range benchmark.Runs {
- if run.WallSeconds < minimum || run.SampleCount < 2 ||
- run.SampleCoverageSeconds < intervalSeconds {
+ if !model.SamplingReliable(benchmark, intervalSeconds) {
return false
}
}
@@ -82,21 +99,39 @@ func benchmarkSamplesReliable(benchmark model.Benchmark, intervalSeconds float64
}
func pairedDifferences(
- baseline, candidate []model.Run,
- pick func(model.Run) float64,
-) []float64 {
- baselineByIndex := make(map[int]float64, len(baseline))
- for _, run := range baseline {
- baselineByIndex[run.Index] = pick(run)
- }
+ baseline map[int]float64,
+ candidate []model.Run,
+ row metricDefinition,
+) (float64, float64, []float64) {
differences := make([]float64, 0, len(candidate))
+ baseMean, candidateMean := 0.0, 0.0
+ count := 0
for _, run := range candidate {
- value, ok := baselineByIndex[run.Index]
+ if row.id == metricPhysicalFootprint && !run.PhysicalFootprintValid {
+ continue
+ }
+ value, ok := baseline[run.Index]
if ok {
- differences = append(differences, pick(run)-value)
+ candidateValue := row.run(run)
+ if !finiteNumber(candidateValue) {
+ continue
+ }
+ count++
+ baseMean = runningMean(baseMean, value, count)
+ candidateMean = runningMean(candidateMean, candidateValue, count)
+ differences = append(differences, candidateValue-value)
}
}
- return differences
+ return baseMean, candidateMean, differences
+}
+
+func runningMean(mean, value float64, count int) float64 {
+ n := float64(count)
+ mean = mean*(1-1/n) + value/n
+ if math.IsInf(mean, 0) {
+ return math.Copysign(math.MaxFloat64, mean)
+ }
+ return mean
}
func comparisonStatus(stats model.Stats, direction metricDirection) (string, string) {
@@ -124,7 +159,7 @@ func comparisonStatus(stats model.Stats, direction metricDirection) (string, str
return "inconclusive", "uncertain"
}
-func formatDelta(delta deltaResult, row metricRow) string {
+func formatDelta(delta deltaResult) string {
change := "Ξ n/a"
if delta.percentAvailable {
change = "Ξ " + formatSignedPercent(delta.percent)
@@ -132,7 +167,7 @@ func formatDelta(delta deltaResult, row metricRow) string {
return change + " Β· " + delta.status
}
-func formatDeltaInterval(delta deltaResult, row metricRow) string {
+func formatDeltaInterval(delta deltaResult, row metricDefinition) string {
if !delta.difference.CI95Valid {
return "insufficient paired runs"
}
diff --git a/internal/report/comparison_html.go b/internal/report/comparison_html.go
index 6635780..14adbba 100644
--- a/internal/report/comparison_html.go
+++ b/internal/report/comparison_html.go
@@ -8,7 +8,8 @@ import (
"github.com/shellcell/snailrace/internal/model"
)
-func writeHTMLComparison(writer io.Writer, report model.Report) {
+func writeHTMLComparison(writer io.Writer, renderer *Renderer) {
+ report := renderer.displayReport
baselinePosition := baselineIndex(report)
baseline := report.Benchmarks[baselinePosition]
fmt.Fprintf(
@@ -35,10 +36,10 @@ func writeHTMLComparison(writer io.Writer, report model.Report) {
`| Metric | Baseline mean | `+
`Candidate mean | Ξ mean |
`)
writeHTMLStaticFootprint(writer, baseline, candidate)
- for _, row := range metricRows {
+ for _, row := range metricCatalog {
writeHTMLComparisonRow(
- writer, report.Host.OS, report.Config.IntervalMS/1000,
- baseline, candidate, row,
+ writer, report.Host.OS, baseline, candidate, row,
+ renderer.comparison(index, row.id),
)
}
fmt.Fprint(writer, "")
@@ -72,23 +73,22 @@ func writeHTMLStaticFootprint(
func writeHTMLComparisonRow(
writer io.Writer,
operatingSystem string,
- intervalSeconds float64,
baseline, candidate model.Benchmark,
- row metricRow,
+ row metricDefinition,
+ delta deltaResult,
) {
- if !available(row, operatingSystem) {
+ if !availableFor(row, operatingSystem, baseline.Summary, candidate.Summary) {
fmt.Fprintf(writer, "| %s | N/A |
", row.name)
return
}
- delta := compareMetric(baseline, candidate, row, intervalSeconds)
fmt.Fprintf(
writer,
`| %s | %s | %s | `+
`%s`+
`%s |
`,
- row.name, row.format(row.stats(baseline.Summary).Mean), delta.class,
- row.format(row.stats(candidate.Summary).Mean), delta.class,
- html.EscapeString(formatDelta(delta, row)),
+ row.name, row.format(delta.baselineMean), delta.class,
+ row.format(delta.candidateMean), delta.class,
+ html.EscapeString(formatDelta(delta)),
html.EscapeString(formatDeltaInterval(delta, row)),
)
}
diff --git a/internal/report/comparison_markdown.go b/internal/report/comparison_markdown.go
index 5da5f28..e22727a 100644
--- a/internal/report/comparison_markdown.go
+++ b/internal/report/comparison_markdown.go
@@ -3,11 +3,10 @@ package report
import (
"fmt"
"io"
-
- "github.com/shellcell/snailrace/internal/model"
)
-func writeMarkdownComparison(writer io.Writer, report model.Report) {
+func writeMarkdownComparison(writer io.Writer, renderer *Renderer) {
+ report := renderer.displayReport
baselinePosition := baselineIndex(report)
baseline := report.Benchmarks[baselinePosition]
fmt.Fprintf(
@@ -35,19 +34,18 @@ func writeMarkdownComparison(writer io.Writer, report model.Report) {
formatBytes(float64(baseline.Tool.DiskFootprintBytes)),
formatBytes(float64(candidate.Tool.DiskFootprintBytes)), staticDelta,
)
- for _, row := range metricRows {
- if !available(row, report.Host.OS) {
+ for _, row := range metricCatalog {
+ if !availableFor(
+ row, report.Host.OS, baseline.Summary, candidate.Summary,
+ ) {
fmt.Fprintf(writer, "| %s | N/A | N/A | N/A | N/A |\n", row.name)
continue
}
- delta := compareMetric(
- baseline, candidate, row, report.Config.IntervalMS/1000,
- )
+ delta := renderer.comparison(index, row.id)
fmt.Fprintf(
writer, "| %s | %s | %s | %s | %s |\n", row.name,
- row.format(row.stats(baseline.Summary).Mean),
- row.format(row.stats(candidate.Summary).Mean),
- formatDelta(delta, row), formatDeltaInterval(delta, row),
+ row.format(delta.baselineMean), row.format(delta.candidateMean),
+ formatDelta(delta), formatDeltaInterval(delta, row),
)
}
fmt.Fprintln(writer)
diff --git a/internal/report/comparison_test.go b/internal/report/comparison_test.go
index cb1f439..e71a84d 100644
--- a/internal/report/comparison_test.go
+++ b/internal/report/comparison_test.go
@@ -8,7 +8,7 @@ import (
)
func TestPairedComparisonRequiresConfidence(t *testing.T) {
- row := metricRows[0]
+ row := metricCatalog[metricWall]
baseline := benchmarkWithWallTimes("baseline", 10, 10, 10)
better := benchmarkWithWallTimes("better", 8, 8, 8)
delta := compareMetric(baseline, better, row, 0)
@@ -27,7 +27,7 @@ func TestSinglePairIsInconclusive(t *testing.T) {
delta := compareMetric(
benchmarkWithWallTimes("baseline", 10),
benchmarkWithWallTimes("candidate", 5),
- metricRows[0], 0,
+ metricCatalog[metricWall], 0,
)
if delta.status != "inconclusive" {
t.Fatalf("status = %q, want inconclusive", delta.status)
@@ -38,9 +38,9 @@ func TestZeroBaselineDeltaIsUnavailable(t *testing.T) {
delta := compareMetric(
benchmarkWithWallTimes("baseline", 0, 0),
benchmarkWithWallTimes("candidate", 1, 1),
- metricRows[0], 0,
+ metricCatalog[metricWall], 0,
)
- if got := formatDelta(delta, metricRows[0]); got != "Ξ n/a Β· worse" {
+ if got := formatDelta(delta); got != "Ξ n/a Β· worse" {
t.Fatalf("delta = %q, want unavailable percentage", got)
}
if got, _ := compareStaticCost(0, 100); got != "Ξ n/a Β· worse" {
@@ -57,7 +57,7 @@ func TestShortRunsMarkSampledMetricsAsLimited(t *testing.T) {
}
baseline.Summary = model.Summarize(baseline.Runs)
candidate.Summary = model.Summarize(candidate.Runs)
- delta := compareMetric(baseline, candidate, metricRows[5], 0.01)
+ delta := compareMetric(baseline, candidate, metricCatalog[metricMeanResident], 0.01)
if delta.status != "sampling-limited" {
t.Fatalf("status = %q, want sampling-limited", delta.status)
}
@@ -74,12 +74,46 @@ func TestTooFewValidSamplesMarksMetricsAsLimited(t *testing.T) {
}
baseline.Summary = model.Summarize(baseline.Runs)
candidate.Summary = model.Summarize(candidate.Runs)
- delta := compareMetric(baseline, candidate, metricRows[5], 0.01)
+ delta := compareMetric(baseline, candidate, metricCatalog[metricMeanResident], 0.01)
if delta.status != "sampling-limited" {
t.Fatalf("status = %q, want sampling-limited", delta.status)
}
}
+func TestPhysicalFootprintComparisonExcludesInvalidPairs(t *testing.T) {
+ baselineRuns := []model.Run{
+ {Index: 1, PeakPhysicalFootprintBytes: 100, PhysicalFootprintValid: true},
+ {Index: 2, PeakPhysicalFootprintBytes: 1000},
+ }
+ candidateRuns := []model.Run{
+ {Index: 1, PeakPhysicalFootprintBytes: 90, PhysicalFootprintValid: true},
+ {Index: 2, PeakPhysicalFootprintBytes: 1},
+ }
+ baseline := model.Benchmark{Runs: baselineRuns, Summary: model.Summarize(baselineRuns)}
+ candidate := model.Benchmark{Runs: candidateRuns, Summary: model.Summarize(candidateRuns)}
+ result := compareMetric(baseline, candidate, metricCatalog[metricPhysicalFootprint], 0)
+ if result.difference.N != 1 || result.difference.Mean != -10 {
+ t.Fatalf("physical footprint difference = %+v", result.difference)
+ }
+ if result.baselineMean != 100 || result.candidateMean != 90 ||
+ math.Abs(result.percent+10) > 1e-9 {
+ t.Fatalf("paired physical footprint means = %+v", result)
+ }
+}
+
+func TestComparisonDropsPairWithNonFiniteValue(t *testing.T) {
+ baselineRuns := []model.Run{{Index: 1, WallSeconds: 1}}
+ candidateRuns := []model.Run{{Index: 1, WallSeconds: math.NaN()}}
+ result := compareMetric(
+ model.Benchmark{Runs: baselineRuns, Summary: model.Summarize(baselineRuns)},
+ model.Benchmark{Runs: candidateRuns, Summary: model.Summarize(candidateRuns)},
+ metricCatalog[metricWall], 0,
+ )
+ if result.difference.N != 0 || result.percentAvailable {
+ t.Fatalf("non-finite pair produced a comparison: %+v", result)
+ }
+}
+
func benchmarkWithWallTimes(name string, values ...float64) model.Benchmark {
runs := make([]model.Run, len(values))
for index, value := range values {
diff --git a/internal/report/comparison_text.go b/internal/report/comparison_text.go
index 8580bc3..108ec46 100644
--- a/internal/report/comparison_text.go
+++ b/internal/report/comparison_text.go
@@ -3,11 +3,10 @@ package report
import (
"fmt"
"io"
-
- "github.com/shellcell/snailrace/internal/model"
)
-func writeTextComparison(writer io.Writer, report model.Report) {
+func writeTextComparison(writer io.Writer, renderer *Renderer) {
+ report := renderer.displayReport
baselinePosition := baselineIndex(report)
baseline := report.Benchmarks[baselinePosition]
for index, candidate := range report.Benchmarks {
@@ -31,19 +30,18 @@ func writeTextComparison(writer io.Writer, report model.Report) {
formatBytes(float64(baseline.Tool.DiskFootprintBytes)),
formatBytes(float64(candidate.Tool.DiskFootprintBytes)), staticDelta,
)
- for _, row := range metricRows {
- if !available(row, report.Host.OS) {
+ for _, row := range metricCatalog {
+ if !availableFor(
+ row, report.Host.OS, baseline.Summary, candidate.Summary,
+ ) {
fmt.Fprintf(writer, "%s\tN/A\tN/A\tN/A\tN/A\n", row.name)
continue
}
- delta := compareMetric(
- baseline, candidate, row, report.Config.IntervalMS/1000,
- )
+ delta := renderer.comparison(index, row.id)
fmt.Fprintf(
writer, "%s\t%s\t%s\t%s\t%s\n", row.name,
- row.format(row.stats(baseline.Summary).Mean),
- row.format(row.stats(candidate.Summary).Mean),
- formatDelta(delta, row), formatDeltaInterval(delta, row),
+ row.format(delta.baselineMean), row.format(delta.candidateMean),
+ formatDelta(delta), formatDeltaInterval(delta, row),
)
}
fmt.Fprintln(writer)
diff --git a/internal/report/error_writer.go b/internal/report/error_writer.go
new file mode 100644
index 0000000..d005df5
--- /dev/null
+++ b/internal/report/error_writer.go
@@ -0,0 +1,28 @@
+package report
+
+import "io"
+
+type errorWriter struct {
+ writer io.Writer
+ err error
+}
+
+func newErrorWriter(writer io.Writer) *errorWriter {
+ if checked, ok := writer.(*errorWriter); ok {
+ return checked
+ }
+ return &errorWriter{writer: writer}
+}
+
+func (writer *errorWriter) Write(value []byte) (int, error) {
+ if writer.err != nil {
+ return 0, writer.err
+ }
+ written, err := writer.writer.Write(value)
+ if err != nil {
+ writer.err = err
+ }
+ return written, err
+}
+
+func (writer *errorWriter) Err() error { return writer.err }
diff --git a/internal/report/error_writer_test.go b/internal/report/error_writer_test.go
new file mode 100644
index 0000000..d2ddb1c
--- /dev/null
+++ b/internal/report/error_writer_test.go
@@ -0,0 +1,42 @@
+package report
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/shellcell/snailrace/internal/model"
+)
+
+var errWriteLimit = errors.New("write limit reached")
+
+type limitedWriter struct {
+ remaining int
+}
+
+func (writer *limitedWriter) Write(value []byte) (int, error) {
+ if writer.remaining <= 0 {
+ return 0, errWriteLimit
+ }
+ if len(value) > writer.remaining {
+ written := writer.remaining
+ writer.remaining = 0
+ return written, errWriteLimit
+ }
+ writer.remaining -= len(value)
+ return len(value), nil
+}
+
+func TestReportFormatsPropagateWriterErrors(t *testing.T) {
+ report := model.Report{
+ Config: model.Config{Mode: "command", Baseline: 1},
+ Benchmarks: []model.Benchmark{benchmarkWithWallTimes("tool", 1, 2)},
+ }
+ for _, format := range []string{"text", "html", "markdown", "svg", "json"} {
+ t.Run(format, func(t *testing.T) {
+ err := NewRenderer(report).Write(&limitedWriter{}, format)
+ if !errors.Is(err, errWriteLimit) {
+ t.Fatalf("error = %v, want write failure", err)
+ }
+ })
+ }
+}
diff --git a/internal/report/failures.go b/internal/report/failures.go
new file mode 100644
index 0000000..0ed9ade
--- /dev/null
+++ b/internal/report/failures.go
@@ -0,0 +1,125 @@
+package report
+
+import (
+ "fmt"
+ "html"
+ "io"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/shellcell/snailrace/internal/model"
+)
+
+type benchmarkFailure struct {
+ benchmark int
+ failed int
+ total int
+ exitCodes []int
+}
+
+func benchmarkFailures(report model.Report) []benchmarkFailure {
+ var failures []benchmarkFailure
+ for index, benchmark := range report.Benchmarks {
+ failed := benchmark.FailedRunCount()
+ if failed == 0 {
+ continue
+ }
+ seen := make(map[int]bool)
+ for _, run := range benchmark.Runs {
+ if run.Failed() {
+ seen[run.ExitCode] = true
+ }
+ }
+ codes := make([]int, 0, len(seen))
+ for code := range seen {
+ codes = append(codes, code)
+ }
+ sort.Ints(codes)
+ failures = append(failures, benchmarkFailure{
+ benchmark: index, failed: failed, total: len(benchmark.Runs), exitCodes: codes,
+ })
+ }
+ return failures
+}
+
+func failureExitCodes(failure benchmarkFailure) string {
+ values := make([]string, len(failure.exitCodes))
+ for index, code := range failure.exitCodes {
+ values[index] = strconv.Itoa(code)
+ }
+ return strings.Join(values, ", ")
+}
+
+func writeTextFailures(writer io.Writer, report model.Report) {
+ failures := benchmarkFailures(report)
+ for _, failure := range failures {
+ fmt.Fprintf(
+ writer, "FAILED %s: %d/%d measured runs exited non-zero (codes %s); excluded from rankings\n",
+ report.Benchmarks[failure.benchmark].Tool.Name,
+ failure.failed, failure.total, failureExitCodes(failure),
+ )
+ }
+ if len(failures) > 0 {
+ fmt.Fprintln(writer)
+ }
+}
+
+func writeHTMLFailures(writer io.Writer, report model.Report) {
+ failures := benchmarkFailures(report)
+ if len(failures) == 0 {
+ return
+ }
+ fmt.Fprint(writer, `Command failures
`)
+ for _, failure := range failures {
+ fmt.Fprintf(
+ writer, `%s: %d/%d measured runs exited `+
+ `non-zero (codes %s); excluded from rankings.
`,
+ htmlToolLabel(report, failure.benchmark, true),
+ failure.failed, failure.total, html.EscapeString(failureExitCodes(failure)),
+ )
+ }
+ fmt.Fprint(writer, ``)
+}
+
+func writeMarkdownFailures(writer io.Writer, report model.Report) {
+ failures := benchmarkFailures(report)
+ if len(failures) == 0 {
+ return
+ }
+ fmt.Fprintln(writer, "## Command Failures")
+ for _, failure := range failures {
+ fmt.Fprintf(
+ writer, "\n- **%s**: %d/%d measured runs exited non-zero (codes %s); excluded from rankings.\n",
+ escapeMarkdown(report.Benchmarks[failure.benchmark].Tool.Name),
+ failure.failed, failure.total, failureExitCodes(failure),
+ )
+ }
+ fmt.Fprintln(writer)
+}
+
+func failureChart(report model.Report) svgChart {
+ failures := benchmarkFailures(report)
+ if len(failures) == 0 {
+ return svgChart{}
+ }
+ height := 54 + len(failures)*24
+ var body strings.Builder
+ body.WriteString(svgChartStyle)
+ body.WriteString(``)
+ body.WriteString(`COMMAND FAILURES`)
+ for index, failure := range failures {
+ fmt.Fprintf(
+ &body, `%s: `+
+ `%d/%d runs, codes %s; excluded from rankings`,
+ 50+index*24,
+ html.EscapeString(clip(report.Benchmarks[failure.benchmark].Tool.Name, 28)),
+ failure.failed, failure.total, html.EscapeString(failureExitCodes(failure)),
+ )
+ }
+ return svgChart{
+ kind: "failure", title: "Command failures", slug: "command-failures",
+ description: "Lists commands with non-zero measured runs that were excluded from rankings.",
+ body: body.String(), height: height,
+ }
+}
diff --git a/internal/report/format.go b/internal/report/format.go
index 6a19e4b..acfce64 100644
--- a/internal/report/format.go
+++ b/internal/report/format.go
@@ -8,8 +8,29 @@ import (
"github.com/shellcell/snailrace/internal/model"
)
-type metricRow struct {
+type metricID uint8
+
+const (
+ metricWall metricID = iota
+ metricCPUTotal
+ metricCPUUser
+ metricCPUSystem
+ metricAverageCPU
+ metricMeanResident
+ metricPeakResident
+ metricOSMaxRSS
+ metricPhysicalFootprint
+ metricPeakVirtual
+ metricPeakProcesses
+ metricPeakThreads
+ metricPeakFDs
+ metricCount
+)
+
+type metricDefinition struct {
+ id metricID
name string
+ chartName string
stats func(model.Summary) model.Stats
format func(float64) string
unavailableDarwin bool
@@ -19,32 +40,32 @@ type metricRow struct {
sampled bool
}
-var metricRows = []metricRow{
- {"Wall time", func(s model.Summary) model.Stats { return s.WallSeconds }, formatDuration, false, false,
+var metricCatalog = [...]metricDefinition{
+ {metricWall, "Wall time", "Wall time", func(s model.Summary) model.Stats { return s.WallSeconds }, formatDuration, false, false,
func(r model.Run) float64 { return r.WallSeconds }, lowerIsBetter, false},
- {"CPU total", func(s model.Summary) model.Stats { return s.CPUTotalSeconds }, formatDuration, false, false,
+ {metricCPUTotal, "CPU total", "Total CPU", func(s model.Summary) model.Stats { return s.CPUTotalSeconds }, formatDuration, false, false,
func(r model.Run) float64 { return r.CPUUserSeconds + r.CPUSystemSeconds }, lowerIsBetter, false},
- {"CPU user", func(s model.Summary) model.Stats { return s.CPUUserSeconds }, formatDuration, false, false,
+ {metricCPUUser, "CPU user", "User CPU", func(s model.Summary) model.Stats { return s.CPUUserSeconds }, formatDuration, false, false,
func(r model.Run) float64 { return r.CPUUserSeconds }, lowerIsBetter, false},
- {"CPU system", func(s model.Summary) model.Stats { return s.CPUSystemSeconds }, formatDuration, false, false,
+ {metricCPUSystem, "CPU system", "System CPU", func(s model.Summary) model.Stats { return s.CPUSystemSeconds }, formatDuration, false, false,
func(r model.Run) float64 { return r.CPUSystemSeconds }, lowerIsBetter, false},
- {"Average CPU", func(s model.Summary) model.Stats { return s.AverageCPUPercent }, formatPercent, false, false,
+ {metricAverageCPU, "Average CPU", "Average CPU", func(s model.Summary) model.Stats { return s.AverageCPUPercent }, formatPercent, false, false,
func(r model.Run) float64 { return r.AverageCPUPercent }, neutralDirection, false},
- {"Mean resident", func(s model.Summary) model.Stats { return s.MeanResidentBytes }, formatBytes, false, false,
+ {metricMeanResident, "Mean resident", "Mean RSS", func(s model.Summary) model.Stats { return s.MeanResidentBytes }, formatBytes, false, false,
func(r model.Run) float64 { return r.MeanResidentBytes }, lowerIsBetter, true},
- {"Peak resident", func(s model.Summary) model.Stats { return s.PeakResidentBytes }, formatBytes, false, false,
+ {metricPeakResident, "Peak resident", "Peak RSS", func(s model.Summary) model.Stats { return s.PeakResidentBytes }, formatBytes, false, false,
func(r model.Run) float64 { return r.PeakResidentBytes }, lowerIsBetter, true},
- {"OS-reported max RSS", func(s model.Summary) model.Stats { return s.OSMaxRSSBytes }, formatBytes, false, false,
+ {metricOSMaxRSS, "OS-reported max RSS", "OS-reported max RSS", func(s model.Summary) model.Stats { return s.OSMaxRSSBytes }, formatBytes, false, false,
func(r model.Run) float64 { return r.OSMaxRSSBytes }, lowerIsBetter, false},
- {"Peak physical footprint", func(s model.Summary) model.Stats { return s.PhysicalFootprintStats() }, formatBytes, false, true,
+ {metricPhysicalFootprint, "Peak physical footprint", "Peak physical footprint", func(s model.Summary) model.Stats { return s.PhysicalFootprintStats() }, formatBytes, false, true,
func(r model.Run) float64 { return r.PeakPhysicalFootprintBytes }, lowerIsBetter, true},
- {"Peak virtual", func(s model.Summary) model.Stats { return s.PeakVirtualBytes }, formatBytes, false, false,
+ {metricPeakVirtual, "Peak virtual", "Peak virtual memory", func(s model.Summary) model.Stats { return s.PeakVirtualBytes }, formatBytes, false, false,
func(r model.Run) float64 { return r.PeakVirtualBytes }, lowerIsBetter, true},
- {"Peak processes", func(s model.Summary) model.Stats { return s.PeakProcesses }, formatCount, false, false,
+ {metricPeakProcesses, "Peak processes", "Peak processes", func(s model.Summary) model.Stats { return s.PeakProcesses }, formatCount, false, false,
func(r model.Run) float64 { return r.PeakProcesses }, neutralDirection, true},
- {"Peak threads", func(s model.Summary) model.Stats { return s.PeakThreads }, formatCount, false, false,
+ {metricPeakThreads, "Peak threads", "Peak threads", func(s model.Summary) model.Stats { return s.PeakThreads }, formatCount, false, false,
func(r model.Run) float64 { return r.PeakThreads }, neutralDirection, true},
- {"Peak FD references", func(s model.Summary) model.Stats { return s.PeakFileDescriptors }, formatCount, true, false,
+ {metricPeakFDs, "Peak FD references", "Peak FD references", func(s model.Summary) model.Stats { return s.PeakFileDescriptors }, formatCount, true, false,
func(r model.Run) float64 { return r.PeakFileDescriptors }, lowerIsBetter, true},
}
@@ -92,6 +113,9 @@ func formatSignedPercent(value float64) string {
}
func formatNumber(value float64) string {
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return "N/A"
+ }
if value == 0 {
return "0"
}
@@ -115,7 +139,24 @@ func formatNumber(value float64) string {
return result
}
-func available(row metricRow, operatingSystem string) bool {
+func available(row metricDefinition, operatingSystem string) bool {
return (operatingSystem != "darwin" || !row.unavailableDarwin) &&
(!row.darwinOnly || operatingSystem == "darwin")
}
+
+func availableFor(
+ row metricDefinition, operatingSystem string, summaries ...model.Summary,
+) bool {
+ if !available(row, operatingSystem) {
+ return false
+ }
+ if !row.darwinOnly {
+ return true
+ }
+ for _, summary := range summaries {
+ if row.stats(summary).N == 0 {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/report/formats.go b/internal/report/formats.go
new file mode 100644
index 0000000..649ab06
--- /dev/null
+++ b/internal/report/formats.go
@@ -0,0 +1,44 @@
+package report
+
+import "strings"
+
+type Format string
+
+const (
+ FormatHTML Format = "html"
+ FormatSVG Format = "svg"
+ FormatMarkdown Format = "markdown"
+ FormatJSON Format = "json"
+ FormatText Format = "text"
+)
+
+type FormatInfo struct {
+ Name Format
+ Extension string
+ NeedsCharts bool
+}
+
+var formatCatalog = map[Format]FormatInfo{
+ FormatHTML: {Name: FormatHTML, Extension: "html"},
+ FormatSVG: {Name: FormatSVG, Extension: "svg", NeedsCharts: true},
+ FormatMarkdown: {Name: FormatMarkdown, Extension: "md", NeedsCharts: true},
+ FormatJSON: {Name: FormatJSON, Extension: "json"},
+ FormatText: {Name: FormatText, Extension: "txt"},
+}
+
+func LookupFormat(value string) (FormatInfo, bool) {
+ value = strings.ToLower(strings.TrimSpace(value))
+ switch value {
+ case "md":
+ value = string(FormatMarkdown)
+ case "txt":
+ value = string(FormatText)
+ }
+ info, ok := formatCatalog[Format(value)]
+ return info, ok
+}
+
+func ValidFormat(format string) bool {
+ _, ok := LookupFormat(format)
+ return ok
+}
diff --git a/internal/report/formats_test.go b/internal/report/formats_test.go
new file mode 100644
index 0000000..cd28260
--- /dev/null
+++ b/internal/report/formats_test.go
@@ -0,0 +1,29 @@
+package report
+
+import "testing"
+
+func TestFormatCatalogCanonicalizesAliasesAndMetadata(t *testing.T) {
+ tests := []struct {
+ input string
+ name Format
+ extension string
+ charts bool
+ }{
+ {"HTML", FormatHTML, "html", false},
+ {"svg", FormatSVG, "svg", true},
+ {"md", FormatMarkdown, "md", true},
+ {"markdown", FormatMarkdown, "md", true},
+ {"json", FormatJSON, "json", false},
+ {"txt", FormatText, "txt", false},
+ }
+ for _, test := range tests {
+ info, ok := LookupFormat(test.input)
+ if !ok || info.Name != test.name || info.Extension != test.extension ||
+ info.NeedsCharts != test.charts {
+ t.Fatalf("format %q = %+v, valid=%v", test.input, info, ok)
+ }
+ }
+ if ValidFormat("unknown") {
+ t.Fatal("unknown format should be invalid")
+ }
+}
diff --git a/internal/report/html.go b/internal/report/html.go
index 4c43814..42ac5d6 100644
--- a/internal/report/html.go
+++ b/internal/report/html.go
@@ -4,7 +4,6 @@ import (
"fmt"
"html"
"io"
- "strings"
"github.com/shellcell/snailrace/internal/model"
)
@@ -58,7 +57,10 @@ th:first-child,td:first-child{text-align:left}code{color:var(--accent);white-spa
.command-item{grid-template-columns:1fr}}
`
-func writeHTML(writer io.Writer, report model.Report) error {
+func writeHTML(writer io.Writer, renderer *Renderer) error {
+ report := renderer.displayReport
+ checked := newErrorWriter(writer)
+ writer = checked
fmt.Fprint(writer, htmlStart)
fmt.Fprintf(
writer,
@@ -67,16 +69,27 @@ func writeHTML(writer io.Writer, report model.Report) error {
html.EscapeString(report.Host.OS), html.EscapeString(report.Host.Architecture),
)
writeCards(writer, report)
+ fmt.Fprintf(
+ writer, `Balanced index dimensions actually included: %s.
`,
+ html.EscapeString(renderer.dimensionText),
+ )
+ for _, caveat := range renderer.caveats {
+ fmt.Fprintf(
+ writer, `Reliability: %s.
`,
+ html.EscapeString(caveat),
+ )
+ }
writeHTMLCommandLegend(writer, report)
+ writeHTMLFailures(writer, report)
if len(report.Benchmarks) > 1 {
- writeHTMLRanking(writer, report)
+ writeHTMLRanking(writer, report, renderer.ranking)
}
- charts := reportCharts(report)
+ charts := renderer.reportCharts()
for _, section := range chartSections(charts) {
writeHTMLChartSection(writer, section.title, section.charts)
}
if len(report.Benchmarks) > 1 {
- writeHTMLComparison(writer, report)
+ writeHTMLComparison(writer, renderer)
}
fmt.Fprint(writer, "Statistical detail
")
for index, benchmark := range report.Benchmarks {
@@ -89,8 +102,8 @@ func writeHTML(writer io.Writer, report model.Report) error {
for _, note := range report.Notes {
fmt.Fprintf(writer, "Note: %s
", html.EscapeString(note))
}
- _, err := fmt.Fprint(writer, "