Skip to content

Replace Never & Eventually functions with synchronous versions#1657

Open
brackendawson wants to merge 3 commits into
stretchr:masterfrom
brackendawson:eventually-sync
Open

Replace Never & Eventually functions with synchronous versions#1657
brackendawson wants to merge 3 commits into
stretchr:masterfrom
brackendawson:eventually-sync

Conversation

@brackendawson
Copy link
Copy Markdown
Collaborator

@brackendawson brackendawson commented Oct 4, 2024

Summary

Eventually, EventuallyWithT, and Never have serious defects which require changes to their behaviour to rectify:

  • They can leak goroutines.
  • The can return a verdict without ever testing the condition!!!
  • They always wait 1 tick before testing the condition.

Making them syncronous fixes every known problem with these functions.

Changes

  • Create a synchronous version of EventuallyWithT called EventuallySync. Making the call to condition syncrhonous means we no longer have to decide what to do (or what not to do) when an asynchronous call to the user's condition function doesn't return before the end of the test. This solves the goroutine leak (assert: Eventually should not leak a goroutine #1611) but also forces the definition of a new function as there could be many badly written tests which would now deadlock if this was implimented in the existing functions. This also means evaluation of the test result happens between calls to the condition function, making the assertion easier to use for the user and solving Never: unexpectedly succeeds if timeout reached before condition returns #1654.
  • Create a synchronous version of Never for the same reasons. Implementing it as Consistently allows it to use CollectT too and delivers Feature: assert.Consistently #1087.
  • Always run condition immediately. This solves the timing race (assert.Eventually can fail having never run condition #1652) where the select statement could be reached for the first time with both tick and waitFor channels ready, where the function would randomly return a verdict without actually testing the condition. This also delivers a popular request: Succeed quickly in Eventually, EventuallyWithT #1424
  • Mark Eventually and EventuallyWithT as deprecated with warnings about their faults and suggest the use of EventuallySync instead.
  • Mark Never as deprecated with warnings about its faults and suggest the use of Consistently instead.
  • Make the flaking TestEventuallyTimeout less likelt to fail, or fail more quickly.

Motivation

  • Eventually/Never/etc.. can leak goroutines.
  • Eventually/Never/etc.. can show either test result without testing anything.
  • Eventually/Never/etc.. make our tests flake.
  • People want Eventually/Never/etc.. to be faster.
  • People want a Consistently assertion.

Related issues

Comment thread assert/assertions.go
collect := new(CollectT)

wait := make(chan struct{})
go func() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this goroutine gain us? We're waiting on it synchronously immediately after, so it's equivalent to just calling the condition inline here (without a goroutine)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CollectT.FailNow will call runtime.GoExit. If we didn't use a new goroutine for each callback then the user calling FailNow would stop the whole test. This is the only reason we can't do this without any additional goroutines at all.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like @dolmen doesn't like CollectT, and I can't say I disagree with him.

So we actually could make this work with no additional goroutines. The choice is to either use func() bool or use an interface with an un-exported implementation. I find that every time I use Eventually I want to use assertions inside the condition function, so the latter is my preference. But we can panic an un-exported value rather than calling runtime.Goexit. All panics from the condition function should be handled regardless.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I totally follow. It sounds like an unexported implementation makes sense, but could you sketch out what that code would look like?

Comment thread assert/assertions.go Outdated

<-ticker

if time.Now().After(deadline) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should still support failing the test immediately when deadline elapses, even if we're in the middle of calling the condition. We'd effectively just keep the goroutine above that I commented on and select over the done channel + the timeout channel. Maybe this is what was meant by leaking a goroutine though.

If we don't do that, I question the value of the API accepting a duration as a timeout because the duration isn't super closely tied with the maximum time this function might take. Maybe it would be better to just accept a maximum number of tries instead.

Copy link
Copy Markdown
Collaborator Author

@brackendawson brackendawson Oct 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should still support failing the test immediately when deadline elapses, even if we're in the middle of calling the condition. We'd effectively just keep the goroutine above that I commented on and select over the done channel + the timeout channel. Maybe this is what was meant by leaking a goroutine though.

This makes the callback asynchronous (sometimes) and puts us back in the situation of having to allow it to potentially leak. It was always asking for trouble. I tried a similar fix in #1653 but I don't like it.

If we don't do that, I question the value of the API accepting a duration as a timeout because the duration isn't super closely tied with the maximum time this function might take. Maybe it would be better to just accept a maximum number of tries instead.

This is an excellent point. I hadn't considered that synchronous behaviour would suit a different function signature better. times int is far more natural than waitFor time.Duration. I've updated the PR.

Thanks for the review!

@brackendawson
Copy link
Copy Markdown
Collaborator Author

The issues described recently in #1396 lead me to believe the collect object and the condition function need more work to support the test being failed immediately from the condition function. Either of these options need to be used:

  • The condition function be run on the same goroutine as the test. This would allow the use of testing.T.FailNow to be supported but means the collect object's FailNow implementation must revert back to a panic with a private value, which it was recently changed away from.
  • The condition function should return a bool: func() bool, if false it means fail immediately.

@ccoVeille
Copy link
Copy Markdown
Collaborator

Do we still need this now #1427 was merged?

I'm simply reviewing issues linked to this recently merged PR, and I wonder if this one is still needed.

@brackendawson
Copy link
Copy Markdown
Collaborator Author

In the case where the condition function does not return before the time, Eventually can still leak a goroutine after the end of the test. Making execution of the condition function synchronous fixes this. But this PR needs some work.

@ccoVeille
Copy link
Copy Markdown
Collaborator

Got it, thanks

Could you rebase and fix the conflicts?

@dolmen dolmen added pkg-assert Change related to package testify/assert assert.Eventually About assert.Eventually/EventuallyWithT labels May 27, 2025
stephanos added a commit to temporalio/temporal that referenced this pull request May 15, 2026
## What changed?

Introduces `common/testing/await`, a polling-based test helper that
replaces testify's `Eventually` / `EventuallyWithT`.

## Why?

From the [package
doc](https://github.com/temporalio/temporal/blob/stephanos/all-require/common/testing/await/await.go):

```
Improvements over testify's eventually functions:

   - Misuse detection: accidentally using the real *testing.T (e.g. s.T() or
     suite assertion methods) instead of the callback's collect T is a
     common mistake. This package detects it and fails with a clear message.

   - Safer bool predicates: unlike testify's Eventually, [RequireTrue] only
     accepts func() bool, so returning false is the sole retry signal. If the
     predicate accidentally marks the real test failed, it reports that
     immediately instead of polling until timeout.

   - Timeout-aware callbacks: callbacks receive a context derived from the
     parent context and canceled when the await timeout or test deadline is
     reached, so RPCs and blocking waits can exit instead of continuing after
     the retry window has expired.

   - Panic propagation: if the condition panics (e.g. nil dereference), the
     panic is propagated immediately rather than being silently swallowed
     or retried until timeout.
     See https:github.com/stretchr/testify/issues/1810

   - Bounded goroutine lifetime: each attempt completes before the next
     starts, avoiding the overlapping-attempt data races and "panic: Fail
     in goroutine after Test has completed" crashes seen with testify's
     Eventually.
     See https:github.com/stretchr/testify/issues/1611

   - Deadlock detection: a condition that ignores t.Context() is abandoned
     after a grace period, producing a clear "does it honor t.Context()?"
     failure instead of hanging until go test -timeout.

   - Condition always runs: testify's Eventually can fail without ever
     running the condition due to a timer/ticker race with short timeouts.
     This package runs the condition immediately on the first iteration.
     See stretchr/testify#1652
```

The upstream fix
([testify#1657](stretchr/testify#1657)) aims to
address several of these but has been open since Oct 2024 without
merging.

## How did you test it?
- [ ] built
- [ ] run locally and tested manually
- [x] covered by existing tests
- [x] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks

The biggest downside is that it adds more code that we own. But apart
from any unknown bugs, this package should rarely/never change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

assert.Eventually About assert.Eventually/EventuallyWithT must-rebase pkg-assert Change related to package testify/assert

Projects

None yet

4 participants