Change:
While trying to update to use latest version of package and to migrate away from deprecated , function, our tests fail as it seems new function immediately executes given condition twice, which is not mentioned anywhere. Result of executed test specified below: Since has been deprecated and it recommends using , I expected it to have the equivalent semantics to allow easy migration from deprecated functions, as no changelog or documentation mentions new semantics. Playground: Local: file with the following content: as . No response_ <details</details<details</details<details</details<details</details<details</details<details</details
/sig api-machinery
CC since you were involved in
/assign /triage accepted
I'd like to work on this one if it's allowed I think this is a simple fix with few lines removed: i.e.: instead of only wait a loop if immediate is false, we wait a time loop after special execution also if the immediate is true.
/assign
/assign

Discussion:
var timeCh <-chan time.Time doneCh := ctx.Done() <ins> if !sliding { timeCh = t.C() } </ins> // if immediate is true the condition is // guaranteed to be executed at least once, // if we haven't requested immediate execution, delay once

}(); err != nil || ok { return err } <del> } else { </del> <ins> } if sliding { </ins> timeCh = t.C() <ins> } for { // Wait for either the context to be cancelled or the next invocation be called </ins> select { case <-doneCh: return ctx.Err() case <-timeCh: } <del> } for { // checking ctx.Err() is slightly faster than checking a select </del> <ins> // IMPORTANT: Because there is no channel priority selection in golang // it is possible for very short timers to "win" the race in the previous select // repeatedly even when the context has been canceled.  We therefore must // explicitly check for context cancellation on every loop and exit if true to // guarantee that we don't invoke condition more than once after context has // been cancelled. </ins> if err := ctx.Err(); err != nil { return err }

if sliding { t.Next() } <del> if timeCh == nil { timeCh = t.C() } // NOTE: b/c there is no priority selection in golang // it is possible for this to race, meaning we could // trigger t.C and doneCh, and t.C select falls through. // In order to mitigate we re-check doneCh at the beginning // of every loop to guarantee at-most one extra execution // of condition. select { case <-doneCh: return ctx.Err() case <-timeCh: } </del> } }

cancelContextAfter int attemptsExpected   int errExpected        error <ins> timer              Timer </ins> }{ { name: "condition successful is only one attempt",

attemptsExpected: 0, errExpected:      context.DeadlineExceeded, }, <ins> { name:      "context canceled before the second execution and immediate", immediate: true, context: func() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), time.Second) }, callback: func(attempts int) (bool, error) { return false, nil }, attemptsExpected: 1, errExpected:      context.DeadlineExceeded, timer:            Backoff{Duration: 2 * time.Second}.Timer(), }, { name:      "immediate and long duration of condition and sliding false", immediate: true, sliding:   false, context: func() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), time.Second) }, callback: func(attempts int) (bool, error) { if attempts >= 4 { return true, nil } time.Sleep(time.Second / 5) return false, nil }, attemptsExpected: 4, timer:            Backoff{Duration: time.Second / 5, Jitter: 0.001}.Timer(), }, { name:      "immediate and long duration of condition and sliding true", immediate: true, sliding:   true, context: func() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), time.Second) }, callback: func(attempts int) (bool, error) { if attempts >= 4 { return true, nil } time.Sleep(time.Second / 5) return false, nil }, errExpected:      context.DeadlineExceeded, attemptsExpected: 3, timer:            Backoff{Duration: time.Second / 5, Jitter: 0.001}.Timer(), }, </ins> } for _, test := range tests { <del> for _, immediate := range []bool{true, false} { t.Run(fmt.Sprintf("immediate=%t", immediate), func(t *testing.T) { for _, sliding := range []bool{true, false} { t.Run(fmt.Sprintf("sliding=%t", sliding), func(t *testing.T) { t.Run(test.name, func(t *testing.T) { contextFn := test.context if contextFn == nil { contextFn = defaultContext } ctx, cancel := contextFn() defer cancel() timer := Backoff{Duration: time.Microsecond}.Timer() attempts := 0 err := loopConditionUntilContext(ctx, timer, test.immediate, test.sliding, func(_ context.Context) (bool, error) { attempts++ defer func() { if test.cancelContextAfter > 0 && test.cancelContextAfter == attempts { cancel() } }() return test.callback(attempts) }) if test.errExpected != err { t.Errorf("expected error: %v but got: %v", test.errExpected, err) } if test.attemptsExpected != attempts { t.Errorf("expected attempts count: %d but got: %d", test.attemptsExpected, attempts) } }) }) } </del> <ins> t.Run(test.name, func(t *testing.T) { contextFn := test.context if contextFn == nil { contextFn = defaultContext } ctx, cancel := contextFn() defer cancel() timer := test.timer if timer == nil { timer = Backoff{Duration: time.Microsecond}.Timer() } attempts := 0 err := loopConditionUntilContext(ctx, timer, test.immediate, test.sliding, func(_ context.Context) (bool, error) { attempts++ defer func() { if test.cancelContextAfter > 0 && test.cancelContextAfter == attempts { cancel() } }() return test.callback(attempts) </ins> }) <del> } </del> <ins> if test.errExpected != err { t.Errorf("expected error: %v but got: %v", test.errExpected, err) } if test.attemptsExpected != attempts { t.Errorf("expected attempts count: %d but got: %d", test.attemptsExpected, attempts) } }) </ins> } }