use panics as a default error reporting mechanism

- runtime.Goexit() has too much performance overhead, and should be used only under special conditions
- introduce build tags
This commit is contained in:
djmil 2026-04-23 20:33:44 +00:00
parent 974fed55d3
commit e204e43e6e
5 changed files with 288 additions and 145 deletions

View File

@ -5,16 +5,22 @@ package result_test
// 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:
// Run (default — panic build):
//
// go test -bench=. -benchmem ./pkg/result/
//
// Run (Goexit build — stray-recover-safe):
//
// go test -bench=. -benchmem -tags result_goexit ./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.
// - HappyPath: result matches canonical — goroutineID is off the happy path;
// the defer closure no longer escapes to heap.
// - FailDepthN (default panic): result pays panic + recover + runtime.Callers;
// ~1.4 µs overhead vs canonical.
// - FailDepthN (-tags result_goexit): pays goroutineID + sync.Map + Goexit;
// ~5.5 µs — 4× slower but immune to stray recover() calls.
// - NoStack variants: CaptureStack=false removes runtime.Callers overhead.
import (
"errors"
@ -383,6 +389,26 @@ func BenchmarkResult_HappyPath(b *testing.B) {
}
}
// ── 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 BenchmarkCanonical_FailDepth3(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
@ -403,6 +429,18 @@ func BenchmarkResult_FailDepth3(b *testing.B) {
}
}
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 BenchmarkCanonical_FailDepth5(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
@ -423,6 +461,18 @@ func BenchmarkResult_FailDepth5(b *testing.B) {
}
}
func BenchmarkResult_NoStack_FailDepth5(b *testing.B) {
result.CaptureStack = false
defer func() { result.CaptureStack = true }()
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() {
@ -443,38 +493,6 @@ func BenchmarkResult_FailDepth10(b *testing.B) {
}
}
// ── 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 }()

View File

@ -23,9 +23,9 @@
// # Intended pattern
//
// 1. Deep call stacks write for the happy path, using [Expect.Expect] or
// [Expect.Expectf] to unwrap values — exiting the current goroutine via
// runtime.Goexit on unexpected errors rather than threading error returns
// through every frame.
// [Expect.Expectf] to unwrap values — exiting the goroutine on failure
// (panic by default; runtime.Goexit with -tags result_goexit) rather than
// threading error returns through every frame.
// 2. The entry point wraps the work with [Go] or [Run], which spawn the work
// in a goroutine and collect any failure as a normal Go error.
//

120
pkg/result/expect_goexit.go Normal file
View File

@ -0,0 +1,120 @@
//go:build result_goexit
// Optional runtime.Goexit implementation, enabled with -tags result_goexit.
//
// Expect and Expectf exit the goroutine via runtime.Goexit instead of panic.
// Goexit runs all deferred functions but is invisible to recover() — no
// recover() call anywhere in the call chain can accidentally swallow a result
// failure, making this build safe to use alongside frameworks or user code
// that uses bare recover() inside Go/Run closures.
//
// Cost: error path ≈ 5.5 µs / ~536 B per failure (goroutineID parse +
// sync.Map store/load + Goexit unwind) — about 4× the default panic build.
// Happy-path cost is identical to the default.
package result
import (
"errors"
"fmt"
"runtime"
"sync"
)
// gErrors stores errors set by Expect/Expectf before calling runtime.Goexit,
// keyed by goroutine ID. Entries are consumed by the enclosing Go or Run call.
var gErrors sync.Map
// goroutineID returns the current goroutine's numeric ID by parsing the first
// line of runtime.Stack output ("goroutine NNN [...]"). Called only on error
// paths so the runtime.Stack overhead is acceptable.
func goroutineID() uint64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
var id uint64
for _, b := range buf[10:n] { // skip "goroutine "
if b < '0' || b > '9' {
break
}
id = id*10 + uint64(b-'0')
}
return id
}
// Expect returns the value or exits the current goroutine via runtime.Goexit,
// storing the annotated error for collection by the enclosing [Go] or [Run]
// call. The stack trace is captured at this call site.
//
// data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T {
if r.err != nil {
gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", msg, r.err),
stack: callers(3),
})
runtime.Goexit()
}
return r.value
}
// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format string
// for the context message. The wrapped error is always appended as ": <err>".
//
// data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil {
gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
stack: callers(3),
})
runtime.Goexit()
}
return r.value
}
// Async runs fn in a new goroutine and returns a channel that will receive
// exactly one Expect[T] when the goroutine finishes. The channel is buffered
// so the goroutine never blocks even if the caller is busy with other work.
//
// Use Async when you want to run several operations concurrently and collect
// their results later:
//
// aCh := result.Async(func() *A { return buildA().Expect("build A") })
// bCh := result.Async(func() *B { return buildB().Expect("build B") })
// a := (<-aCh).Expect("A")
// b := (<-bCh).Expect("B")
//
// Genuine runtime panics (nil-pointer dereferences, etc.) are not recovered —
// they still crash the program, as they should.
func Async[T any](fn func() T) <-chan Expect[T] {
ch := make(chan Expect[T], 1)
go func() {
var (
val T
finished bool
)
defer func() {
if v := recover(); v != nil {
if err, ok := v.(*stackError); ok {
// Must() panic — treat as a collected failure.
ch <- Fail[T](err)
return
}
panic(v) // genuine runtime panic — crash the program
}
if finished {
ch <- Ok(val)
return
}
// goroutineID is looked up here, on the error path only.
if stored, ok := gErrors.LoadAndDelete(goroutineID()); ok {
ch <- Fail[T](stored.(error))
} else {
ch <- Fail[T](errors.New("goroutine exited unexpectedly"))
}
}()
val = fn()
finished = true
}()
return ch
}

