fix(lifecycle): preserve mixed shutdown error identity
This commit is contained in:
1 parent
b9a5fa8bd8
commit
64c7e6b65d
3 files changed
+91
-28
No files matched your search
@@ -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
@@ -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 false
|
||||||
}
|
|
||||||
}
|
|
||||||
return err, 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user