fix(lifecycle): preserve mixed shutdown error identity

This commit is contained in:
end committed 2026-10-27 12:04:13 -07:00
1 parent b9a5fa8bd8
commit 64c7e6b65d
3 files changed
+91 -28

No files matched your search

+5
View File
@@ -35,6 +35,11 @@ an injected signal cancels work before teardown. One shared shutdown deadline
abandons shutdown. Callbacks must cooperate: abandonment cannot kill goroutines, abandons shutdown. Callbacks must cooperate: abandonment cannot kill goroutines,
and the caller owns process exit and any unreleased resources. 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 The caller owns signal subscription, SIGINT/SIGTERM filtering, a channel buffered
for at least two signals, and subscription cleanup. Nil signals disable reception. for at least two signals, and subscription cleanup. Nil signals disable reception.
There are no signal subscriptions, process exits, external dependencies or CLI There are no signal subscriptions, process exits, external dependencies or CLI
+16 -26
View File
@@ -131,44 +131,32 @@ func (a *App) stopLocked() {
a.notifyLocked() a.notifyLocked()
} }
// remaining removes only cancellation leaves caused by established shutdown, // cancellationOnly recognizes expected cancellation without rebuilding error trees.
// preserving independent components even inside a wrapped errors.Join. func cancellationOnly(err, cancellation error) bool {
func remaining(err, cancellation error) (error, bool) {
if err == nil { if err == nil {
return nil, false return false
} }
if err == cancellation { if err == cancellation {
return nil, true return true
} }
if many, ok := err.(interface{ Unwrap() []error }); ok { if many, ok := err.(interface{ Unwrap() []error }); ok {
var kept []error children := many.Unwrap()
changed := false for _, child := range children {
for _, child := range many.Unwrap() { if !cancellationOnly(child, cancellation) {
e, removed := remaining(child, cancellation) return false
changed = changed || removed
if e != nil {
kept = append(kept, e)
} }
} }
if changed { return len(children) > 0
return errors.Join(kept...), true
}
} }
if one, ok := err.(interface{ Unwrap() error }); ok { if one, ok := err.(interface{ Unwrap() error }); ok {
kept, changed := remaining(one.Unwrap(), cancellation) return cancellationOnly(one.Unwrap(), cancellation)
if changed {
if kept == nil {
return nil, true
}
return fmt.Errorf("%s: %w", err.Error(), kept), true
}
} }
return err, false return false
} }
func (a *App) recordLocked(ctx context.Context, phase, name string, err error) { func (a *App) recordLocked(ctx context.Context, phase, name string, err error) {
if a.stopping && context.Cause(ctx) == a.shutdownCause { if a.stopping && context.Cause(ctx) == a.shutdownCause && cancellationOnly(err, ctx.Err()) {
err, _ = remaining(err, ctx.Err()) err = nil
} }
if err != nil { if err != nil {
a.errs = append(a.errs, fmt.Errorf("%s %q: %w", phase, name, err)) 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 // In-flight setup is awaited within that budget before rollback; abandoned setup
// is never torn down later. Deadline or second signal abandons outstanding work. // is never torn down later. Deadline or second signal abandons outstanding work.
// Observed failures are joined; expected callback cancellation during established // 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 // Cancellation is omitted only when context.Cause proves App caused it; parent
// propagation and independently canceled descendants are retained conservatively. // propagation and independently canceled descendants are retained conservatively.
// Run never recovers panics, closes Signals or exits the process. // Run never recovers panics, closes Signals or exits the process.
+70 -2
View File
@@ -541,7 +541,75 @@ func TestMixedShutdownErrorPreservesCause(t *testing.T) {
if !errors.As(err, &pathErr) || pathErr != independent || !errors.Is(err, io.ErrUnexpectedEOF) { if !errors.As(err, &pathErr) || pathErr != independent || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("independent cause lost: %v", err) t.Fatalf("independent cause lost: %v", err)
} }
if errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {
t.Fatalf("expected cancellation retained: %v", err) 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)
}
} }
} }