runtime.Goexit() instead of panic

- usage policy: application code vs pkg
This commit is contained in:
djmil 2026-04-23 18:48:31 +00:00
parent 09d7c98069
commit e2b7e94847
6 changed files with 220 additions and 81 deletions

View File

@ -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.

View File

@ -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:

View File

@ -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
}

View File

@ -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)
// func run() error {
// return result.Run(func() {
// port := parsePort(cfg.Port).Expect("load config port")
// _ = port // happy path continues …
// return nil
// })
// }
//
// [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

View File

@ -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:

View File

@ -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