105
pkg/result/expect_panic.go Normal file
View File

@ -0,0 +1,105 @@
//go:build !result_goexit
// Default error-exit implementation: Expect and Expectf signal failure via
// panic, which is caught by the enclosing result.Go / result.Run goroutine.
//
// Performance: error path ≈ 1.4 µs / ~352 B per failure (goroutine spawn +
// panic + recover). The runtime.Goexit build is about 4× slower (~5.5 µs).
//
// Trade-off: a deferred recover() inside a Go/Run closure that does not
// re-panic unrecognized values will silently swallow a result failure and let
// execution continue as if nothing happened. This follows standard Go practice
// for recover() — always check the type and re-panic anything unrecognized:
//
// defer func() {
// if r := recover(); r != nil {
// if _, ok := r.(MyExpectedType); !ok {
// panic(r) // not ours — let it propagate
// }
// // handle MyExpectedType ...
// }
// }()
//
// If your codebase uses bare recover() inside Go/Run closures, or integrates
// with frameworks that do, opt into the safer runtime.Goexit implementation:
//
// go test -tags result_goexit ./...
// go build -tags result_goexit ./...
package result
import (
"errors"
"fmt"
)
// Expect returns the value or panics with the annotated error, which is
// collected by the enclosing [Go] or [Run] call. The stack trace is captured
// at this call site when [CaptureStack] is true.
//
// data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T {
if r.err != nil {
panic(&stackError{
err: fmt.Errorf("%s: %w", msg, r.err),
stack: callers(3),
})
}
return r.value
}
// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format string
// for the context message. The wrapped error is always appended as ": <err>".
//
// data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil {
panic(&stackError{
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
stack: callers(3),
})
}
return r.value
}
// Async runs fn in a new goroutine and returns a channel that will receive
// exactly one Expect[T] when the goroutine finishes. The channel is buffered
// so the goroutine never blocks even if the caller is busy with other work.
//
// Use Async when you want to run several operations concurrently and collect
// their results later:
//
// aCh := result.Async(func() *A { return buildA().Expect("build A") })
// bCh := result.Async(func() *B { return buildB().Expect("build B") })
// a := (<-aCh).Expect("A")
// b := (<-bCh).Expect("B")
//
// Genuine runtime panics (nil-pointer dereferences, etc.) are not recovered —
// they still crash the program, as they should.
func Async[T any](fn func() T) <-chan Expect[T] {
ch := make(chan Expect[T], 1)
go func() {
var (
val T
finished bool
)
defer func() {
if v := recover(); v != nil {
if se, ok := v.(*stackError); ok {
// Expect/Must panic — treat as a collected failure.
ch <- Fail[T](se)
return
}
panic(v) // genuine runtime panic — crash the program
}
if finished {
ch <- Ok(val)
return
}
ch <- Fail[T](errors.New("goroutine exited unexpectedly"))
}()
val = fn()
finished = true
}()
return ch
}

View File

