diff --git a/README.md b/README.md index 34613f8..2d63b5c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ an injected signal cancels work before teardown. One shared shutdown deadline abandons shutdown. Callbacks must cooperate: abandonment cannot kill goroutines, and the caller owns process exit and any unreleased resources. +During App-initiated shutdown, callback errors containing only the callback's +context cancellation are omitted. Mixed cancellation and independent failures +are retained intact to preserve wrapper types, identity and custom matching; +these errors may still match cancellation through `errors.Is`. + The caller owns signal subscription, SIGINT/SIGTERM filtering, a channel buffered for at least two signals, and subscription cleanup. Nil signals disable reception. There are no signal subscriptions, process exits, external dependencies or CLI diff --git a/lifecycle/lifecycle.go b/lifecycle/lifecycle.go index 4c50f5a..82f2a31 100644 --- a/lifecycle/lifecycle.go +++ b/lifecycle/lifecycle.go @@ -131,44 +131,32 @@ func (a *App) stopLocked() { a.notifyLocked() } -// remaining removes only cancellation leaves caused by established shutdown, -// preserving independent components even inside a wrapped errors.Join. -func remaining(err, cancellation error) (error, bool) { +// cancellationOnly recognizes expected cancellation without rebuilding error trees. +func cancellationOnly(err, cancellation error) bool { if err == nil { - return nil, false + return false } if err == cancellation { - return nil, true + return true } if many, ok := err.(interface{ Unwrap() []error }); ok { - var kept []error - changed := false - for _, child := range many.Unwrap() { - e, removed := remaining(child, cancellation) - changed = changed || removed - if e != nil { - kept = append(kept, e) + children := many.Unwrap() + for _, child := range children { + if !cancellationOnly(child, cancellation) { + return false } } - if changed { - return errors.Join(kept...), true - } + return len(children) > 0 } if one, ok := err.(interface{ Unwrap() error }); ok { - kept, changed := remaining(one.Unwrap(), cancellation) - if changed { - if kept == nil { - return nil, true - } - return fmt.Errorf("%s: %w", err.Error(), kept), true - } + return cancellationOnly(one.Unwrap(), cancellation) } - return err, false + return false } func (a *App) recordLocked(ctx context.Context, phase, name string, err error) { - if a.stopping && context.Cause(ctx) == a.shutdownCause { - err, _ = remaining(err, ctx.Err()) + if a.stopping && context.Cause(ctx) == a.shutdownCause && cancellationOnly(err, ctx.Err()) { + err = nil } if err != nil { a.errs = append(a.errs, fmt.Errorf("%s %q: %w", phase, name, err)) @@ -196,7 +184,9 @@ func (a *App) launchLocked(ctx context.Context, phase, name string, fn func(cont // In-flight setup is awaited within that budget before rollback; abandoned setup // is never torn down later. Deadline or second signal abandons outstanding work. // Observed failures are joined; expected callback cancellation during established -// shutdown is omitted, while initiating failures and every teardown error remain. +// shutdown is omitted only for cancellation-only error trees. Mixed errors remain +// intact to preserve wrapper types, identity and matching, so may still match +// cancellation. Initiating failures and every teardown error remain. // Cancellation is omitted only when context.Cause proves App caused it; parent // propagation and independently canceled descendants are retained conservatively. // Run never recovers panics, closes Signals or exits the process. diff --git a/lifecycle/lifecycle_test.go b/lifecycle/lifecycle_test.go index fb9b63b..1b0804d 100644 --- a/lifecycle/lifecycle_test.go +++ b/lifecycle/lifecycle_test.go @@ -541,7 +541,75 @@ func TestMixedShutdownErrorPreservesCause(t *testing.T) { if !errors.As(err, &pathErr) || pathErr != independent || !errors.Is(err, io.ErrUnexpectedEOF) { t.Fatalf("independent cause lost: %v", err) } - if errors.Is(err, context.Canceled) { - t.Fatalf("expected cancellation retained: %v", err) + if !errors.Is(err, context.Canceled) { + t.Fatalf("mixed wrapper was reconstructed: %v", err) + } +} + +// matchingError exercises wrapper-owned matching independently of its child. +type matchingError struct { + child error + match error +} + +func (e *matchingError) Error() string { return "custom: " + e.child.Error() } +func (e *matchingError) Unwrap() error { return e.child } +func (e *matchingError) Is(target error) bool { return target == e.match } + +func TestMixedShutdownErrorPreservesWrapper(t *testing.T) { + for _, kind := range []string{"path", "custom"} { + t.Run(kind, func(t *testing.T) { + signals := make(chan os.Signal, 2) + a := newApp(t, signals, time.Second) + ready := make(chan struct{}) + match := errors.New("custom match") + var original error + add(t, a, Module{Name: "mixed", Run: func(ctx context.Context) error { + close(ready) + <-ctx.Done() + joined := errors.Join(ctx.Err(), io.ErrUnexpectedEOF) + if kind == "path" { + original = &os.PathError{Op: "read", Path: "queue", Err: joined} + } else { + original = &matchingError{child: joined, match: match} + } + return original + }}) + done := run(a, context.Background()) + wait(t, ready) + signals <- syscall.SIGTERM + err := receive(t, done) + if !errors.Is(err, original) || !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("wrapper identity or inner failure lost: %v", err) + } + if kind == "path" { + var pathErr *os.PathError + if !errors.As(err, &pathErr) || pathErr != original { + t.Fatalf("typed wrapper lost: %v", err) + } + } else { + var custom *matchingError + if !errors.As(err, &custom) || custom != original || !errors.Is(err, match) { + t.Fatalf("custom matching lost: %v", err) + } + } + }) + } +} + +func TestCancellationOnly(t *testing.T) { + for _, err := range []error{ + context.Canceled, + fmt.Errorf("wrapped: %w", context.Canceled), + &os.PathError{Op: "read", Path: "queue", Err: errors.Join(context.Canceled, fmt.Errorf("wrapped: %w", context.Canceled))}, + } { + if !cancellationOnly(err, context.Canceled) { + t.Errorf("expected cancellation not recognized: %v", err) + } + } + for _, err := range []error{nil, io.ErrUnexpectedEOF, context.DeadlineExceeded, errors.Join(context.Canceled, io.ErrUnexpectedEOF)} { + if cancellationOnly(err, context.Canceled) { + t.Errorf("independent error classified as cancellation: %v", err) + } } }