diff --git a/assert/Eventually.go b/assert/Eventually.go index cbd24b0..7d08810 100644 --- a/assert/Eventually.go +++ b/assert/Eventually.go @@ -1,6 +1,7 @@ package assert import ( + "runtime" "testing" "time" @@ -50,6 +51,7 @@ func (r Retry) Assert(tb testing.TB, blk func(t It)) { isFailed := tb.Failed() r.Strategy.While(func() bool { tb.Helper() + runtime.Gosched() lastRecorder = &doubles.RecorderTB{TB: tb} ro := sandbox.Run(func() { tb.Helper() diff --git a/assert/Waiter.go b/assert/Waiter.go index a0343f6..692992c 100644 --- a/assert/Waiter.go +++ b/assert/Waiter.go @@ -1,8 +1,9 @@ package assert import ( - "runtime" "time" + + "go.llib.dev/testcase/internal/rth" ) // Waiter is a component that waits for a time, event, or opportunity. @@ -19,8 +20,7 @@ type Waiter struct { func (w Waiter) Wait() { finishTime := time.Now().Add(w.WaitDuration) for time.Now().Before(finishTime) { - runtime.Gosched() - time.Sleep(time.Nanosecond) + rth.Schedule(w.WaitDuration) } } diff --git a/clock/README.md b/clock/README.md index d66f9bd..b9c9ac4 100644 --- a/clock/README.md +++ b/clock/README.md @@ -16,6 +16,7 @@ - [Will this replace dependency injection for time-related configurations?](#will-this-replace-dependency-injection-for-time-related-configurations) - [Why not just use a global variable with `time.Now`?](#why-not-just-use-a-global-variable-with-timenow) - [How does the clock package help with testing time-dependent goroutine synchronization?](#how-does-the-clock-package-help-with-testing-time-dependent-goroutine-synchronization) + - [Does the `clock` package have a monotonic view of time?](#does-the-clock-package-have-a-monotonic-view-of-time) @@ -167,3 +168,22 @@ func TestXXX(t *testing.T) { } ``` +### Does the `clock` package have a monotonic view of time? + +Monotonic time means: +- Time is always increasing steadily, without jumping backwards. +- Every thread sees time moving forward at the same rate. +- All goroutines share a single, unified notion of time. + +The `clock` package inherits its time view from the standard Go time API. +The Go standard library provides APIs to get the current time, +which includes both the "wall clock" and the "monotonic clock". +When the system clock synchronizes and changes, the wall clock time can move backward, +but for measurements, Go uses the monotonic time value for precise results. + +The `clock` package also allows traveling backward in time. +In this sense, `clock` isn't strictly monotonic because you can set it to a specific point in time. +After time travel, time is monotonic again until you again make a travel back in time. + +It’s open to interpretation whether the `clock` remains monotonic when time is frozen with `timecop`. + diff --git a/clock/timecop/timecop.go b/clock/timecop/timecop.go index f4e916d..7f65696 100644 --- a/clock/timecop/timecop.go +++ b/clock/timecop/timecop.go @@ -1,11 +1,11 @@ package timecop import ( - "runtime" "testing" "time" "go.llib.dev/testcase/clock/internal" + "go.llib.dev/testcase/internal/rth" ) // Travel will initiate a time travel. @@ -22,10 +22,8 @@ func Travel[D time.Duration | time.Time](tb testing.TB, d D, tos ...TravelOption case time.Time: travelByTime(tb, d, opt) } - for i, n := 0, runtime.NumGoroutine(); i < n; i++ { // since goroutines don't have guarantee when they will be scheduled - runtime.Gosched() // we explicitly mark that we are okay with other goroutines to be scheduled - time.Sleep(time.Nanosecond) // and we also okay to be low piority and blocked for the sake of other goroutines. - } + const travelWaitTimeout = 3 * time.Second + rth.Schedule(travelWaitTimeout) } const BlazingFast = 100 diff --git a/internal/rth/rth.go b/internal/rth/rth.go new file mode 100644 index 0000000..99d7c26 --- /dev/null +++ b/internal/rth/rth.go @@ -0,0 +1,24 @@ +package rth + +import ( + "runtime" + "time" +) + +func Schedule(maxWait time.Duration) { + const WaitUnit = time.Nanosecond + var ( + goroutNum = runtime.NumGoroutine() + startedAt = time.Now() + ) + for i := 0; i < goroutNum; i++ { // since goroutines don't have guarantee when they will be scheduled + runtime.Gosched() // we explicitly mark that we are okay with other goroutines to be scheduled + elapsed := time.Since(startedAt) + if maxWait <= elapsed { // if max wait time is reached + return + } + if elapsed < maxWait { // if we withint the max wait time, + time.Sleep(WaitUnit) // then we could just yield CPU too with sleep + } + } +} diff --git a/internal/rth/rth_test.go b/internal/rth/rth_test.go new file mode 100644 index 0000000..70a9f0b --- /dev/null +++ b/internal/rth/rth_test.go @@ -0,0 +1,42 @@ +package rth_test + +import ( + "context" + "strconv" + "testing" + "time" + + "go.llib.dev/testcase/assert" + "go.llib.dev/testcase/internal/rth" + "go.llib.dev/testcase/random" +) + +func TestSchedule(t *testing.T) { + rnd := random.New(random.CryptoSeed{}) + done := make(chan struct{}) + defer close(done) + + for i := 0; i < 1000; i++ { + // fire goroutines + go func() { + for { + select { + case <-done: // stop when the program is done + return + default: // do something that requires CPU time + strconv.Itoa(rnd.Int()) + } + } + }() + } + + var adjusted = func(d time.Duration, m float64) time.Duration { + return time.Duration(float64(d) * m) + } + + for _, dur := range []time.Duration{time.Second, time.Millisecond} { + assert.Within(t, adjusted(dur, 1.2), func(ctx context.Context) { + rth.Schedule(dur) + }) + } +}