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:
parent
974fed55d3
commit
e204e43e6e
@ -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 }()
|
||||
|
||||
@ -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
120
pkg/result/expect_goexit.go
Normal 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
105
pkg/result/expect_panic.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user