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
|
// 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.
|
// 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/
|
// 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:
|
// What to look for:
|
||||||
// - HappyPath: result always spawns a goroutine (result.Go); that dominates.
|
// - HappyPath: result matches canonical — goroutineID is off the happy path;
|
||||||
// - FailDepthN: result pays goroutineID (runtime.Stack) + debug.Stack +
|
// the defer closure no longer escapes to heap.
|
||||||
// sync.Map.Store + runtime.Goexit; canonical just returns an error.
|
// - FailDepthN (default panic): result pays panic + recover + runtime.Callers;
|
||||||
// - Allocs: result error path allocates for the stack-trace string;
|
// ~1.4 µs overhead vs canonical.
|
||||||
// canonical error path is typically zero additional allocs.
|
// - 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 (
|
import (
|
||||||
"errors"
|
"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) {
|
func BenchmarkCanonical_FailDepth3(b *testing.B) {
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
for b.Loop() {
|
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) {
|
func BenchmarkCanonical_FailDepth5(b *testing.B) {
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
for b.Loop() {
|
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) {
|
func BenchmarkCanonical_FailDepth10(b *testing.B) {
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
for b.Loop() {
|
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) {
|
func BenchmarkResult_NoStack_FailDepth10(b *testing.B) {
|
||||||
result.CaptureStack = false
|
result.CaptureStack = false
|
||||||
defer func() { result.CaptureStack = true }()
|
defer func() { result.CaptureStack = true }()
|
||||||
|
|||||||
@ -23,9 +23,9 @@
|
|||||||
// # Intended pattern
|
// # Intended pattern
|
||||||
//
|
//
|
||||||
// 1. Deep call stacks write for the happy path, using [Expect.Expect] or
|
// 1. Deep call stacks write for the happy path, using [Expect.Expect] or
|
||||||
// [Expect.Expectf] to unwrap values — exiting the current goroutine via
|
// [Expect.Expectf] to unwrap values — exiting the goroutine on failure
|
||||||
// runtime.Goexit on unexpected errors rather than threading error returns
|
// (panic by default; runtime.Goexit with -tags result_goexit) rather than
|
||||||
// through every frame.
|
// threading error returns through every frame.
|
||||||
// 2. The entry point wraps the work with [Go] or [Run], which spawn the work
|
// 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.
|
// 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"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Expect holds either a value of type T or an error.
|
// Expect holds either a value of type T or an error.
|
||||||
@ -14,10 +13,6 @@ type Expect[T any] struct {
|
|||||||
err error
|
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
|
// CaptureStack controls whether Expect, Expectf, and Must capture a stack
|
||||||
// trace at the failure site. Defaults to true. Set to false at program startup
|
// 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
|
// (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
|
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.
|
// Ok wraps a successful value in an Expect.
|
||||||
func Ok[T any](v T) Expect[T] {
|
func Ok[T any](v T) Expect[T] {
|
||||||
return Expect[T]{value: v}
|
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.
|
// 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
|
// Use for genuine unrecoverable conditions where an immediate crash is correct.
|
||||||
// cleanly by [Go] or [Run]. Must is for call sites where a genuine unrecoverable
|
// For normal error propagation inside [Go] or [Run], use [Expect.Expect].
|
||||||
// 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: callers(3)})
|
panic(&stackError{err: r.err, stack: callers(3)})
|
||||||
@ -93,90 +71,12 @@ func (r Expect[T]) Must() T {
|
|||||||
return r.value
|
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.
|
// 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.
|
// Useful at the boundary where you want to re-join normal error-return code.
|
||||||
func (r Expect[T]) Unwrap() (T, error) {
|
func (r Expect[T]) Unwrap() (T, error) {
|
||||||
return r.value, r.err
|
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
|
// 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
|
// result as Expect[T]. It is a convenience wrapper around [Async] for the
|
||||||
// common single-goroutine case.
|
// 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.
|
// Catch recovers a panic produced by [Expect.Must] and stores it in *errp.
|
||||||
// For [Expect.Expect] and [Expect.Expectf] unwinding, use [Go] or [Run]
|
// For normal error propagation use [Go] or [Run] instead — they collect
|
||||||
// instead — those exit via runtime.Goexit and are not recoverable by Catch.
|
// failures from [Expect.Expect] and [Expect.Expectf] regardless of build tag.
|
||||||
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked.
|
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked.
|
||||||
func Catch(errp *error) {
|
func Catch(errp *error) {
|
||||||
v := recover()
|
v := recover()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user