package lifecycle_test import ( "context" "log/slog" "sync" "testing" "time" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/lifecycle" ) // waitTimeout is the stop budget the timeout case gives a // goroutine that never returns. The test's own patience is the // go test deadline, so the only thing this value affects is how // long the case takes. const waitTimeout = 100 * time.Millisecond func discardLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } func TestWaitForShutdown_DrainedGroup(t *testing.T) { t.Parallel() var wg sync.WaitGroup wg.Go(func() {}) require.NoError( t, lifecycle.WaitForShutdown( context.Background(), discardLogger(), "test component", &wg, ), ) } // racePasses is how many times the both-cases-ready race is run. // Without the preamble each pass is an independent coin flip, so // the probability of the whole loop passing by luck is 2^-N: at // this N the test is deterministic in practice, and it involves no // wall-clock waiting at all. const racePasses = 1000 // TestWaitDone_DrainedBeforeExpiredContext covers the case where a // component drained cleanly but the stop context had already // expired. Both select cases are ready, and Go chooses among ready // cases uniformly at random, so the drained case must be settled by // the preamble before the bounded select ever runs. func TestWaitDone_DrainedBeforeExpiredContext(t *testing.T) { t.Parallel() done := make(chan struct{}) close(done) ctx, cancel := context.WithCancel(context.Background()) cancel() for pass := range racePasses { require.NoErrorf( t, lifecycle.WaitDone( ctx, discardLogger(), "test component", done, ), "pass %d reported a timeout for a drained component", pass, ) } } // TestWaitDone_ExpiredContext pins the other side of the preamble: // an expired context with a component that has not drained is still // a timeout. func TestWaitDone_ExpiredContext(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) cancel() err := lifecycle.WaitDone( ctx, discardLogger(), "test component", make(chan struct{}), ) require.ErrorIs(t, err, context.Canceled) require.ErrorContains(t, err, "test component") } func TestWaitForShutdown_ContextExpires(t *testing.T) { t.Parallel() release := make(chan struct{}) t.Cleanup(func() { close(release) }) var wg sync.WaitGroup wg.Go(func() { <-release }) ctx, cancel := context.WithTimeout( context.Background(), waitTimeout, ) defer cancel() err := lifecycle.WaitForShutdown( ctx, discardLogger(), "test component", &wg, ) require.ErrorIs(t, err, context.DeadlineExceeded) require.ErrorContains(t, err, "test component") }