@ -5,7 +5,6 @@ import (
"fmt"
"runtime"
"strings"
"sync"
)
// Expect holds either a value of type T or an error.
@ -14,10 +13,6 @@ type Expect[T any] struct {
err error
}
// gErrors stores errors set by Expect/Expectf before calling runtime.Goexit,
// keyed by goroutine ID. Entries are consumed by the enclosing Go or Run call.
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
@ -43,22 +38,6 @@ func callers(skip int) []uintptr {
return cp
}
// goroutineID returns the current goroutine's numeric ID by parsing the first
// line of runtime.Stack output ("goroutine NNN [...]"). Called only on error
// paths so the runtime.Stack overhead is acceptable.
func goroutineID() uint64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
var id uint64
for _, b := range buf[10:n] { // skip "goroutine "
if b < '0' || b > '9' {
break
}
id = id*10 + uint64(b-'0')
}
return id
}
// Ok wraps a successful value in an Expect.
func Ok[T any](v T) Expect[T] {
return Expect[T]{value: v}
@ -83,9 +62,8 @@ func (r Expect[T]) Err() error {
}
// Must returns the value or panics with the wrapped error and a stack trace.
// Prefer [Expect.Expect] — it exits via runtime.Goexit and is collected
// cleanly by [Go] or [Run]. Must is for call sites where a genuine unrecoverable
// condition warrants an immediate crash.
// Use for genuine unrecoverable conditions where an immediate crash is correct.
// For normal error propagation inside [Go] or [Run], use [Expect.Expect].
func (r Expect[T]) Must() T {
if r.err != nil {
panic(&stackError{err: r.err, stack: callers(3)})
@ -93,90 +71,12 @@ func (r Expect[T]) Must() T {
return r.value
}
// Expect returns the value or exits the current goroutine via runtime.Goexit,
// storing the annotated error for collection by the enclosing [Go] or [Run]
// call. The stack trace is captured at this call site.
//
// data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T {
if r.err != nil {
gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", msg, r.err),
stack: callers(3),
})
runtime.Goexit()
}
return r.value
}
// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format string
// for the context message. The wrapped error is always appended as ": <err>".
//
// data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil {
gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
stack: callers(3),
})
runtime.Goexit()
}
return r.value
}
// Unwrap returns the value and error in the standard Go (value, error) form.
// Useful at the boundary where you want to re-join normal error-return code.
func (r Expect[T]) Unwrap() (T, error) {
return r.value, r.err
}
// Async runs fn in a new goroutine and returns a channel that will receive
// exactly one Expect[T] when the goroutine finishes. The channel is buffered
// so the goroutine never blocks even if the caller is busy with other work.
//
// Use Async when you want to run several operations concurrently and collect
// their results later:
//
// aCh := result.Async(func() *A { return buildA().Expect("build A") })
// bCh := result.Async(func() *B { return buildB().Expect("build B") })
// a := (<-aCh).Expect("A")
// b := (<-bCh).Expect("B")
//
// Genuine runtime panics (nil-pointer dereferences, etc.) are not recovered —
// they still crash the program, as they should.
func Async[T any](fn func() T) <-chan Expect[T] {
ch := make(chan Expect[T], 1)
go func() {
var (
val T
finished bool
)
defer func() {
if v := recover(); v != nil {
if err, ok := v.(*stackError); ok {
// Must() panic — treat as a collected failure.
ch <- Fail[T](err)
return
}
panic(v) // genuine runtime panic — crash the program
}
if finished {
ch <- Ok(val)
return
}
// goroutineID is looked up here, on the error path only.
if stored, ok := gErrors.LoadAndDelete(goroutineID()); ok {
ch <- Fail[T](stored.(error))
} else {
ch <- Fail[T](errors.New("goroutine exited unexpectedly"))
}
}()
val = fn()
finished = true
}()
return ch
}
// Go runs fn in a new goroutine and blocks until it completes, returning the
// result as Expect[T]. It is a convenience wrapper around [Async] for the
// common single-goroutine case.
@ -207,8 +107,8 @@ func Run(fn func()) error {
}
// Catch recovers a panic produced by [Expect.Must] and stores it in *errp.
// For [Expect.Expect] and [Expect.Expectf] unwinding, use [Go] or [Run]
// instead — those exit via runtime.Goexit and are not recoverable by Catch.
// For normal error propagation use [Go] or [Run] instead — they collect
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked.
func Catch(errp *error) {
v := recover()