diff --git a/pkg/result/bench_test.go b/pkg/result/bench_test.go index d511eb9..c7b0630 100644 --- a/pkg/result/bench_test.go +++ b/pkg/result/bench_test.go @@ -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 }() diff --git a/pkg/result/doc.go b/pkg/result/doc.go index fc4631c..01a1bcb 100644 --- a/pkg/result/doc.go +++ b/pkg/result/doc.go @@ -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. // diff --git a/pkg/result/expect_goexit.go b/pkg/result/expect_goexit.go new file mode 100644 index 0000000..a280229 --- /dev/null +++ b/pkg/result/expect_goexit.go @@ -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 ": ". +// +// 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 +} diff --git a/pkg/result/expect_panic.go b/pkg/result/expect_panic.go new file mode 100644 index 0000000..a2ce57d --- /dev/null +++ b/pkg/result/expect_panic.go @@ -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 ": ". +// +// 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 +} diff --git a/pkg/result/result.go b/pkg/result/result.go index edf677a..55b4b1e 100644 --- a/pkg/result/result.go +++ b/pkg/result/result.go @@ -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 ": ". -// -// 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()