runtime.Goexit() instead of panic
- usage policy: application code vs pkg
This commit is contained in:
parent
09d7c98069
commit
e2b7e94847
@ -39,10 +39,10 @@ tools.versions Pinned tool versions (sourced by Makefile and pre-push
|
||||
- **Module imports** — always use the full module path `gitea.djmil.dev/go/template/...`
|
||||
- **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/`
|
||||
- **Types** — expose concrete types from constructors (`New(...) *Type`); never wrap in an interface at the implementation site. Consumers define their own interfaces if they need one (Go's implicit satisfaction makes this free)
|
||||
- **Errors** — `pkg/result` is the default error-handling mechanism for all code in this repo, including public APIs:
|
||||
- functions return `result.Expect[T]` instead of `(T, error)`
|
||||
- callers unwrap with `.Expect("context")` (panics with annotated error + stack trace) or `.Must()` (panics with raw error)
|
||||
- top-level entry points (e.g. `cmd/` functions, HTTP handlers) defer `result.Catch(&err)` to convert any result panic into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked
|
||||
- **Errors** — `pkg/result` is a convenience tool for removing error-threading clutter from application logic; use it as follows:
|
||||
- `pkg/` libraries **only return** `result.Expect[T]` — never call `.Expect()`, `.Must()`, or `.Expectf()` inside library code; those methods exit the goroutine via `runtime.Goexit` and are only safe in application-layer code protected by a boundary
|
||||
- application code (`cmd/`, HTTP handlers, etc.) chains `.Expect("context")` freely — each call exits the goroutine on failure and is caught at the entry point
|
||||
- top-level entry points defer `result.Catch(&err)` (or use `result.Run(...)`) to convert any result exit into a normal Go error; genuine runtime panics (nil-deref, etc.) are re-panicked
|
||||
- bridge existing `(T, error)` stdlib/third-party calls with `result.Of(...)`: `result.Of(os.ReadFile("cfg.json")).Expect("read config")`
|
||||
- use `result.StackTrace(err)` to retrieve the capture-site stack from a caught error
|
||||
- still use `fmt.Errorf("context: %w", err)` when wrapping errors *before* constructing a `result.Fail`
|
||||
@ -126,3 +126,4 @@ Debug: use launch config "Debug: app" (F5).
|
||||
- 2026-03-29 — Fixed stale docs and .golangci.yml local-prefixes; .vscode launch configs use CLI flags.
|
||||
- 2026-04-01 — Replaced tools.go/go.mod pinning with tools.versions + go run tool@version; go.mod is now free of dev tool deps.
|
||||
- 2026-04-01 — Added make release: lists tags with no args; validates semver, runs test-race+lint+security, then tags+pushes.
|
||||
- 2026-04-23 — Documented result layering rule: pkg/ libraries only return Expect[T]; .Expect()/.Must() calls belong in application-layer code.
|
||||
|
||||
15
README.md
15
README.md
@ -182,9 +182,18 @@ keeping `go.mod` clean. It provides `Expect[T]`, a generic type that holds
|
||||
either a success value or an error.
|
||||
|
||||
The idea: deep call stacks write for the **happy path**, unwrapping results with
|
||||
`Expect` or `Must` (which panic on failure). A single `defer result.Catch(&err)`
|
||||
at the entry point recovers those panics and returns them as a normal Go error,
|
||||
with the stack trace captured at the original failure site.
|
||||
`.Expect()` or `.Must()` (which exit the goroutine on failure). A single
|
||||
`defer result.Catch(&err)` at the entry point collects that exit as a normal
|
||||
Go error, with the stack trace captured at the original failure site.
|
||||
|
||||
### Layering rule
|
||||
|
||||
`pkg/` libraries must only **return** `Expect[T]` — never call `.Expect()` or
|
||||
`.Must()` themselves. Those methods exit the current goroutine via
|
||||
`runtime.Goexit` and are only safe inside application-layer code (`cmd/`,
|
||||
HTTP handlers) that is protected by `defer result.Catch(&err)` or
|
||||
`result.Run(...)`. Calling them inside a reusable package takes control away
|
||||
from the caller and makes the package non-composable.
|
||||
|
||||
```go
|
||||
// Wrap any (value, error) function:
|
||||
|
||||
@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
if err := result.Run(showGreeting); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
|
||||
if stack := result.StackTrace(err); stack != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", stack)
|
||||
@ -24,9 +24,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func run() (err error) {
|
||||
defer result.Catch(&err)
|
||||
|
||||
func showGreeting() {
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
cfg := config.Load()
|
||||
|
||||
@ -52,6 +50,4 @@ func run() (err error) {
|
||||
log.WithField("message", msg).Info("greeting complete")
|
||||
|
||||
fmt.Printf("%s (listening on :%d)\n", msg, cfg.App.Port)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -1,15 +1,36 @@
|
||||
// Package result provides a generic Expect[T] type for happy-path-oriented code.
|
||||
//
|
||||
// The intended pattern is:
|
||||
// # Purpose
|
||||
//
|
||||
// 1. Deep call stacks write for the happy path, using [Expect.Must] or
|
||||
// [Expect.Expect] to unwrap values — panicking on unexpected errors rather
|
||||
// than threading error returns through every frame.
|
||||
// 2. The function that initiates a complex operation defers [Catch], which
|
||||
// recovers any Expect panic and returns it as a normal Go error.
|
||||
// result is a convenience tool for removing error-threading clutter from
|
||||
// application logic. Instead of propagating (value, error) pairs through every
|
||||
// frame, functions return Expect[T] and the caller unwraps at the boundary.
|
||||
//
|
||||
// Stack traces are captured at the panic site and can be retrieved from the
|
||||
// caught error via [StackTrace].
|
||||
// # Layering rule
|
||||
//
|
||||
// Reusable library code (packages under pkg/) must only *return* Expect[T] —
|
||||
// it must never call .Expect(), .Must(), or .Expectf() itself. Those methods
|
||||
// exit the current goroutine via runtime.Goexit and are only safe inside a
|
||||
// goroutine controlled by [Go] or [Run]. Calling them in a library takes
|
||||
// control away from the caller and makes the package non-composable.
|
||||
//
|
||||
// The right split:
|
||||
//
|
||||
// - pkg/ functions: return Expect[T] or Expect[Nothing] — let the caller decide.
|
||||
// - Application code (cmd/, HTTP handlers, …): chain .Expect() calls freely,
|
||||
// protected by a defer result.Catch(&err) or a result.Run wrapper.
|
||||
//
|
||||
// # 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.
|
||||
// 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.
|
||||
//
|
||||
// Stack traces are captured at the failure site and can be retrieved from the
|
||||
// collected error via [StackTrace].
|
||||
//
|
||||
// # Constructors
|
||||
//
|
||||
@ -20,14 +41,16 @@
|
||||
//
|
||||
// # Boundary pattern
|
||||
//
|
||||
// func run() (err error) {
|
||||
// defer result.Catch(&err)
|
||||
// port := parsePort(cfg.Port).Expect("load config port")
|
||||
// _ = port // happy path continues …
|
||||
// return nil
|
||||
// func run() error {
|
||||
// return result.Run(func() {
|
||||
// port := parsePort(cfg.Port).Expect("load config port")
|
||||
// _ = port // happy path continues …
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// [Catch] only intercepts error panics produced by this package. Real runtime
|
||||
// panics (nil-pointer dereferences, index out of bounds, etc.) are re-panicked
|
||||
// so genuine bugs are never silently swallowed.
|
||||
// [Go] is the typed variant — it returns Expect[T] when the closure produces
|
||||
// a value. [Run] is a convenience wrapper for closures that return nothing.
|
||||
//
|
||||
// Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.)
|
||||
// are not recovered — they still crash the program, as they should.
|
||||
package result
|
||||
|
||||
@ -48,18 +48,41 @@ func Example_of() {
|
||||
// 9090
|
||||
}
|
||||
|
||||
// Example_catch shows the boundary pattern: defer Catch at the entry point so
|
||||
// any Expect/Must panic anywhere in the call stack is captured as a normal
|
||||
// error return.
|
||||
func Example_catch() {
|
||||
run := func() (err error) {
|
||||
defer result.Catch(&err)
|
||||
// Simulate a deep call stack: this panics because the port is invalid.
|
||||
_ = parsePort("99999").Expect("load config port")
|
||||
return nil
|
||||
}
|
||||
// Example_go shows result.Go running a closure in a goroutine and returning
|
||||
// a typed Expect[T] value — the happy-path result or a collected error.
|
||||
func Example_go() {
|
||||
port := result.Go(func() int {
|
||||
return parsePort("8080").Expect("parse port")
|
||||
})
|
||||
|
||||
if err := run(); err != nil {
|
||||
fmt.Println(port.Expect("main"))
|
||||
// Output:
|
||||
// 8080
|
||||
}
|
||||
|
||||
// Example_goError shows result.Go collecting an error from a failed Expect
|
||||
// call inside the goroutine.
|
||||
func Example_goError() {
|
||||
port := result.Go(func() int {
|
||||
return parsePort("99999").Expect("parse port")
|
||||
})
|
||||
|
||||
if err := port.Err(); err != nil {
|
||||
fmt.Println("failed:", err)
|
||||
}
|
||||
// Output:
|
||||
// failed: parse port: parsePort: 99999 out of range
|
||||
}
|
||||
|
||||
// Example_catch shows the boundary pattern: result.Run collects any
|
||||
// Expect/Expectf failure anywhere in the call stack as a normal error.
|
||||
func Example_catch() {
|
||||
err := result.Run(func() {
|
||||
// Simulate a deep call stack: this exits the goroutine because the port is invalid.
|
||||
_ = parsePort("99999").Expect("load config port")
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("caught:", err)
|
||||
}
|
||||
// Output:
|
||||
@ -79,25 +102,20 @@ func Example_unwrap() {
|
||||
// 443
|
||||
}
|
||||
|
||||
// Example_nonErrorPanic shows that Catch does NOT swallow genuine runtime
|
||||
// panics — only error panics produced by Must/Expect are captured.
|
||||
func Example_nonErrorPanic() {
|
||||
safeRun := func() (err error) {
|
||||
defer func() {
|
||||
// Outer recover catches the re-panic from Catch.
|
||||
if v := recover(); v != nil {
|
||||
err = fmt.Errorf("non-error panic: %v", v)
|
||||
}
|
||||
}()
|
||||
defer result.Catch(&err)
|
||||
panic("unexpected runtime problem") // not an error — Catch re-panics
|
||||
}
|
||||
// Example_mustCollected shows that Must() panics (produced by this package)
|
||||
// are collected by Go/Run as normal errors, just like Expect failures.
|
||||
// Genuine runtime panics (nil-deref, index out of bounds) are NOT collected —
|
||||
// they propagate and crash the program, as they should.
|
||||
func Example_mustCollected() {
|
||||
err := result.Run(func() {
|
||||
result.Fail[int](errors.New("unrecoverable")).Must()
|
||||
})
|
||||
|
||||
if err := safeRun(); err != nil {
|
||||
fmt.Println(err)
|
||||
if err != nil {
|
||||
fmt.Println("collected:", err)
|
||||
}
|
||||
// Output:
|
||||
// non-error panic: unexpected runtime problem
|
||||
// collected: unrecoverable
|
||||
}
|
||||
|
||||
// Example_expectf shows Expectf for context messages that include runtime
|
||||
@ -112,13 +130,11 @@ func Example_expectf() {
|
||||
// Example_expectfError shows that Expectf annotates the error message with the
|
||||
// formatted context, just like Expect does.
|
||||
func Example_expectfError() {
|
||||
run := func() (err error) {
|
||||
defer result.Catch(&err)
|
||||
err := result.Run(func() {
|
||||
_ = parsePort("99999").Expectf("arg %d port value", 2)
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
if err := run(); err != nil {
|
||||
if err != nil {
|
||||
fmt.Println("caught:", err)
|
||||
}
|
||||
// Output:
|
||||
|
||||
@ -3,7 +3,9 @@ package result
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Expect holds either a value of type T or an error.
|
||||
@ -12,6 +14,26 @@ 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
|
||||
|
||||
// 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}
|
||||
@ -36,8 +58,9 @@ func (r Expect[T]) Err() error {
|
||||
}
|
||||
|
||||
// Must returns the value or panics with the wrapped error and a stack trace.
|
||||
// Prefer [Expect.Expect] — it adds a message that makes the panic site easy to
|
||||
// locate in logs.
|
||||
// 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.
|
||||
func (r Expect[T]) Must() T {
|
||||
if r.err != nil {
|
||||
panic(&stackError{err: r.err, stack: debug.Stack()})
|
||||
@ -45,16 +68,18 @@ func (r Expect[T]) Must() T {
|
||||
return r.value
|
||||
}
|
||||
|
||||
// Expect returns the value or panics with the error annotated by msg and a
|
||||
// stack trace captured at this call site.
|
||||
// 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 {
|
||||
panic(&stackError{
|
||||
gErrors.Store(goroutineID(), &stackError{
|
||||
err: fmt.Errorf("%s: %w", msg, r.err),
|
||||
stack: debug.Stack(),
|
||||
})
|
||||
runtime.Goexit()
|
||||
}
|
||||
return r.value
|
||||
}
|
||||
@ -65,10 +90,11 @@ func (r Expect[T]) Expect(msg string) T {
|
||||
// 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{
|
||||
gErrors.Store(goroutineID(), &stackError{
|
||||
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
|
||||
stack: debug.Stack(),
|
||||
})
|
||||
runtime.Goexit()
|
||||
}
|
||||
return r.value
|
||||
}
|
||||
@ -79,18 +105,86 @@ func (r Expect[T]) Unwrap() (T, error) {
|
||||
return r.value, r.err
|
||||
}
|
||||
|
||||
// Catch recovers a panic produced by [Expect.Must] or [Expect.Expect] and
|
||||
// stores it in *errp. Call it via defer at the entry point of any function
|
||||
// that runs a happy-path call stack:
|
||||
// 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.
|
||||
//
|
||||
// func run() (err error) {
|
||||
// defer result.Catch(&err)
|
||||
// ...
|
||||
// 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
|
||||
)
|
||||
id := goroutineID()
|
||||
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
|
||||
}
|
||||
if stored, ok := gErrors.LoadAndDelete(id); 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.
|
||||
//
|
||||
// msg := result.Go(func() string {
|
||||
// cfg := loadConfig().Expect("load config")
|
||||
// return greet(cfg).Expect("greet")
|
||||
// })
|
||||
func Go[T any](fn func() T) Expect[T] {
|
||||
return <-Async(fn)
|
||||
}
|
||||
|
||||
// Run runs fn in a new goroutine and returns any error produced by
|
||||
// [Expect.Expect] or [Expect.Expectf] calls within fn. Blocks until fn
|
||||
// completes or the goroutine exits.
|
||||
//
|
||||
// func run() error {
|
||||
// return result.Run(func() {
|
||||
// port := parsePort(cfg.Port).Expect("load config port")
|
||||
// _ = port // happy path continues …
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// The stored error retains its stack trace; retrieve it with [StackTrace].
|
||||
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked
|
||||
// so genuine bugs are not silently swallowed.
|
||||
func Run(fn func()) error {
|
||||
return Go(func() struct{} {
|
||||
fn()
|
||||
return struct{}{}
|
||||
}).Err()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked.
|
||||
func Catch(errp *error) {
|
||||
v := recover()
|
||||
if v == nil {
|
||||
@ -103,9 +197,9 @@ func Catch(errp *error) {
|
||||
panic(v) // not an error — let it propagate
|
||||
}
|
||||
|
||||
// StackTrace returns the stack trace captured when [Expect.Expect] or
|
||||
// [Expect.Must] panicked. Returns an empty string if err was not produced by
|
||||
// this package.
|
||||
// StackTrace returns the stack trace captured when [Expect.Expect],
|
||||
// [Expect.Expectf], or [Expect.Must] stored or panicked with an error.
|
||||
// Returns an empty string if err was not produced by this package.
|
||||
func StackTrace(err error) string {
|
||||
var s *stackError
|
||||
if errors.As(err, &s) {
|
||||
@ -114,7 +208,7 @@ func StackTrace(err error) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// stackError wraps an error with a stack trace captured at the panic site.
|
||||
// stackError wraps an error with a stack trace captured at the failure site.
|
||||
type stackError struct {
|
||||
err error
|
||||
stack []byte
|
||||
|
||||
Loading…
Reference in New Issue
Block a user