diff --git a/CLAUDE.md b/CLAUDE.md index 48221dd..1a77bc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 348cb97..ef176df 100644 --- a/README.md +++ b/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: diff --git a/cmd/app/main.go b/cmd/app/main.go index 1f74b81..a3d8edd 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -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 } diff --git a/pkg/result/doc.go b/pkg/result/doc.go index 8bd4721..fc4631c 100644 --- a/pkg/result/doc.go +++ b/pkg/result/doc.go @@ -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 diff --git a/pkg/result/example_test.go b/pkg/result/example_test.go index 3923502..22ee080 100644 --- a/pkg/result/example_test.go +++ b/pkg/result/example_test.go @@ -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: diff --git a/pkg/result/result.go b/pkg/result/result.go index 6523de4..7e8ddf0 100644 --- a/pkg/result/result.go +++ b/pkg/result/result.go @@ -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