Skip to content

Commit

Permalink
When passed a context and no explicit timeout, Eventually will only t…
Browse files Browse the repository at this point in the history
…imeout when the context is cancelled

This enables using the same context across a series of Eventually's without worrying about specifying a specific timeout for each one.

If an explicit timeout _is_ set, then that timeout is used alognside the context.
  • Loading branch information
onsi committed Oct 11, 2022
1 parent bf3cba9 commit e5105cf
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 67 deletions.
4 changes: 4 additions & 0 deletions docs/index.md
Expand Up @@ -265,6 +265,8 @@ You can also configure the context in this way:
Eventually(ACTUAL).WithTimeout(TIMEOUT).WithPolling(POLLING_INTERVAL).WithContext(ctx).Should(MATCHER)
```

When no explicit timeout is provided, `Eventually` will use the default timeout. However if no explicit timeout is provided _and_ a context is provided, `Eventually` will not apply a timeout but will instead keep trying until the context is cancelled. If both a context and a timeout are provided, `Eventually` will keep trying until either the context is cancelled or time runs out, whichever comes first.

Eventually works with any Gomega compatible matcher and supports making assertions against three categories of `ACTUAL` value:

#### Category 1: Making `Eventually` assertions on values
Expand Down Expand Up @@ -475,6 +477,8 @@ As with `Eventually`, you can also pass `Consistently` a function. In fact, `Co

If `Consistently` is passed a `context.Context` it will exit if the context is cancelled - however it will always register the cancellation of the context as a failure. That is, the context is not used to control the duration of `Consistently` - that is always done by the `DURATION` parameter; instead, the context is used to allow `Consistently` to bail out early if it's time for the spec to finish up (e.g. a timeout has elapsed, or the user has sent an interrupt signal).

When no explicit duration is provided, `Consistently` will use the default duration. Unlike `Eventually`, this behavior holds whether or not a context is provided.

> Developers often try to use `runtime.Gosched()` to nudge background goroutines to run. This can lead to flaky tests as it is not deterministic that a given goroutine will run during the `Gosched`. `Consistently` is particularly handy in these cases: it polls for 100ms which is typically more than enough time for all your Goroutines to run. Yes, this is basically like putting a time.Sleep() in your tests... Sometimes, when making negative assertions in a concurrent world, that's the best you can do!
### Bailing Out Early
Expand Down
40 changes: 33 additions & 7 deletions internal/async_assertion.go
Expand Up @@ -2,12 +2,12 @@ package internal

import (
"context"
"errors"
"fmt"
"reflect"
"runtime"
"sync"
"time"
"errors"

"github.com/onsi/gomega/types"
)
Expand All @@ -18,7 +18,6 @@ type StopTryingError interface {
wasViaPanic() bool
}


