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/...` - **Module imports** — always use the full module path `gitea.djmil.dev/go/template/...`
- **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/` - **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) - **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: - **Errors**`pkg/result` is a convenience tool for removing error-threading clutter from application logic; use it as follows:
- functions return `result.Expect[T]` instead of `(T, error)` - `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
- callers unwrap with `.Expect("context")` (panics with annotated error + stack trace) or `.Must()` (panics with raw error) - 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 (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 - 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")` - 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 - 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` - 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-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 — 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-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. either a success value or an error.
The idea: deep call stacks write for the **happy path**, unwrapping results with 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)` `.Expect()` or `.Must()` (which exit the goroutine on failure). A single
at the entry point recovers those panics and returns them as a normal Go error, `defer result.Catch(&err)` at the entry point collects that exit as a normal
with the stack trace captured at the original failure site. 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 ```go
// Wrap any (value, error) function: // Wrap any (value, error) function:

View File

@ -15,7 +15,7 @@ import (
) )
func main() { func main() {
if err := run(); err != nil { if err := result.Run(showGreeting); err != nil {
fmt.Fprintf(os.Stderr, "fatal: %v\n", err) fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
if stack := result.StackTrace(err); stack != "" { if stack := result.StackTrace(err); stack != "" {
fmt.Fprintf(os.Stderr, "%s\n", stack) fmt.Fprintf(os.Stderr, "%s\n", stack)
@ -24,9 +24,7 @@ func main() {
} }
} }
func run() (err error) { func showGreeting() {
defer result.Catch(&err)
// ── Config ──────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────
cfg := config.Load() cfg := config.Load()
@ -52,6 +50,4 @@ func run() (err error) {
log.WithField("message", msg).Info("greeting complete") log.WithField("message", msg).Info("greeting complete")
fmt.Printf("%s (listening on :%d)\n", msg, cfg.App.Port) 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. // 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 // result is a convenience tool for removing error-threading clutter from
// [Expect.Expect] to unwrap values — panicking on unexpected errors rather // application logic. Instead of propagating (value, error) pairs through every
// than threading error returns through every frame. // frame, functions return Expect[T] and the caller unwraps at the boundary.
// 2. The function that initiates a complex operation defers [Catch], which
// recovers any Expect panic and returns it as a normal Go error.
// //
// Stack traces are captured at the panic site and can be retrieved from the // # Layering rule
// caught error via [StackTrace]. //
// 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 // # Constructors
// //
@ -20,14 +41,16 @@
// //
// # Boundary pattern // # Boundary pattern
// //
// func run() (err error) { // func run() error {
// defer result.Catch(&err) // return result.Run(func() {
// port := parsePort(cfg.Port).Expect("load config port") // port := parsePort(cfg.Port).Expect("load config port")
// _ = port // happy path continues … // _ = port // happy path continues …
// return nil // })
// } // }
// //
// [Catch] only intercepts error panics produced by this package. Real runtime // [Go] is the typed variant — it returns Expect[T] when the closure produces
// panics (nil-pointer dereferences, index out of bounds, etc.) are re-panicked // a value. [Run] is a convenience wrapper for closures that return nothing.
// so genuine bugs are never silently swallowed. //
// Genuine runtime panics (nil-pointer dereferences, index out of bounds, etc.)
// are not recovered — they still crash the program, as they should.
package result package result

View File

@ -48,18 +48,41 @@ func Example_of() {
// 9090 // 9090
} }
// Example_catch shows the boundary pattern: defer Catch at the entry point so // Example_go shows result.Go running a closure in a goroutine and returning
// any Expect/Must panic anywhere in the call stack is captured as a normal // a typed Expect[T] value — the happy-path result or a collected error.
// error return. func Example_go() {
func Example_catch() { port := result.Go(func() int {
run := func() (err error) { return parsePort("8080").Expect("parse port")
defer result.Catch(&err) })
// Simulate a deep call stack: this panics because the port is invalid.
_ = parsePort("99999").Expect("load config port")
return nil
}
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) fmt.Println("caught:", err)
} }
// Output: // Output:
@ -79,25 +102,20 @@ func Example_unwrap() {
// 443 // 443
} }
// Example_nonErrorPanic shows that Catch does NOT swallow genuine runtime // Example_mustCollected shows that Must() panics (produced by this package)
// panics — only error panics produced by Must/Expect are captured. // are collected by Go/Run as normal errors, just like Expect failures.
func Example_nonErrorPanic() { // Genuine runtime panics (nil-deref, index out of bounds) are NOT collected —
safeRun := func() (err error) { // they propagate and crash the program, as they should.
defer func() { func Example_mustCollected() {
// Outer recover catches the re-panic from Catch. err := result.Run(func() {
if v := recover(); v != nil { result.Fail[int](errors.New("unrecoverable")).Must()
err = fmt.Errorf("non-error panic: %v", v) })
}
}()
defer result.Catch(&err)
panic("unexpected runtime problem") // not an error — Catch re-panics
}
if err := safeRun(); err != nil { if err != nil {
fmt.Println(err) fmt.Println("collected:", err)
} }
// Output: // Output:
// non-error panic: unexpected runtime problem // collected: unrecoverable
} }
// Example_expectf shows Expectf for context messages that include runtime // 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 // Example_expectfError shows that Expectf annotates the error message with the
// formatted context, just like Expect does. // formatted context, just like Expect does.
func Example_expectfError() { func Example_expectfError() {
run := func() (err error) { err := result.Run(func() {
defer result.Catch(&err)
_ = parsePort("99999").Expectf("arg %d port value", 2) _ = parsePort("99999").Expectf("arg %d port value", 2)
return nil })
}
if err := run(); err != nil { if err != nil {
fmt.Println("caught:", err) fmt.Println("caught:", err)
} }
// Output: // Output:

View File

@ -3,7 +3,9 @@ package result
import ( import (
"errors" "errors"
"fmt" "fmt"
"runtime"
"runtime/debug" "runtime/debug"
"sync"
) )
// Expect holds either a value of type T or an error. // Expect holds either a value of type T or an error.
@ -12,6 +14,26 @@ 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
// 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}
@ -36,8 +58,9 @@ 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 adds a message that makes the panic site easy to // Prefer [Expect.Expect] — it exits via runtime.Goexit and is collected
// locate in logs. // 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 { func (r Expect[T]) Must() T {
if r.err != nil { if r.err != nil {
panic(&stackError{err: r.err, stack: debug.Stack()}) panic(&stackError{err: r.err, stack: debug.Stack()})
@ -45,16 +68,18 @@ func (r Expect[T]) Must() T {
return r.value return r.value
} }
// Expect returns the value or panics with the error annotated by msg and a // Expect returns the value or exits the current goroutine via runtime.Goexit,
// stack trace captured at this call site. // 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") // data := Parse(raw).Expect("parse user input")
func (r Expect[T]) Expect(msg string) T { func (r Expect[T]) Expect(msg string) T {
if r.err != nil { if r.err != nil {
panic(&stackError{ gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", msg, r.err), err: fmt.Errorf("%s: %w", msg, r.err),
stack: debug.Stack(), stack: debug.Stack(),
}) })
runtime.Goexit()
} }
return r.value 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) // data := Parse(raw).Expectf("parse user input id=%d", id)
func (r Expect[T]) Expectf(format string, args ...any) T { func (r Expect[T]) Expectf(format string, args ...any) T {
if r.err != nil { if r.err != nil {
panic(&stackError{ gErrors.Store(goroutineID(), &stackError{
err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err), err: fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), r.err),
stack: debug.Stack(), stack: debug.Stack(),
}) })
runtime.Goexit()
} }
return r.value return r.value
} }
@ -79,18 +105,86 @@ func (r Expect[T]) Unwrap() (T, error) {
return r.value, r.err return r.value, r.err
} }
// Catch recovers a panic produced by [Expect.Must] or [Expect.Expect] and // Async runs fn in a new goroutine and returns a channel that will receive
// stores it in *errp. Call it via defer at the entry point of any function // exactly one Expect[T] when the goroutine finishes. The channel is buffered
// that runs a happy-path call stack: // so the goroutine never blocks even if the caller is busy with other work.
// //
// func run() (err error) { // Use Async when you want to run several operations concurrently and collect
// defer result.Catch(&err) // 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 …
// })
// } // }
// func Run(fn func()) error {
// The stored error retains its stack trace; retrieve it with [StackTrace]. return Go(func() struct{} {
// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked fn()
// so genuine bugs are not silently swallowed. 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) { func Catch(errp *error) {
v := recover() v := recover()
if v == nil { if v == nil {
@ -103,9 +197,9 @@ func Catch(errp *error) {
panic(v) // not an error — let it propagate panic(v) // not an error — let it propagate
} }
// StackTrace returns the stack trace captured when [Expect.Expect] or // StackTrace returns the stack trace captured when [Expect.Expect],
// [Expect.Must] panicked. Returns an empty string if err was not produced by // [Expect.Expectf], or [Expect.Must] stored or panicked with an error.
// this package. // Returns an empty string if err was not produced by this package.
func StackTrace(err error) string { func StackTrace(err error) string {
var s *stackError var s *stackError
if errors.As(err, &s) { if errors.As(err, &s) {
@ -114,7 +208,7 @@ func StackTrace(err error) string {
return "" 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 { type stackError struct {
err error err error
stack []byte stack []byte