Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lifecycle/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ var errServiceCompleted = errors.New("service completed")
// The cleanup is guaranteed to run even if any step fails,
// using defer to ensure each started actor is properly stopped.
// The ctx parameter controls the long-running monitoring process and can be used to cancel the entire operation.
// Cancelling ctx is treated as a graceful shutdown request and Serve returns nil,
// whether the cancellation arrives during actor startup or while monitoring services.
func (lc *Lifecycle) Serve(ctx context.Context, ctors ...any) (xerr error) {
if !lc.served.CompareAndSwap(false, true) {
return errors.WithStack(ErrServed)
Expand Down Expand Up @@ -387,6 +389,15 @@ func (lc *Lifecycle) Serve(ctx context.Context, ctors ...any) (xerr error) {
logger.DebugContext(ctx, fmt.Sprintf("%s starting", actorType), "actor", actorName)

if err := actor.Start(ctx); err != nil {
// A shutdown request racing the startup sequence is a graceful
// shutdown, mirroring the run-phase handling of context.Canceled,
// not a startup failure. Checked via ctx.Err() rather than the
// returned error because the cancellation may be masked by
// intermediate layers (e.g. database drivers).
if errors.Is(ctx.Err(), context.Canceled) {
logger.InfoContext(ctx, fmt.Sprintf("%s startup aborted by shutdown request", actorType), "actor", actorName, "cause", err)
return nil
}
logger.ErrorContext(ctx, fmt.Sprintf("Failed to start %s", strings.ToLower(actorType)), "actor", actorName, "error", err)
return err
}
Expand Down
45 changes: 45 additions & 0 deletions lifecycle/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,51 @@ func TestServeConvenienceFunction(t *testing.T) {
require.ErrorIs(t, err, ErrNoServices)
}

// TestServeCancelDuringActorStartup covers the race where a shutdown request
// (context cancellation) arrives while an actor is still starting, e.g. a
// SIGTERM racing a startup I/O call. Startup must treat it as a graceful
// shutdown — mirroring the run-phase behavior — not as a startup failure.
func TestServeCancelDuringActorStartup(t *testing.T) {
t.Run("cancellation during startup is graceful shutdown", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

err := Serve(ctx,
func(lc *Lifecycle) string {
lc.Add(NewFuncService(func(ctx context.Context) error {
<-ctx.Done()
return nil
}).WithName("background-service"))
lc.Add(NewFuncActor(func(ctx context.Context) error {
// Shutdown is requested while this actor is starting.
cancel()
return ctx.Err()
}, nil).WithName("starting-actor"))
return "ok"
},
)
require.NoError(t, err)
})

t.Run("real startup failure is still reported", func(t *testing.T) {
startErr := errors.New("dial failed")

err := Serve(context.Background(),
func(lc *Lifecycle) string {
lc.Add(NewFuncService(func(ctx context.Context) error {
<-ctx.Done()
return nil
}).WithName("background-service"))
lc.Add(NewFuncActor(func(_ context.Context) error {
return startErr
}, nil).WithName("failing-actor"))
return "ok"
},
)
require.ErrorIs(t, err, startErr)
})
}

// TestBuilderMethods tests all builder methods for comprehensive coverage
func TestBuilderMethods(t *testing.T) {
lc := New()
Expand Down
Loading