@@ -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
253264func 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] .
720745func (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
0 commit comments