From 81f5a49ceae39ca9e399fc01e87fa3e8acb3e45c Mon Sep 17 00:00:00 2001 From: djmil Date: Wed, 6 May 2026 21:04:26 +0000 Subject: [PATCH] result: Errw with caller info - wrap existing errors with context + file:line, newline-separated for readable error chains - dual mode philosophy: panics + if err != nil - unify Expect for goexit and panic cases --- .vscode/settings.json | 5 +- cmd/app/main.go | 2 +- internal/greeter/greeter.go | 2 +- internal/logger/logger.go | 4 +- pkg/result/doc.go | 68 +++++++++++++++-------- pkg/result/example_test.go | 36 +++++++----- pkg/result/expect_goexit.go | 89 +++++------------------------- pkg/result/expect_panic.go | 79 ++------------------------- pkg/result/panic_test.go | 2 +- pkg/result/result.go | 106 +++++++++++++++++++++++++++++++++++- 10 files changed, 195 insertions(+), 198 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 438e263..1fa7aa0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,12 +29,13 @@ "go.testExplorer.enable": true, "cSpell.words": [ "djmil", + "Errf", + "Errw", "Expectf", "Failf", "gitea", "golangci", "nolint", - "testutil", - "Errf" + "testutil" ] } diff --git a/cmd/app/main.go b/cmd/app/main.go index 79a363b..3644e58 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -16,7 +16,7 @@ import ( func main() { if err := result.Run(showGreeting); err != nil { - fmt.Fprintf(os.Stderr, "fatal: %v\n", err) + fmt.Fprintf(os.Stderr, "[failed] %v\n", err) if stack := result.StackTrace(err); stack != "" { fmt.Fprintf(os.Stderr, "%s\n", stack) } diff --git a/internal/greeter/greeter.go b/internal/greeter/greeter.go index 86dbd59..ed25536 100644 --- a/internal/greeter/greeter.go +++ b/internal/greeter/greeter.go @@ -27,7 +27,7 @@ func New(log *logger.Logger) *Service { // Greet returns a personalized greeting and logs the interaction. func (s *Service) Greet(name string) result.Expect[string] { if name == "" { - return result.Errf[string]("Greet: name must not be empty") + return result.Errf[string]("name must not be empty") } msg := fmt.Sprintf("Hello, %s!", name) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 866e7cf..03fdffd 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -32,7 +32,7 @@ type Logger struct { func New(level string) result.Expect[*Logger] { lvl := parseLevel(level) if lvl.Err() != nil { - return result.Errf[*Logger]("parseLevel: %w", lvl.Err()) + return result.Errw[*Logger](lvl.Err(), "parse log level") } handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()}) @@ -76,7 +76,7 @@ func (l *Logger) WithFields(fields map[string]any) *Logger { func parseLevel(level string) result.Expect[slog.Level] { var lvl slog.Level if err := lvl.UnmarshalText([]byte(level)); err != nil { - return result.Errf[slog.Level]("unknown level %q (use debug|info|warn|error)", level) + return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)") } return result.Ok(lvl) diff --git a/pkg/result/doc.go b/pkg/result/doc.go index 0ab5dbf..27bc2fd 100644 --- a/pkg/result/doc.go +++ b/pkg/result/doc.go @@ -1,41 +1,61 @@ -// Package result provides a generic Expect[T] type for happy-path-oriented code. +// Package result provides a generic Expect[T] type that supports two error +// handling styles without forcing either one. // -// # Purpose +// # Two modes, one type // -// 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. +// Expect[T] is a drop-in replacement for (T, error) that also enables +// panic-based happy-path propagation when that suits the code better. Both +// styles compose freely — the same Expect[T] value works in either. +// +// func parseHost(s string) result.Expect[string] { +// if s == "" { +// return result.Errf[string]("host must not be empty") +// } +// return result.Ok(s) +// } +// +// Mode 1 — standard Go style (if err != nil): +// +// host, err := parseHost(s).Unwrap() +// if err != nil { +// return 0, err +// } +// +// Or check and access separately, just as with (T, error): +// +// r := parseHost(s) +// if r.Err() != nil { +// return 0, r.Err() +// } +// use(r.Value()) +// +// Mode 2 — happy-path style (panic-based propagation): +// +// port := parseHost(s).Expect("parse host") // panics on failure +// +// Failures are collected at the entry point by [Go] or [Run] and returned as a +// normal Go error — no goroutine leaks, no silent swallowing. // // # 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. +// exit the current goroutine and are only safe inside a goroutine controlled +// by [Go] or [Run]. // // The right split: // -// - pkg/ functions: return Expect[T] or Expect[Nothing] — let the caller decide. +// - pkg/ functions: return Expect[T] — let the caller decide how to handle it. // - 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 goroutine on failure -// (panic by default; runtime.Goexit with -tags result_goexit) 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 // -// Use [Ok] to wrap a success value, [Err] to wrap an error, and [Of] to -// bridge existing (value, error) return signatures: +// Use [Ok] to wrap a success value, [Err] / [Errf] / [Errw] to wrap errors, +// and [Of] to bridge existing (value, error) return signatures: // // data := result.Of(os.ReadFile("cfg.json")).Expect("read config") // @@ -43,8 +63,8 @@ // // func run() error { // return result.Run(func() { -// port := parsePort(cfg.Port).Expect("load config port") -// _ = port // happy path continues … +// host := parseHost(cfg.Host).Expect("load config host") +// _ = host // happy path continues … // }) // } // @@ -55,8 +75,8 @@ // // func load() (err error) { // defer result.Catch(&err) -// port := parsePort(cfg.Port).Expect("load config port") -// _ = port +// host := parseHost(cfg.Host).Expect("load config host") +// _ = host // return // } // diff --git a/pkg/result/example_test.go b/pkg/result/example_test.go index 500bf4a..84941c3 100644 --- a/pkg/result/example_test.go +++ b/pkg/result/example_test.go @@ -19,7 +19,7 @@ func parseHost(s string) result.Expect[string] { func parsePort(s string) result.Expect[int] { port := result.Of(strconv.Atoi(s)) if port.Err() != nil { - return port + return result.Errw[int](port.Err(), "result.Of") } if port.Value() < 1 || port.Value() > 65535 { return result.Errf[int]("%d out of range", port.Value()) @@ -27,15 +27,23 @@ func parsePort(s string) result.Expect[int] { return port } +// getURL is an example of business logic implementation with emphasis on happy-path. +func getURL(host, port string) string { + mHost := parseHost(host).Expect("parse host") + mPort := parsePort(port).Expect("parse port") + return fmt.Sprintf("http://%s:%d", mHost, mPort) +} + // Example_errCheck shows checking the error without panicking — useful at the // outermost boundary where you want a normal error return. func Example_errCheck() { r := parsePort("not-a-number") if r.Err() != nil { - fmt.Println("parsePort failed:", r.Err()) + fmt.Println("failed:", r.Err()) } // Output: - // parsePort failed: strconv.Atoi: parsing "not-a-number": invalid syntax + // failed: example_test.go:22: result.Of + // strconv.Atoi: parsing "not-a-number": invalid syntax } // Example_happyPath shows the basic happy-path pattern: call Expect at each @@ -59,16 +67,12 @@ func Example_catch() { if err := loadHost(); err != nil { fmt.Println("caught:", err) + // NOTE: you can have stack trace of an error as well + // fmt.Println("stacktrace:", result.StackTrace(err)) } // Output: - // caught: read host: host must not be empty -} - -// getURL is an example of business logic implementation with emphasis on happy-path. -func getURL(host, port string) string { - mHost := parseHost(host).Expect("parse host") - mPort := parsePort(port).Expect("parse port") - return fmt.Sprintf("http://%s:%d", mHost, mPort) + // caught: read host + // example_test.go:13: host must not be empty } // Example_run shows result.Run as a lightweight synchronous boundary — a concise @@ -101,7 +105,8 @@ func Example_runtimeError() { fmt.Println("caught:", err) } // Output: - // caught: arg 2 port value: 99999 out of range + // caught: arg 2 port value + // example_test.go:25: 99999 out of range } // Example_go is like result.Run but returns a typed Expect[T] so the computed @@ -109,9 +114,9 @@ func Example_runtimeError() { func Example_go() { url := result.Go(func() string { return getURL("localhost", "8080") - }) + }).Must() - fmt.Println(url.Expect("get url")) + fmt.Println(url) // Output: // http://localhost:8080 } @@ -127,7 +132,8 @@ func Example_goError() { fmt.Println("failed:", err) } // Output: - // failed: parse port: 99999 out of range + // failed: parse port + // example_test.go:25: 99999 out of range } // Example_unwrap shows re-joining the normal Go (value, error) world at a diff --git a/pkg/result/expect_goexit.go b/pkg/result/expect_goexit.go index 19e856a..d0ac3c0 100644 --- a/pkg/result/expect_goexit.go +++ b/pkg/result/expect_goexit.go @@ -15,13 +15,12 @@ package result import ( - "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. +// gErrors stores errors set by exitGoroutine before calling runtime.Goexit, +// keyed by goroutine ID. Entries are consumed by the enclosing Async call. var gErrors sync.Map // goroutineID returns the current goroutine's numeric ID by parsing the first @@ -40,80 +39,18 @@ func goroutineID() uint64 { 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 +// exitGoroutine stores se in gErrors and exits the goroutine via Goexit. +// The stored error is retrieved by collectGoexitFailure in the enclosing Async. +func exitGoroutine(se *stackError) { + gErrors.Store(goroutineID(), se) + runtime.Goexit() } -// Expectf is like [Expect.Expect] but accepts a fmt.Sprintf-style format string -// for the context message. The wrapped error is always appended as ": ". -// -// 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() +// collectGoexitFailure retrieves any failure stored by exitGoroutine for the +// current goroutine, consuming the entry. +func collectGoexitFailure() error { + if stored, ok := gErrors.LoadAndDelete(goroutineID()); ok { + return stored.(error) } - 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 <- Err[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 <- Err[T](stored.(error)) - } else { - ch <- Errf[T]("goroutine exited unexpectedly") - } - }() - val = fn() - finished = true - }() - return ch + return nil } diff --git a/pkg/result/expect_panic.go b/pkg/result/expect_panic.go index f769b92..2245c9b 100644 --- a/pkg/result/expect_panic.go +++ b/pkg/result/expect_panic.go @@ -28,77 +28,10 @@ package result -import ( - "fmt" -) +// exitGoroutine signals a result failure by panicking with se. +// The panic is caught by the enclosing Async goroutine via recover(). +func exitGoroutine(se *stackError) { panic(se) } -// 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 ": ". -// -// 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 <- Err[T](se) - return - } - panic(v) // genuine runtime panic — crash the program - } - if finished { - ch <- Ok(val) - return - } - ch <- Errf[T]("goroutine exited unexpectedly") - }() - val = fn() - finished = true - }() - return ch -} +// collectGoexitFailure is a no-op in the panic build — failures travel via +// panic/recover, not via the gErrors map. +func collectGoexitFailure() error { return nil } diff --git a/pkg/result/panic_test.go b/pkg/result/panic_test.go index 914d9f9..cee3fa8 100644 --- a/pkg/result/panic_test.go +++ b/pkg/result/panic_test.go @@ -22,7 +22,7 @@ func TestMustCollected(t *testing.T) { if err == nil { t.Fatal("expected error, got nil") } - if err.Error() != "unrecoverable" { + if !strings.Contains(err.Error(), "unrecoverable") { t.Fatalf("unexpected error: %v", err) } } diff --git a/pkg/result/result.go b/pkg/result/result.go index 788bc3f..36c5876 100644 --- a/pkg/result/result.go +++ b/pkg/result/result.go @@ -3,11 +3,17 @@ package result import ( "errors" "fmt" + "path/filepath" "runtime" "strings" ) // Expect holds either a value of type T or an error. +// +// Fields are unexported to prevent external mutation (e.g. r.Err = nil silently +// turning a failure into an apparent success). Methods provide a read-only view +// of the same data with no meaningful performance difference — trivial getters +// are inlined by the compiler. type Expect[T any] struct { value T err error @@ -49,9 +55,26 @@ func Err[T any](err error) Expect[T] { } // Errf wraps a formatted error in an Expect. It is a convenience shorthand -// for [Err][fmt.Errorf(format, args...)]. +// for [Err][fmt.Errorf(format, args...)]. The caller's file and line are +// prepended to the error message automatically. func Errf[T any](format string, args ...any) Expect[T] { - return Expect[T]{err: fmt.Errorf(format, args...)} + _, file, line, _ := runtime.Caller(1) + loc := fmt.Sprintf("%s:%d", filepath.Base(file), line) + return Expect[T]{err: fmt.Errorf(loc+": "+format, args...)} +} + +// Errw wraps an existing error with a context message, following the standard +// Go error-propagation convention (errors.Is/As chain is preserved). Each +// wrapping level is placed on its own line so the full error reads as a +// top-down trace: outermost context first, root cause last. The caller's file +// and line are prepended automatically. +// +// main.go:42: load config +// logger.go:35: parse log level +// strconv.Atoi: parsing "x": invalid syntax +func Errw[T any](err error, format string, args ...any) Expect[T] { + _, file, line, _ := runtime.Caller(1) + return Expect[T]{err: fmt.Errorf("%s:%d: %s\n%w", filepath.Base(file), line, fmt.Sprintf(format, args...), err)} } // Of is a convenience constructor that bridges standard Go (value, error) @@ -68,7 +91,9 @@ func (r Expect[T]) Err() error { } // Value returns the underlying value, or the zero value of T if the Expect -// holds an error. Always check [Expect.Err] first. +// holds an error. Mirrors the (value, error) convention: the caller is trusted +// to check [Expect.Err] first, just as they would check the error return in +// standard Go code. func (r Expect[T]) Value() T { return r.value } @@ -89,6 +114,81 @@ func (r Expect[T]) Unwrap() (T, error) { return r.value, r.err } +// Expect returns the value or exits the current goroutine with the wrapped +// error annotated with msg. The failure is collected by the enclosing [Go] or +// [Run] call as a normal Go error. A 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 { + exitGoroutine(&stackError{ + err: fmt.Errorf("%s\n%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. +// +// data := Parse(raw).Expectf("parse user input id=%d", id) +func (r Expect[T]) Expectf(format string, args ...any) T { + if r.err != nil { + exitGoroutine(&stackError{ + err: fmt.Errorf("%s\n%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 { + ch <- Err[T](se) // Expect/Expectf/Must failure + return + } + panic(v) // genuine runtime panic — crash the program + } + if finished { + ch <- Ok(val) + return + } + if err := collectGoexitFailure(); err != nil { + ch <- Err[T](err) + } else { + ch <- Errf[T]("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.