performance benchmark
+ Switching debug.Stack() → runtime.Callers() saved ~900 ns and ~1 KB per error — the formatted string is gone, just raw program counters stored
This commit is contained in:
parent
e2b7e94847
commit
9599b8c0a3
488
pkg/result/bench_test.go
Normal file
488
pkg/result/bench_test.go
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
package result_test
|
||||||
|
|
||||||
|
// Comparison benchmark: canonical Go error handling vs the result package.
|
||||||
|
//
|
||||||
|
// Scenario: a "process record" pipeline with five steps and varying call-stack
|
||||||
|
// depths. Four inputs exercise the happy path and failure at depths 3, 5, 10.
|
||||||
|
//
|
||||||
|
// Run:
|
||||||
|
//
|
||||||
|
// go test -bench=. -benchmem ./pkg/result/
|
||||||
|
//
|
||||||
|
// What to look for:
|
||||||
|
// - HappyPath: result always spawns a goroutine (result.Go); that dominates.
|
||||||
|
// - FailDepthN: result pays goroutineID (runtime.Stack) + debug.Stack +
|
||||||
|
// sync.Map.Store + runtime.Goexit; canonical just returns an error.
|
||||||
|
// - Allocs: result error path allocates for the stack-trace string;
|
||||||
|
// canonical error path is typically zero additional allocs.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.djmil.dev/go/template/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Shared types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type bHeader struct{ raw, id, name, val string }
|
||||||
|
type bFields struct {
|
||||||
|
id int
|
||||||
|
name, val string
|
||||||
|
}
|
||||||
|
type bRecord struct {
|
||||||
|
id int
|
||||||
|
name string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
type bOutput struct{ line string }
|
||||||
|
|
||||||
|
// mockRsrc simulates a resource requiring cleanup (e.g. a file, DB conn).
|
||||||
|
type mockRsrc struct{ closed bool }
|
||||||
|
|
||||||
|
func (m *mockRsrc) close() { m.closed = true }
|
||||||
|
|
||||||
|
// ── Inputs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
happy = "42|Alice|100" // all steps succeed
|
||||||
|
fail3 = "" // parseHeader fails at depth 3: empty input
|
||||||
|
fail5 = "0|Alice|100" // validateFields fails at depth 5: id ≤ 0
|
||||||
|
fail10 = "42|Alice|-9" // enrichRecord fails at depth 10: negative score
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errEmpty = errors.New("empty input")
|
||||||
|
errNegScore = errors.New("negative score")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Canonical: standard (value, error) error handling ──────────────────────────
|
||||||
|
//
|
||||||
|
// Each step returns (T, error); every call site does: if err != nil { return }.
|
||||||
|
// The entry point uses goRun, a thin generic helper that mirrors result.Go
|
||||||
|
// (spawns one goroutine, blocks, returns (T, error)) so both sides pay the
|
||||||
|
// same goroutine-spawn cost and the benchmark isolates the error-propagation
|
||||||
|
// mechanism only.
|
||||||
|
// //go:noinline on intermediate frames prevents the compiler from collapsing
|
||||||
|
// the declared stack depth, keeping the benchmark representative.
|
||||||
|
|
||||||
|
// goRun is the canonical counterpart of result.Go: spawns a goroutine, blocks
|
||||||
|
// until fn returns, and hands back (T, error). Both entry points are now
|
||||||
|
// structurally identical — the only measured difference is how errors travel
|
||||||
|
// through the call stack.
|
||||||
|
func goRun[T any](fn func() (T, error)) (T, error) {
|
||||||
|
type ret struct {
|
||||||
|
v T
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan ret, 1)
|
||||||
|
go func() {
|
||||||
|
v, err := fn()
|
||||||
|
ch <- ret{v, err}
|
||||||
|
}()
|
||||||
|
r := <-ch
|
||||||
|
return r.v, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHeader — 3 levels deep; fails on fail3 input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func c_parseHeader(raw string) (bHeader, error) { return c_parseHeader2(raw) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_parseHeader2(raw string) (bHeader, error) { return c_parseHeader3(raw) }
|
||||||
|
func c_parseHeader3(raw string) (bHeader, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return bHeader{}, errEmpty
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(raw, "|", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return bHeader{}, fmt.Errorf("malformed record: %q", raw)
|
||||||
|
}
|
||||||
|
return bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateFields — 5 levels deep; fails on fail5 input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func c_validate(h bHeader) (bFields, error) { return c_validate2(h) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_validate2(h bHeader) (bFields, error) { return c_validate3(h) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_validate3(h bHeader) (bFields, error) { return c_validate4(h) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_validate4(h bHeader) (bFields, error) { return c_validate5(h) }
|
||||||
|
func c_validate5(h bHeader) (bFields, error) {
|
||||||
|
id, err := strconv.Atoi(h.id)
|
||||||
|
if err != nil {
|
||||||
|
return bFields{}, fmt.Errorf("parse id %q: %w", h.id, err)
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
return bFields{}, fmt.Errorf("id %d: must be > 0", id)
|
||||||
|
}
|
||||||
|
return bFields{id: id, name: h.name, val: h.val}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformData — 5 levels deep; does not fail on any benchmark input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func c_transform(f bFields) (bRecord, error) { return c_transform2(f) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_transform2(f bFields) (bRecord, error) { return c_transform3(f) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_transform3(f bFields) (bRecord, error) { return c_transform4(f) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_transform4(f bFields) (bRecord, error) { return c_transform5(f) }
|
||||||
|
func c_transform5(f bFields) (bRecord, error) {
|
||||||
|
v, err := strconv.ParseFloat(f.val, 64)
|
||||||
|
if err != nil {
|
||||||
|
return bRecord{}, fmt.Errorf("parse value %q: %w", f.val, err)
|
||||||
|
}
|
||||||
|
return bRecord{id: f.id, name: f.name, score: v * 1.5}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichRecord — 10 levels deep; 1 defer at level 1; fails on fail10 input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich(r bRecord) (bRecord, error) {
|
||||||
|
res := &mockRsrc{}
|
||||||
|
defer res.close()
|
||||||
|
return c_enrich2(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich2(r bRecord) (bRecord, error) { return c_enrich3(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich3(r bRecord) (bRecord, error) { return c_enrich4(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich4(r bRecord) (bRecord, error) { return c_enrich5(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich5(r bRecord) (bRecord, error) { return c_enrich6(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich6(r bRecord) (bRecord, error) { return c_enrich7(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich7(r bRecord) (bRecord, error) { return c_enrich8(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich8(r bRecord) (bRecord, error) { return c_enrich9(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_enrich9(r bRecord) (bRecord, error) { return c_enrich10(r) }
|
||||||
|
func c_enrich10(r bRecord) (bRecord, error) {
|
||||||
|
if r.score < 0 {
|
||||||
|
return bRecord{}, errNegScore
|
||||||
|
}
|
||||||
|
return bRecord{id: r.id, name: r.name, score: r.score + 10.0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOutput — 3 levels deep; does not fail on any benchmark input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func c_format(r bRecord) (bOutput, error) { return c_format2(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func c_format2(r bRecord) (bOutput, error) { return c_format3(r) }
|
||||||
|
func c_format3(r bRecord) (bOutput, error) {
|
||||||
|
return bOutput{line: fmt.Sprintf("%d|%s|%.2f", r.id, r.name, r.score)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalProcess is the pipeline entry point using canonical error handling.
|
||||||
|
func canonicalProcess(raw string) (bOutput, error) {
|
||||||
|
return goRun(func() (bOutput, error) {
|
||||||
|
h, err := c_parseHeader(raw)
|
||||||
|
if err != nil {
|
||||||
|
return bOutput{}, fmt.Errorf("parse header: %w", err)
|
||||||
|
}
|
||||||
|
f, err := c_validate(h)
|
||||||
|
if err != nil {
|
||||||
|
return bOutput{}, fmt.Errorf("validate fields: %w", err)
|
||||||
|
}
|
||||||
|
r, err := c_transform(f)
|
||||||
|
if err != nil {
|
||||||
|
return bOutput{}, fmt.Errorf("transform: %w", err)
|
||||||
|
}
|
||||||
|
r, err = c_enrich(r)
|
||||||
|
if err != nil {
|
||||||
|
return bOutput{}, fmt.Errorf("enrich: %w", err)
|
||||||
|
}
|
||||||
|
out, err := c_format(r)
|
||||||
|
if err != nil {
|
||||||
|
return bOutput{}, fmt.Errorf("format: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result: happy-path-oriented error handling ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each step returns result.Expect[T]; the call site chains .Expect("ctx").
|
||||||
|
// The entry point wraps everything in result.Go, which spawns one goroutine
|
||||||
|
// and collects any Expect failure as a normal Go error.
|
||||||
|
|
||||||
|
// parseHeader — 3 levels deep; fails on fail3 input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func r_parseHeader(raw string) result.Expect[bHeader] { return r_parseHeader2(raw) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_parseHeader2(raw string) result.Expect[bHeader] { return r_parseHeader3(raw) }
|
||||||
|
func r_parseHeader3(raw string) result.Expect[bHeader] {
|
||||||
|
if raw == "" {
|
||||||
|
return result.Fail[bHeader](errEmpty)
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(raw, "|", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return result.Fail[bHeader](fmt.Errorf("malformed record: %q", raw))
|
||||||
|
}
|
||||||
|
return result.Ok(bHeader{raw: raw, id: parts[0], name: parts[1], val: parts[2]})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateFields — 5 levels deep; fails on fail5 input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func r_validate(h bHeader) result.Expect[bFields] { return r_validate2(h) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_validate2(h bHeader) result.Expect[bFields] { return r_validate3(h) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_validate3(h bHeader) result.Expect[bFields] { return r_validate4(h) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_validate4(h bHeader) result.Expect[bFields] { return r_validate5(h) }
|
||||||
|
func r_validate5(h bHeader) result.Expect[bFields] {
|
||||||
|
id, err := strconv.Atoi(h.id)
|
||||||
|
if err != nil {
|
||||||
|
return result.Fail[bFields](fmt.Errorf("parse id %q: %w", h.id, err))
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
return result.Fail[bFields](fmt.Errorf("id %d: must be > 0", id))
|
||||||
|
}
|
||||||
|
return result.Ok(bFields{id: id, name: h.name, val: h.val})
|
||||||
|
}
|
||||||
|
|
||||||
|
// transformData — 5 levels deep; does not fail on any benchmark input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func r_transform(f bFields) result.Expect[bRecord] { return r_transform2(f) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_transform2(f bFields) result.Expect[bRecord] { return r_transform3(f) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_transform3(f bFields) result.Expect[bRecord] { return r_transform4(f) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_transform4(f bFields) result.Expect[bRecord] { return r_transform5(f) }
|
||||||
|
func r_transform5(f bFields) result.Expect[bRecord] {
|
||||||
|
v, err := strconv.ParseFloat(f.val, 64)
|
||||||
|
if err != nil {
|
||||||
|
return result.Fail[bRecord](fmt.Errorf("parse value %q: %w", f.val, err))
|
||||||
|
}
|
||||||
|
return result.Ok(bRecord{id: f.id, name: f.name, score: v * 1.5})
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichRecord — 10 levels deep; 1 defer at level 1; fails on fail10 input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich(r bRecord) result.Expect[bRecord] {
|
||||||
|
res := &mockRsrc{}
|
||||||
|
defer res.close()
|
||||||
|
return r_enrich2(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich2(r bRecord) result.Expect[bRecord] { return r_enrich3(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich3(r bRecord) result.Expect[bRecord] { return r_enrich4(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich4(r bRecord) result.Expect[bRecord] { return r_enrich5(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich5(r bRecord) result.Expect[bRecord] { return r_enrich6(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich6(r bRecord) result.Expect[bRecord] { return r_enrich7(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich7(r bRecord) result.Expect[bRecord] { return r_enrich8(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich8(r bRecord) result.Expect[bRecord] { return r_enrich9(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_enrich9(r bRecord) result.Expect[bRecord] { return r_enrich10(r) }
|
||||||
|
func r_enrich10(r bRecord) result.Expect[bRecord] {
|
||||||
|
if r.score < 0 {
|
||||||
|
return result.Fail[bRecord](errNegScore)
|
||||||
|
}
|
||||||
|
return result.Ok(bRecord{id: r.id, name: r.name, score: r.score + 10.0})
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOutput — 3 levels deep; does not fail on any benchmark input.
|
||||||
|
//
|
||||||
|
//go:noinline
|
||||||
|
func r_format(r bRecord) result.Expect[bOutput] { return r_format2(r) }
|
||||||
|
|
||||||
|
//go:noinline
|
||||||
|
func r_format2(r bRecord) result.Expect[bOutput] { return r_format3(r) }
|
||||||
|
func r_format3(r bRecord) result.Expect[bOutput] {
|
||||||
|
return result.Ok(bOutput{line: fmt.Sprintf("%d|%s|%.2f", r.id, r.name, r.score)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultProcess is the pipeline entry point using result-package error handling.
|
||||||
|
func resultProcess(raw string) (bOutput, error) {
|
||||||
|
return result.Go(func() bOutput {
|
||||||
|
h := r_parseHeader(raw).Expect("parse header")
|
||||||
|
f := r_validate(h).Expect("validate fields")
|
||||||
|
r := r_transform(f).Expect("transform")
|
||||||
|
r = r_enrich(r).Expect("enrich")
|
||||||
|
return r_format(r).Expect("format")
|
||||||
|
}).Unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Benchmarks ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var sinkOutput bOutput // prevents the compiler from eliminating pipeline work
|
||||||
|
|
||||||
|
func BenchmarkCanonical_HappyPath(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
out, err := canonicalProcess(happy)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
sinkOutput = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkResult_HappyPath(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
out, err := resultProcess(happy)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
sinkOutput = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkCanonical_FailDepth3(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := canonicalProcess(fail3)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkResult_FailDepth3(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := resultProcess(fail3)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkCanonical_FailDepth5(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := canonicalProcess(fail5)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkResult_FailDepth5(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := resultProcess(fail5)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkCanonical_FailDepth10(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := canonicalProcess(fail10)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkResult_FailDepth10(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := resultProcess(fail10)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CaptureStack=false variants ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// These show the floor: goroutine spawn + Goexit mechanism with no stack
|
||||||
|
// capture. Set result.CaptureStack=false at startup to reach this level in
|
||||||
|
// production. The happy-path cost is unchanged (CaptureStack is only
|
||||||
|
// consulted on error paths).
|
||||||
|
|
||||||
|
func BenchmarkResult_NoStack_HappyPath(b *testing.B) {
|
||||||
|
result.CaptureStack = false
|
||||||
|
defer func() { result.CaptureStack = true }()
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
out, err := resultProcess(happy)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
sinkOutput = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkResult_NoStack_FailDepth3(b *testing.B) {
|
||||||
|
result.CaptureStack = false
|
||||||
|
defer func() { result.CaptureStack = true }()
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := resultProcess(fail3)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkResult_NoStack_FailDepth10(b *testing.B) {
|
||||||
|
result.CaptureStack = false
|
||||||
|
defer func() { result.CaptureStack = true }()
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := resultProcess(fail10)
|
||||||
|
if err == nil {
|
||||||
|
b.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -18,6 +18,31 @@ type Expect[T any] struct {
|
|||||||
// keyed by goroutine ID. Entries are consumed by the enclosing Go or Run call.
|
// keyed by goroutine ID. Entries are consumed by the enclosing Go or Run call.
|
||||||
var gErrors sync.Map
|
var gErrors sync.Map
|
||||||
|
|
||||||
|
// CaptureStack controls whether Expect, Expectf, and Must capture a stack
|
||||||
|
// trace at the failure site. Defaults to true. Set to false at program startup
|
||||||
|
// (before spawning goroutines) to cut ~1.5 KB of allocation and most of the
|
||||||
|
// error-path overhead; errors are still collected and propagated, just without
|
||||||
|
// a trace. StackTrace will return an empty string for errors captured while
|
||||||
|
// this is false.
|
||||||
|
var CaptureStack = true
|
||||||
|
|
||||||
|
// callers returns the program counters of the call stack starting at the
|
||||||
|
// caller's caller, skipping skip frames above runtime.Callers itself.
|
||||||
|
// Returns nil when CaptureStack is false.
|
||||||
|
func callers(skip int) []uintptr {
|
||||||
|
if !CaptureStack {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pcs [32]uintptr
|
||||||
|
n := runtime.Callers(skip, pcs[:])
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := make([]uintptr, n)
|
||||||
|
copy(cp, pcs[:n])
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
// goroutineID returns the current goroutine's numeric ID by parsing the first
|
// goroutineID returns the current goroutine's numeric ID by parsing the first
|
||||||
// line of runtime.Stack output ("goroutine NNN [...]"). Called only on error
|
// line of runtime.Stack output ("goroutine NNN [...]"). Called only on error
|
||||||
// paths so the runtime.Stack overhead is acceptable.
|
// paths so the runtime.Stack overhead is acceptable.
|
||||||
@ -63,7 +88,7 @@ func (r Expect[T]) Err() error {
|
|||||||
// condition warrants an immediate crash.
|
// condition warrants an immediate crash.
|
||||||
func (r Expect[T]) Must() T {
|
func (r Expect[T]) Must() T {
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
panic(&stackError{err: r.err, stack: debug.Stack()})
|
panic(&stackError{err: r.err, stack: callers(3)})
|
||||||
}
|
}
|
||||||
return r.value
|
return r.value
|
||||||
}
|
}
|
||||||
@ -77,7 +102,7 @@ func (r Expect[T]) Expect(msg string) T {
|
|||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
gErrors.Store(goroutineID(), &stackError{
|
gErrors.Store(goroutineID(), &stackError{
|
||||||
err: fmt.Errorf("%s: %w", msg, r.err),
|
err: fmt.Errorf("%s: %w", msg, r.err),
|
||||||
stack: debug.Stack(),
|
stack: callers(3),
|
||||||
})
|
})
|
||||||
runtime.Goexit()
|
runtime.Goexit()
|
||||||
}
|
}
|
||||||
@ -92,7 +117,7 @@ func (r Expect[T]) Expectf(format string, args ...any) T {
|
|||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
gErrors.Store(goroutineID(), &stackError{
|
gErrors.Store(goroutineID(), &stackError{
|
||||||
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
|
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
|
||||||
stack: debug.Stack(),
|
stack: callers(3),
|
||||||
})
|
})
|
||||||
runtime.Goexit()
|
runtime.Goexit()
|
||||||
}
|
}
|
||||||
@ -197,21 +222,35 @@ func Catch(errp *error) {
|
|||||||
panic(v) // not an error — let it propagate
|
panic(v) // not an error — let it propagate
|
||||||
}
|
}
|
||||||
|
|
||||||
// StackTrace returns the stack trace captured when [Expect.Expect],
|
// StackTrace returns the call stack captured when [Expect.Expect],
|
||||||
// [Expect.Expectf], or [Expect.Must] stored or panicked with an error.
|
// [Expect.Expectf], or [Expect.Must] stored or panicked with an error.
|
||||||
// Returns an empty string if err was not produced by this package.
|
// Frames are formatted as "function\n\tfile:line\n" starting at the call
|
||||||
|
// site of the failing Expect/Expectf/Must call.
|
||||||
|
// Returns an empty string if err was not produced by this package or if
|
||||||
|
// [CaptureStack] was false when the error was captured.
|
||||||
func StackTrace(err error) string {
|
func StackTrace(err error) string {
|
||||||
var s *stackError
|
var s *stackError
|
||||||
if errors.As(err, &s) {
|
if !errors.As(err, &s) || len(s.stack) == 0 {
|
||||||
return string(s.stack)
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
|
}
|
||||||
|
frames := runtime.CallersFrames(s.stack)
|
||||||
|
var b strings.Builder
|
||||||
|
for {
|
||||||
|
f, more := frames.Next()
|
||||||
|
if f.Function != "runtime.goexit" {
|
||||||
|
fmt.Fprintf(&b, "%s\n\t%s:%d\n", f.Function, f.File, f.Line)
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// stackError wraps an error with a stack trace captured at the failure site.
|
// stackError wraps an error with program counters captured at the failure site.
|
||||||
type stackError struct {
|
type stackError struct {
|
||||||
err error
|
err error
|
||||||
stack []byte
|
stack []uintptr // nil when CaptureStack is false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stackError) Error() string { return s.err.Error() }
|
func (s *stackError) Error() string { return s.err.Error() }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user