func asStopTryingError(actual interface{}) (StopTryingError, bool) {
if actual == nil {
return nil, false
Expand Down Expand Up @@ -173,15 +172,15 @@ func (assertion *AsyncAssertion) processReturnValues(values []reflect.Value) (in
return nil, fmt.Errorf("No values were returned by the function passed to Gomega"), stopTrying
}
actual := values[0].Interface()
if stopTryingErr, ok := asStopTryingError(actual); ok{
if stopTryingErr, ok := asStopTryingError(actual); ok {
stopTrying = stopTryingErr
}
for i, extraValue := range values[1:] {
extra := extraValue.Interface()
if extra == nil {
continue
}
if stopTryingErr, ok := asStopTryingError(extra); ok{
if stopTryingErr, ok := asStopTryingError(extra); ok {
stopTrying = stopTryingErr
continue
}
Expand Down Expand Up @@ -325,13 +324,40 @@ func (assertion *AsyncAssertion) matcherSaysStopTrying(matcher types.GomegaMatch
return StopTrying("No future change is possible. Bailing out early")
}

func (assertion *AsyncAssertion) afterTimeout() <-chan time.Time {
if assertion.timeoutInterval >= 0 {
return time.After(assertion.timeoutInterval)
}

if assertion.asyncType == AsyncAssertionTypeConsistently {
return time.After(assertion.g.DurationBundle.ConsistentlyDuration)
} else {
if assertion.ctx == nil {
return time.After(assertion.g.DurationBundle.EventuallyTimeout)
} else {
return nil
}
}
}

func (assertion *AsyncAssertion) afterPolling() <-chan time.Time {
if assertion.pollingInterval >= 0 {
return time.After(assertion.pollingInterval)
}
if assertion.asyncType == AsyncAssertionTypeConsistently {
return time.After(assertion.g.DurationBundle.ConsistentlyPollingInterval)
} else {
return time.After(assertion.g.DurationBundle.EventuallyPollingInterval)
}
}

type contextWithAttachProgressReporter interface {
AttachProgressReporter(func() string) func()
}

func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch bool, optionalDescription ...interface{}) bool {
timer := time.Now()
timeout := time.After(assertion.timeoutInterval)
timeout := assertion.afterTimeout()
lock := sync.Mutex{}

var matches bool
Expand Down Expand Up @@ -398,7 +424,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
}

select {
case <-time.After(assertion.pollingInterval):
case <-assertion.afterPolling():
v, e, st := pollActual()
if st != nil && st.wasViaPanic() {
// we were told to stop trying via panic - which means we dont' have reasonable new values
Expand Down Expand Up @@ -438,7 +464,7 @@ func (assertion *AsyncAssertion) match(matcher types.GomegaMatcher, desiredMatch
}

select {
case <-time.After(assertion.pollingInterval):
case <-assertion.afterPolling():
v, e, st := pollActual()
if st != nil && st.wasViaPanic() {
// we were told to stop trying via panic - which means we made it this far and should return successfully
Expand Down
188 changes: 132 additions & 56 deletions internal/async_assertion_test.go
Expand Up @@ -170,70 +170,108 @@ var _ = Describe("Asynchronous Assertions", func() {
})
})

Context("when the passed-in context is cancelled", func() {
It("stops and returns a failure", func() {
ctx, cancel := context.WithCancel(context.Background())
counter := 0
ig.G.Eventually(func() string {
counter++
if counter == 2 {
cancel()
} else if counter == 10 {
return MATCH
}
return NO_MATCH
}, time.Hour, ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match"))
})
Context("with a passed-in context", func() {
Context("when the passed-in context is cancelled", func() {
It("stops and returns a failure", func() {
ctx, cancel := context.WithCancel(context.Background())
counter := 0
ig.G.Eventually(func() string {
counter++
if counter == 2 {
cancel()
} else if counter == 10 {
return MATCH
}
return NO_MATCH
}, time.Hour, ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match"))
})

It("can also be configured via WithContext()", func() {
ctx, cancel := context.WithCancel(context.Background())
counter := 0
ig.G.Eventually(func() string {
counter++
if counter == 2 {
cancel()
} else if counter == 10 {
It("can also be configured via WithContext()", func() {
ctx, cancel := context.WithCancel(context.Background())
counter := 0
ig.G.Eventually(func() string {
counter++
if counter == 2 {
cancel()
} else if counter == 10 {
return MATCH
}
return NO_MATCH
}, time.Hour).WithContext(ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match"))
})

It("counts as a failure for Consistently", func() {
ctx, cancel := context.WithCancel(context.Background())
counter := 0
ig.G.Consistently(func() string {
counter++
if counter == 2 {
cancel()
} else if counter == 10 {
return NO_MATCH
}
return MATCH
}
return NO_MATCH
}, time.Hour).WithContext(ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: no match"))
}, time.Hour).WithContext(ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: match"))
})
})

It("counts as a failure for Consistently", func() {
ctx, cancel := context.WithCancel(context.Background())
counter := 0
ig.G.Consistently(func() string {
counter++
if counter == 2 {
cancel()
} else if counter == 10 {
Context("when the passed-in context is a Ginkgo SpecContext that can take a progress reporter attachment", func() {
It("attaches a progress reporter context that allows it to report on demand", func() {
fakeSpecContext := &FakeGinkgoSpecContext{}
var message string
ctx := context.WithValue(context.Background(), "GINKGO_SPEC_CONTEXT", fakeSpecContext)
ig.G.Eventually(func() string {
if fakeSpecContext.Attached != nil {
message = fakeSpecContext.Attached()
}
return NO_MATCH
}
return MATCH
}, time.Hour).WithContext(ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: match"))
}).WithTimeout(time.Millisecond * 20).WithContext(ctx).Should(Equal(MATCH))

Ω(message).Should(Equal("Expected\n <string>: no match\nto equal\n <string>: match"))
Ω(fakeSpecContext.Cancelled).Should(BeTrue())
})
})
})

Context("when the passed-in context is a Ginkgo SpecContext that can take a progress reporter attachment", func() {
It("attaches a progress reporter context that allows it to report on demand", func() {
fakeSpecContext := &FakeGinkgoSpecContext{}
var message string
ctx := context.WithValue(context.Background(), "GINKGO_SPEC_CONTEXT", fakeSpecContext)
ig.G.Eventually(func() string {
if fakeSpecContext.Attached != nil {
message = fakeSpecContext.Attached()
}
return NO_MATCH
}).WithTimeout(time.Millisecond * 20).WithContext(ctx).Should(Equal(MATCH))
Describe("the interaction between the context and the timeout", func() {
It("only relies on context cancellation when no explicit timeout is specified", func() {
ig.G.SetDefaultEventuallyTimeout(time.Millisecond * 10)
ig.G.SetDefaultEventuallyPollingInterval(time.Millisecond * 40)
t := time.Now()
ctx, cancel := context.WithCancel(context.Background())
iterations := 0
ig.G.Eventually(func() string {
iterations += 1
if time.Since(t) > time.Millisecond*200 {
cancel()
}
return "A"
}).WithContext(ctx).Should(Equal("B"))
Ω(time.Since(t)).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*100))
Ω(iterations).Should(BeNumerically("~", 200/40, 2))
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
})

Ω(message).Should(Equal("Expected\n <string>: no match\nto equal\n <string>: match"))
Ω(fakeSpecContext.Cancelled).Should(BeTrue())
It("uses the explicit timeout when it is provided", func() {
t := time.Now()
ctx, cancel := context.WithCancel(context.Background())
iterations := 0
ig.G.Eventually(func() string {
iterations += 1
if time.Since(t) > time.Millisecond*200 {
cancel()
}
return "A"
}).WithContext(ctx).WithTimeout(time.Millisecond * 80).ProbeEvery(time.Millisecond * 40).Should(Equal("B"))
Ω(time.Since(t)).Should(BeNumerically("~", time.Millisecond*80, time.Millisecond*40))
Ω(iterations).Should(BeNumerically("~", 80/40, 2))
Ω(ig.FailureMessage).Should(ContainSubstring("Timed out after"))
})
})
})
})
Expand Down Expand Up @@ -352,6 +390,44 @@ var _ = Describe("Asynchronous Assertions", func() {
Ω(ig.FailureMessage).Should(ContainSubstring("boop"))
})
})

Context("with a passed-in context", func() {
Context("when the passed-in context is cancelled", func() {
It("counts as a failure for Consistently", func() {
ctx, cancel := context.WithCancel(context.Background())
counter := 0
ig.G.Consistently(func() string {
counter++
if counter == 2 {
cancel()
} else if counter == 10 {
return NO_MATCH
}
return MATCH
}, time.Hour).WithContext(ctx).Should(SpecMatch())
Ω(ig.FailureMessage).Should(ContainSubstring("Context was cancelled after"))
Ω(ig.FailureMessage).Should(ContainSubstring("positive: match"))
})
})

Describe("the interaction between the context and the timeout", func() {
It("only always uses the default interval even if not explicit duration is provided", func() {
ig.G.SetDefaultConsistentlyDuration(time.Millisecond * 200)
ig.G.SetDefaultConsistentlyPollingInterval(time.Millisecond * 40)
t := time.Now()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
iterations := 0
ig.G.Consistently(func() string {
iterations += 1
return "A"
}).WithContext(ctx).Should(Equal("A"))
Ω(time.Since(t)).Should(BeNumerically("~", time.Millisecond*200, time.Millisecond*100))
Ω(iterations).Should(BeNumerically("~", 200/40, 2))
Ω(ig.FailureMessage).Should(BeZero())
})
})
})
})

Describe("the passed-in actual", func() {
Expand Down
8 changes: 4 additions & 4 deletions internal/gomega.go
Expand Up @@ -57,8 +57,8 @@ func (g *Gomega) Eventually(actual interface{}, intervals ...interface{}) types.
}

func (g *Gomega) EventuallyWithOffset(offset int, actual interface{}, args ...interface{}) types.AsyncAssertion {
timeoutInterval := g.DurationBundle.EventuallyTimeout
pollingInterval := g.DurationBundle.EventuallyPollingInterval
timeoutInterval := -time.Duration(1)
pollingInterval := -time.Duration(1)
intervals := []interface{}{}
var ctx context.Context
for _, arg := range args {
Expand All @@ -84,8 +84,8 @@ func (g *Gomega) Consistently(actual interface{}, intervals ...interface{}) type
}

func (g *Gomega) ConsistentlyWithOffset(offset int, actual interface{}, args ...interface{}) types.AsyncAssertion {
timeoutInterval := g.DurationBundle.ConsistentlyDuration
pollingInterval := g.DurationBundle.ConsistentlyPollingInterval
timeoutInterval := -time.Duration(1)
pollingInterval := -time.Duration(1)
intervals := []interface{}{}
var ctx context.Context
for _, arg := range args {
Expand Down

0 comments on commit e5105cf

Please sign in to comment.