fix(lifecycle): retain parent cancellation at every shutdown return
This commit is contained in:
1 parent
64c7e6b65d
commit
f4abb626c6
2 files changed
+71
-11
No files matched your search
@@ -238,7 +238,7 @@ func (a *App) Run(parent context.Context) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
signals = nil
|
signals = nil
|
||||||
} else if err := receiveSignal(sig); err != nil {
|
} else if err := receiveSignal(sig); err != nil {
|
||||||
return a.result(err)
|
return a.result(parent, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
default:
|
default:
|
||||||
@@ -246,7 +246,6 @@ func (a *App) Run(parent context.Context) error {
|
|||||||
|
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
if !a.stopping && parent.Err() != nil {
|
if !a.stopping && parent.Err() != nil {
|
||||||
a.errs = append(a.errs, parent.Err())
|
|
||||||
a.stopLocked()
|
a.stopLocked()
|
||||||
parentDone = nil
|
parentDone = nil
|
||||||
}
|
}
|
||||||
@@ -288,7 +287,7 @@ func (a *App) Run(parent context.Context) error {
|
|||||||
|
|
||||||
if stopping {
|
if stopping {
|
||||||
if err := shutdown.Err(); err != nil {
|
if err := shutdown.Err(); err != nil {
|
||||||
return a.result(err)
|
return a.result(parent, err)
|
||||||
}
|
}
|
||||||
if setupDone == nil && teardownDone == nil {
|
if setupDone == nil && teardownDone == nil {
|
||||||
if teardownIndex >= 0 {
|
if teardownIndex >= 0 {
|
||||||
@@ -297,7 +296,7 @@ func (a *App) Run(parent context.Context) error {
|
|||||||
teardownDone = result
|
teardownDone = result
|
||||||
go func() { result <- m.Teardown(shutdown) }()
|
go func() { result <- m.Teardown(shutdown) }()
|
||||||
} else if active == 0 {
|
} else if active == 0 {
|
||||||
return a.result(nil)
|
return a.result(parent, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,7 +324,6 @@ func (a *App) Run(parent context.Context) error {
|
|||||||
case <-a.wake:
|
case <-a.wake:
|
||||||
case <-parentDone:
|
case <-parentDone:
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.errs = append(a.errs, parent.Err())
|
|
||||||
a.stopLocked()
|
a.stopLocked()
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
parentDone = nil
|
parentDone = nil
|
||||||
@@ -335,16 +333,17 @@ func (a *App) Run(parent context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := receiveSignal(sig); err != nil {
|
if err := receiveSignal(sig); err != nil {
|
||||||
return a.result(err)
|
return a.result(parent, err)
|
||||||
}
|
}
|
||||||
case <-deadline:
|
case <-deadline:
|
||||||
return a.result(context.DeadlineExceeded)
|
return a.result(parent, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) result(extra error) error {
|
func (a *App) result(parent context.Context, extra error) error {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
return errors.Join(append(a.errs, extra)...)
|
// invariant: every completion and abandonment retains parent cancellation exactly once.
|
||||||
|
return errors.Join(append(a.errs, parent.Err(), extra)...)
|
||||||
}
|
}
|
||||||
@@ -380,17 +380,24 @@ func TestAdmissionRace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIndependentDescendantCancellation(t *testing.T) {
|
func TestIndependentDescendantCancellation(t *testing.T) {
|
||||||
|
for _, mixed := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "cancellation-only", true: "mixed"}[mixed], func(t *testing.T) {
|
||||||
signals := make(chan os.Signal, 2)
|
signals := make(chan os.Signal, 2)
|
||||||
a := newApp(t, signals, time.Second)
|
a := newApp(t, signals, time.Second)
|
||||||
canceled := make(chan struct{})
|
canceled := make(chan struct{})
|
||||||
allowReturn := make(chan struct{})
|
allowReturn := make(chan struct{})
|
||||||
|
other := errors.New("other")
|
||||||
add(t, a, Module{Name: "owner", Setup: func(ctx context.Context) error {
|
add(t, a, Module{Name: "owner", Setup: func(ctx context.Context) error {
|
||||||
child, cancel := context.WithCancel(ctx)
|
child, cancel := context.WithCancel(ctx)
|
||||||
err := a.Go(child, "independent", func(ctx context.Context) error {
|
err := a.Go(child, "independent", func(ctx context.Context) error {
|
||||||
cancel()
|
cancel()
|
||||||
close(canceled)
|
close(canceled)
|
||||||
<-allowReturn
|
<-allowReturn
|
||||||
return fmt.Errorf("worker result: %w", errors.Join(ctx.Err(), errors.New("other")))
|
err := ctx.Err()
|
||||||
|
if mixed {
|
||||||
|
err = errors.Join(err, other)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("worker result: %w", err)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
@@ -400,9 +407,63 @@ func TestIndependentDescendantCancellation(t *testing.T) {
|
|||||||
done := run(a, context.Background())
|
done := run(a, context.Background())
|
||||||
wait(t, canceled)
|
wait(t, canceled)
|
||||||
signals <- syscall.SIGTERM
|
signals <- syscall.SIGTERM
|
||||||
if err := receive(t, done); !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "other") {
|
if err := receive(t, done); !errors.Is(err, context.Canceled) || errors.Is(err, other) != mixed {
|
||||||
t.Fatalf("independent cancellation lost: %v", err)
|
t.Fatalf("independent cancellation lost: %v", err)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParentCancellationDuringShutdown(t *testing.T) {
|
||||||
|
for _, boundary := range []string{"completion", "deadline", "second-signal"} {
|
||||||
|
t.Run(boundary, func(t *testing.T) {
|
||||||
|
iterations := 1
|
||||||
|
if boundary == "completion" {
|
||||||
|
iterations = 1000
|
||||||
|
}
|
||||||
|
for range iterations {
|
||||||
|
parent, cancel := context.WithCancel(context.Background())
|
||||||
|
signals := make(chan os.Signal, 2)
|
||||||
|
timeout := time.Second
|
||||||
|
if boundary == "deadline" {
|
||||||
|
timeout = 20 * time.Millisecond
|
||||||
|
}
|
||||||
|
a := newApp(t, signals, timeout)
|
||||||
|
exited := make(chan struct{})
|
||||||
|
add(t, a, Module{Name: "last", Teardown: func(ctx context.Context) error {
|
||||||
|
defer close(exited)
|
||||||
|
cancel()
|
||||||
|
switch boundary {
|
||||||
|
case "deadline":
|
||||||
|
<-ctx.Done()
|
||||||
|
case "second-signal":
|
||||||
|
signals <- syscall.SIGINT
|
||||||
|
signals <- syscall.SIGTERM
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}})
|
||||||
|
err := a.Run(parent)
|
||||||
|
cancel()
|
||||||
|
wait(t, exited)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("parent cancellation lost: %v", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) != (boundary == "deadline") {
|
||||||
|
t.Fatalf("unexpected shutdown result: %v", err)
|
||||||
|
}
|
||||||
|
parentErrors := 0
|
||||||
|
for _, child := range err.(interface{ Unwrap() []error }).Unwrap() {
|
||||||
|
if child == context.Canceled {
|
||||||
|
parentErrors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parentErrors != 1 {
|
||||||
|
t.Fatalf("parent error recorded %d times: %v", parentErrors, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSuccessfulInterruptedSetupRollsBack(t *testing.T) {
|
func TestSuccessfulInterruptedSetupRollsBack(t *testing.T) {
|
||||||
|
|||||||
Reference in new issue
Block a user