Skip to content

Commit 1c5ae8c

Browse files
committed
feat(condition): CollectT now distinguishes between FailNow and Cancel
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent 60195c6 commit 1c5ae8c

File tree

2 files changed

+129
-28
lines changed

2 files changed

+129
-28
lines changed

internal/assertions/condition.go

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,13 @@ func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick t
222222
// If the condition is not met before the timeout, the collected errors from the
223223
// last tick are copied to t.
224224
//
225-
// Calling [CollectT.FailNow] cancels the condition immediately and causes the assertion to fail.
225+
// Calling [CollectT.FailNow] (directly, or transitively through [require] assertions)
226+
// fails the current tick only: the poller will retry on the next tick. This means
227+
// [require]-style assertions inside [EventuallyWith] behave naturally — they abort
228+
// the current evaluation and let the polling loop converge.
229+
//
230+
// To abort the whole assertion immediately (e.g. when the condition can no longer
231+
// be expected to succeed), call [CollectT.Cancel].
226232
//
227233
// # Usage
228234
//
@@ -246,10 +252,15 @@ func Consistently[C Conditioner](t T, condition C, timeout time.Duration, tick t
246252
// The condition function is never executed in parallel: only one goroutine executes it.
247253
// It may write to variables outside its scope without triggering race conditions.
248254
//
255+
// The condition is wrapped in its own goroutine, so a call to [runtime.Goexit]
256+
// (e.g. via [require] assertions or [CollectT.FailNow]) cleanly aborts only the
257+
// current tick.
258+
//
249259
// # Examples
250260
//
251261
// success: func(c *CollectT) { True(c,true) }, 100*time.Millisecond, 20*time.Millisecond
252262
// failure: func(c *CollectT) { False(c,true) }, 100*time.Millisecond, 20*time.Millisecond
263+
// failure: func(c *CollectT) { c.Cancel() }, 100*time.Millisecond, 20*time.Millisecond
253264
func EventuallyWith[C CollectibleConditioner](t T, condition C, timeout time.Duration, tick time.Duration, msgAndArgs ...any) bool {
254265
// Domain: condition
255266
if h, ok := t.(H); ok {
@@ -559,14 +570,12 @@ func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failF
559570
return // timeout = success
560571
case fn := <-p.conditionChan:
561572
var conditionWg sync.WaitGroup
562-
conditionWg.Add(1)
563-
go func() { // guards against the condition issue an early GoExit
564-
defer conditionWg.Done()
573+
conditionWg.Go(func() { // guards against the condition issue an early GoExit
565574

566575
if err := fn(ctx); err != nil {
567576
close(p.doneChan) // (condition true <=> returns error) = failure for Never and Consistently
568577
}
569-
}()
578+
})
570579
conditionWg.Wait()
571580

572581
select {
@@ -590,14 +599,12 @@ func (p *conditionPoller) executeCondition(parentCtx, ctx context.Context, failF
590599
return
591600
case fn := <-p.conditionChan:
592601
var conditionWg sync.WaitGroup
593-
conditionWg.Add(1)
594-
go func() { // guards against the condition issue an early GoExit
595-
defer conditionWg.Done()
602+
conditionWg.Go(func() { // guards against the condition issue an early GoExit
596603

597604
if err := fn(ctx); err == nil {
598605
close(p.doneChan) // (condition true <=> err == nil) = success for Eventually
599606
}
600-
}()
607+
})
601608
conditionWg.Wait()
602609

603610
select {
@@ -684,6 +691,15 @@ func (p *conditionPoller) cancellableContext(parentCtx context.Context, timeout
684691
return ctx, cancel
685692
}
686693

694+
// Sentinel errors recorded by [CollectT.FailNow] and [CollectT.Cancel].
695+
// Kept package-private: callers should rely on observable behavior, not on
696+
// the marker shape. They are distinguishable so future tooling can tell apart
697+
// "tick aborted by require" from "user explicitly cancelled the assertion".
698+
var (
699+
errFailNow = errors.New("collect: failed now (tick aborted)")
700+
errCancelled = errors.New("collect: cancelled (assertion aborted)")
701+
)
702+
687703
// CollectT implements the [T] interface and collects all errors.
688704
//
689705
// [CollectT] is specifically intended to be used with [EventuallyWith] and
@@ -692,16 +708,20 @@ type CollectT struct {
692708
// Domain: condition
693709
//
694710
// Maintainer:
695-
// 1. FailNow() no longer just exits the go routine, but cancels the context of the caller instead before exiting.
696-
// 2. We no longer establish the distinction between c.error nil or empty. Non-empty is an error, full stop.
697-
// 2. Deprecated methods have been removed.
711+
// 1. FailNow() exits the current tick goroutine via runtime.Goexit (matching
712+
// stretchr/testify semantics): require-style assertions abort the current
713+
// evaluation and the poller retries on the next tick. It does NOT cancel
714+
// the EventuallyWith context.
715+
// 2. Cancel() is the explicit escape hatch: it cancels the EventuallyWith
716+
// context before exiting via runtime.Goexit, aborting the whole assertion.
717+
// 3. We no longer establish the distinction between c.errors nil or empty.
718+
// Non-empty is an error, full stop.
719+
// 4. Deprecated methods have been removed.
698720

699721
// A slice of errors. Non-empty slice denotes a failure.
700-
// NOTE: When c.FailNow() is called, it cancels the context and exits the goroutine.
701-
// The "failed now" error is appended but may be lost if the goroutine exits before collection.
702722
errors []error
703723

704-
// cancelContext cancels the parent context on FailNow()
724+
// cancelContext cancels the parent EventuallyWith context on Cancel().
705725
cancelContext func()
706726
}
707727

@@ -713,13 +733,33 @@ func (c *CollectT) Errorf(format string, args ...any) {
713733
c.errors = append(c.errors, fmt.Errorf(format, args...))
714734
}
715735

716-
// FailNow records a failure and cancels the parent [EventuallyWith] context,
717-
// before exiting the current go routine with [runtime.Goexit].
736+
// FailNow records a failure for the current tick and exits the condition
737+
// goroutine via [runtime.Goexit].
738+
//
739+
// It does NOT cancel the [EventuallyWith] context: the poller will retry on
740+
// the next tick. If a later tick succeeds, the assertion succeeds. If no tick
741+
// ever succeeds before the timeout, the errors collected during the LAST tick
742+
// (the one which most recently called FailNow) are reported on the parent t.
718743
//
719-
// This causes the assertion to fail immediately without waiting for a timeout.
744+
// To abort the whole assertion immediately, use [CollectT.Cancel].
720745
func (c *CollectT) FailNow() {
746+
c.errors = append(c.errors, errFailNow)
747+
runtime.Goexit()
748+
}
749+
750+
// Cancel records a failure, cancels the [EventuallyWith] context, then exits
751+
// the condition goroutine via [runtime.Goexit].
752+
//
753+
// This aborts the whole assertion immediately, without waiting for the timeout.
754+
// The errors collected during the cancelled tick are reported on the parent t.
755+
//
756+
// Use this when the condition can no longer be expected to succeed (e.g. an
757+
// upstream resource has been observed in an unrecoverable state). For ordinary
758+
// per-tick failures (e.g. "value not yet ready"), use [CollectT.FailNow]
759+
// directly or transitively through [require] assertions.
760+
func (c *CollectT) Cancel() {
761+
c.errors = append(c.errors, errCancelled)
721762
c.cancelContext()
722-
c.errors = append(c.errors, errors.New("failed now")) // so c.failed() is true (currently lost as not owned by another go routine)
723763
runtime.Goexit()
724764
}
725765

internal/assertions/condition_test.go

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ func TestConditionEventuallyNoLeak(t *testing.T) {
294294
})
295295
}
296296

297+
//nolint:gocognit,gocyclo,cyclop // subtests are actually not complex
297298
func TestConditionEventuallyWith(t *testing.T) {
298299
t.Parallel()
299300

@@ -406,28 +407,88 @@ func TestConditionEventuallyWith(t *testing.T) {
406407
}
407408
})
408409

409-
t.Run("should fail with a call to collect.FailNow", func(t *testing.T) {
410+
t.Run("collect.FailNow only fails the current tick (poller retries)", func(t *testing.T) {
410411
t.Parallel()
411412

412413
mock := new(errorsCapturingT)
413-
counter := 0
414+
var counter int
415+
var mu sync.Mutex
414416

415-
// The call to FailNow cancels the execution context of EventuallyWith.
416-
// so we don't have to wait for the timeout.
417+
// FailNow on every tick: the poller must keep retrying until the timeout.
417418
condition := func(collect *CollectT) {
419+
mu.Lock()
418420
counter++
421+
mu.Unlock()
419422
collect.FailNow()
420423
}
421424

425+
if EventuallyWith(mock, condition, testTimeout, testTick) {
426+
t.Error("expected EventuallyWith to return false")
427+
}
428+
mu.Lock()
429+
got := counter
430+
mu.Unlock()
431+
if got < 2 {
432+
t.Errorf("expected the condition to be retried multiple times, got %d call(s)", got)
433+
}
434+
})
435+
436+
t.Run("collect.FailNow allows convergence on a later tick", func(t *testing.T) {
437+
t.Parallel()
438+
439+
mock := new(errorsCapturingT)
440+
var counter int
441+
var mu sync.Mutex
442+
443+
// First few ticks fail via FailNow, then converge.
444+
condition := func(collect *CollectT) {
445+
mu.Lock()
446+
counter++
447+
n := counter
448+
mu.Unlock()
449+
if n < 3 {
450+
collect.FailNow()
451+
}
452+
}
453+
454+
if !EventuallyWith(mock, condition, testTimeout, testTick) {
455+
t.Error("expected EventuallyWith to eventually return true")
456+
}
457+
if len(mock.errors) != 0 {
458+
t.Errorf("expected no errors reported on parent t after success, got %d: %v", len(mock.errors), mock.errors)
459+
}
460+
})
461+
462+
t.Run("collect.Cancel aborts the whole assertion immediately", func(t *testing.T) {
463+
t.Parallel()
464+
465+
mock := new(errorsCapturingT)
466+
var counter int
467+
var mu sync.Mutex
468+
469+
// Cancel must short-circuit: a 30-minute timeout must NOT be waited on.
470+
condition := func(collect *CollectT) {
471+
mu.Lock()
472+
counter++
473+
mu.Unlock()
474+
collect.Cancel()
475+
}
476+
477+
start := time.Now()
422478
if EventuallyWith(mock, condition, 30*time.Minute, testTick) {
423479
t.Error("expected EventuallyWith to return false")
424480
}
425-
const expectedErrors = 2
426-
if len(mock.errors) != expectedErrors {
427-
t.Errorf("expected %d errors (0 accumulated + 2 from EventuallyWith), got %d", expectedErrors, len(mock.errors))
481+
if elapsed := time.Since(start); elapsed > 5*time.Second {
482+
t.Errorf("expected Cancel to short-circuit, but EventuallyWith took %s", elapsed)
483+
}
484+
mu.Lock()
485+
got := counter
486+
mu.Unlock()
487+
if got != 1 {
488+
t.Errorf("expected the condition function to have been called only once, but got: %d", got)
428489
}
429-
if counter != 1 {
430-
t.Errorf("expected the condition function to have been called only once, but got: %d", counter)
490+
if len(mock.errors) == 0 {
491+
t.Error("expected at least one error reported on parent t after Cancel")
431492
}
432493
})
433494
}

0 commit comments

Comments
 (0)