Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Eventually/EventuallyWithT should only use one goroutine #1439

Open
cszczepaniak opened this issue Jul 30, 2023 · 7 comments
Open

Eventually/EventuallyWithT should only use one goroutine #1439

cszczepaniak opened this issue Jul 30, 2023 · 7 comments
Labels
assert.Eventually About assert.Eventually/EventuallyWithT concurrency enhancement help wanted input needed pkg-assert Change related to package testify/assert

Comments

@cszczepaniak
Copy link

cszczepaniak commented Jul 30, 2023

Currently, Eventually and EventuallyWithT spin up a goroutine per condition check. It also sets the ticker channel to nil while the condition is being checked, which makes it hard to understand to readers that the select will never hit case <-tick while the condition is running.

A better implementation may be to spin up a single goroutine which polls the condition repeatedly while the function waits on the result or a timeout.

This would also solve #1424 if implemented correctly.

@cszczepaniak
Copy link
Author

cszczepaniak commented Jul 30, 2023

Here's the implementation I have in mind. We could similarly implement EventuallyWithT. I will open a PR if this is desirable:

func Eventually(t TestingT, condition func() bool, waitFor time.Duration, tick time.Duration, msgAndArgs ...interface{}) bool {
	if h, ok := t.(tHelper); ok {
		h.Helper()
	}

	ctx, done := context.WithTimeout(context.Background(), waitFor)
	defer done()

	ch := make(chan bool, 1)
	defer close(ch)

	go func() {
		ticker := time.NewTicker(tick)
		defer ticker.Stop()

		for {
			if condition() {
				ch <- true
				return
			}

			select {
			case <-ticker.C:
			case <-ctx.Done():
				return
			}
		}
	}()

	select {
	case <-ch:
		return true
	case <-ctx.Done():
		return Fail(t, "Condition never satisfied", msgAndArgs...)
	}
}

@dolmen
Copy link
Collaborator

dolmen commented Jul 30, 2023

I like the idea of not overlapping the executions of the condition. However this will change the behavior.

We need feedback from the community.

@cszczepaniak
Copy link
Author

cszczepaniak commented Jul 30, 2023

The behavior will change as described in #1424 (namely, that the condition will be checked before waiting for one tick) -- are there more ways in which this will change the behavior that should be enumerated here for completeness?

@cszczepaniak
Copy link
Author

This comment suggests that Eventually is guaranteed to check the condition every tick, which may be an additional change to behavior. This is not true however, because the implementation sets the tick channel to nil in between executions. Here's a simple test that demonstrates the condition being called strictly fewer times than there are ticks in the total timeout duration:

func TestEventuallyCallsEveryTick(t *testing.T) {
	mockT := new(testing.T)

	n := 0
	condition := func() bool {
		n++
		time.Sleep(250 * time.Millisecond)
		return false
	}

	False(t, Eventually(mockT, condition, 500*time.Millisecond, 100*time.Millisecond))
	// If called every tick, should be 5 because 500ms/100ms = 5
	Equal(t, 5, n)
}

With result:

--- FAIL: TestEventuallyCallsEveryTick (0.50s)
    testify/assert/assertions_test.go:2723: 
        	Error Trace:	
        	Error:      	Not equal: 
        	            	expected: 5
        	            	actual  : 2
        	Test:       	TestEventuallyCallsEveryTick
FAIL
FAIL	github.com/stretchr/testify/assert	0.766s
FAIL

@dolmen dolmen changed the title Eventually/EventuallyWithT should only use one goroutine Eventually/EventuallyWithT should only use one goroutine Jul 31, 2023
@dolmen
Copy link
Collaborator

dolmen commented Jul 31, 2023

@cszczepaniak Thanks for this analysis.

Here is a more verbose test running on go.dev/play: https://go.dev/play/p/-Dyc4bZRCTk

func TestEventuallyCallsEveryTick(t *testing.T) {
	n := 0
	condition := func() bool {
		m := n
		t.Logf("cond%d %v", m, time.Now())
		n++
		time.Sleep(250 * time.Millisecond)
		t.Logf("cond%d %v", m, time.Now())
		return false
	}

	assert.False(t, assert.Eventually(t, condition, 500*time.Millisecond, 100*time.Millisecond))
	// If called every tick, should be 5 because 500ms/100ms = 5
	assert.Equal(t, 5, n)
}
=== RUN   TestEventuallyCallsEveryTick
    prog_test.go:14: cond0 2009-11-10 23:00:00.1 +0000 UTC m=+0.100000001
    prog_test.go:17: cond0 2009-11-10 23:00:00.35 +0000 UTC m=+0.350000001
    prog_test.go:14: cond1 2009-11-10 23:00:00.35 +0000 UTC m=+0.350000001
    prog_test.go:21: 
        	Error Trace:	/tmp/sandbox109644822/prog_test.go:21
        	Error:      	Condition never satisfied
        	Test:       	TestEventuallyCallsEveryTick
    prog_test.go:23: 
        	Error Trace:	/tmp/sandbox109644822/prog_test.go:23
        	Error:      	Not equal: 
        	            	expected: 5
        	            	actual  : 2
        	Test:       	TestEventuallyCallsEveryTick
--- FAIL: TestEventuallyCallsEveryTick (0.50s)
FAIL

@cszczepaniak
Copy link
Author

Thanks @dolmen, today I learned that the Go playground can run tests!

@dolmen dolmen added the assert.Eventually About assert.Eventually/EventuallyWithT label Jul 31, 2023
@brackendawson
Copy link
Collaborator

I hadn't noticed the nil ticker. Given the current actual behaviour does not overlap ticks I'm quite happy with such a change. We should also bring the documentation into line with the real behaviour.

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 concurrency enhancement help wanted input needed pkg-assert Change related to package testify/assert
Projects
None yet
Development

No branches or pull requests

3 participants