Skip to content

Commit

Permalink
testtime: add mockable timers for use in tests (#14672)
Browse files Browse the repository at this point in the history
* timeutil: add timer interface and fake timer

Signed-off-by: Oliver Calder <[email protected]>

* timeutil: fix timer stop/reset return values and add more methods

Rather than returning whether the timer had fired, `Stop` and `Reset`
should return whether the timer is currently active. If it has fired or
been stopped manually, these should return false.

Additionally, add the `Active` method to report whether the timer is
currently active, and add the `FireCount` method to report the number of
times that the timer has fired. This is useful to directly check whether
a timer's callback has been called.

Signed-off-by: Oliver Calder <[email protected]>

* testtime,timeutil: move mockable timer to dedicated testtime package

Signed-off-by: Oliver Calder <[email protected]>

* testtime: create wrapper around time.Timer to include C in interface

The `time.Timer` type uses an instance variable `C` for the channel over
which the expiration time is sent when a timer which was created via
`time.NewTimer` fires. Interfaces in Go cannot have instance variables,
so we must use a `C()` method instead. However, this means `time.Timer`
cannot implement our `testtime.Timer` interface fully.

Thus, this commit adds `testtime.RealTimer` as a wrapper around
`time.Timer` which exposes the latter's inner `C` variable as `C()`.

Since we now need to construct `testtime.RealTimer` instances in place
of `time.Timer` instances in production code, the `testtime.AfterFunc`
and `testtime.NewTimer` functions now return `testtime.RealTimer`s by
default, which are thin wrappers around `time.Timer`s.

In test code, tests can call `testtime.MockTimers` to make all
subsequent invocations of `testtime.AfterFunc` and `testtime.NewTimer`
return `testtime.TestTimer`s instead of `testtime.RealTimer`s.

It is important that `testtime.MockTimers` is never used in non-test
code, so this commit adds a static check to ensure this is the case.

Signed-off-by: Oliver Calder <[email protected]>

* testtime,timeutil: move timer to timeutil and use testtime for mocked timer

Signed-off-by: Oliver Calder <[email protected]>

* testtime: panic if used in non-test code or fire called when inactive

Signed-off-by: Oliver Calder <[email protected]>

* testtime: add checks that firing inactive timer panics and remove leftover test

Signed-off-by: Oliver Calder <[email protected]>

* randutil: export Perm function from math/rand

Signed-off-by: Oliver Calder <[email protected]>

* testtime: add timer tests from the go standard library

Signed-off-by: Oliver Calder <[email protected]>

* testtime: prefer callback to expired channel and improve newTimerFunc test helper

Signed-off-by: Oliver Calder <[email protected]>

* testtime: rename internal lock to mu

Signed-off-by: Oliver Calder <[email protected]>

* testtime: replace internal expiration/currtime with duration/elapsed

Since `TestTimer` is designed to allow the passage of time to be
controlled manually, it makes sense to represent this internally using a
duration and elapsed time, rather than storing the timestamp at the
creation of the timer and advancing an internal "current time".

This way, the time at which the timer was created has no effect on the
expiration timestamp which may be sent over the C channel when it fires,
and timers don't each have their own conflicting view of what time it
is. When a timer expires, the current time is sent over the channel, or
if `Fire` is called directly, then the given time is sent instead.

Signed-off-by: Oliver Calder <[email protected]>

* .golangci.yml: add lint to check for usage of testtime in non-test code

Signed-off-by: Oliver Calder <[email protected]>

* run-checks: remove manual check for testtime in favor of lint

Signed-off-by: Oliver Calder <[email protected]>

---------

Signed-off-by: Oliver Calder <[email protected]>
  • Loading branch information
olivercalder authored Nov 14, 2024
1 parent 89d50e5 commit e716737
Show file tree
Hide file tree
Showing 7 changed files with 1,137 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ linters-settings:
deny:
- pkg: "os/user"
desc: "Please use osutil/user instead. See https://github.com/canonical/snapd/pull/13776"
testtime:
files:
- "!$test"
deny:
- pkg: "github.com/snapcore/snapd/testtime"
desc: "Cannot use testtime outside of test code"
- pkg: "github.com/canonical/snapd/testtime"
desc: "Cannot use testtime outside of test code"

misspell:
# Correct spellings using locale preferences for US or UK.
Expand Down
1 change: 1 addition & 0 deletions randutil/rand.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func RandomString(length int) string {
var (
Intn = rand.Intn
Int63n = rand.Int63n
Perm = rand.Perm
)

// RandomDuration returns a random duration up to the given length.
Expand Down
28 changes: 28 additions & 0 deletions testtime/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// -*- Mode: Go; indent-tabs-mode: t -*-

/*
* Copyright (C) 2024 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package testtime

import (
"time"
)

func (t *TestTimer) SetCChan(c chan time.Time) {
t.c = c
}
213 changes: 213 additions & 0 deletions testtime/testtime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// -*- Mode: Go; indent-tabs-mode: t -*-

/*
* Copyright (C) 2024 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

// Package testtime provides a mocked version of time.Timer for use in tests.
package testtime

import (
"sync"
"time"

"github.com/snapcore/snapd/osutil"
"github.com/snapcore/snapd/timeutil"
)

// TestTimer is a mocked version of time.Timer for which the passage of time or
// the direct expiration of the timer is controlled manually.
//
// TestTimer implements timeutil.Timer.
//
// TestTimer also provides methods to introspect whether the timer is active or
// how many times it has fired.
type TestTimer struct {
mu sync.Mutex
duration time.Duration
elapsed time.Duration
active bool
fireCount int
callback func()
c chan time.Time
}

var _ timeutil.Timer = (*TestTimer)(nil)

// AfterFunc waits for the timer to fire and then calls f in its own goroutine.
// It returns a Timer that can be used to cancel the call using its Stop method.
// The returned Timer's C field is not used and will be nil.
//
// AfterFunc returns a TestTimer which simulates the behavior of a timer which
// was created via time.AfterFunc.
//
// See here for more details: https://pkg.go.dev/time#AfterFunc
func AfterFunc(d time.Duration, f func()) *TestTimer {
osutil.MustBeTestBinary("testtime timers cannot be used outside of tests")
timer := &TestTimer{
duration: d,
active: true,
callback: f,
}
// If duration is 0 or negative, ensure timer fires
defer timer.maybeFire()
return timer
}

// NewTimer creates a new Timer that will send the current time on its channel
// after the timer fires.
//
// NewTimer returns a TestTimer which simulates the behavior of a timer which
// was created via time.NewTimer.
//
// See here for more details: https://pkg.go.dev/time#NewTimer
func NewTimer(d time.Duration) *TestTimer {
osutil.MustBeTestBinary("testtime timers cannot be used outside of tests")
c := make(chan time.Time, 1)
timer := &TestTimer{
duration: d,
active: true,
c: c,
}
// If duration is 0 or negative, ensure timer fires
defer timer.maybeFire()
return timer
}

// ExpiredC returns the underlying C channel of the timer.
func (t *TestTimer) ExpiredC() <-chan time.Time {
return t.c
}

// Reset changes the timer to expire after duration d. It returns true if the
// timer had been active, false if the timer had expired or been stopped.
//
// As the test timer does not actually count down, Reset sets the timer's
// elapsed time to 0 and set its duration to the given duration. The elapsed
// time must be advanced manually using Elapse.
//
// This simulates the behavior of Timer.Reset() from the time package.
// See here fore more details: https://pkg.go.dev/time#Timer.Reset
func (t *TestTimer) Reset(d time.Duration) bool {
t.mu.Lock()
defer t.mu.Unlock()
active := t.active
t.active = true
t.duration = d
t.elapsed = 0
if t.c != nil {
// Drain the channel, guaranteeing that a receive after Reset will
// block until the timer fires again, and not receive a time value
// from the timer firing before the reset occurred.
// This complies with the new behavior of Reset as of Go 1.23.
// See: https://pkg.go.dev/time#Timer.Reset
select {
case <-t.c:
default:
}
}
// If duration is 0 or negative, ensure timer fires
defer t.maybeFire()
return active
}

// Stop prevents the timer from firing. It returns true if the call stops the
// timer, false if the timer has already expired or been stopped.
//
// This simulates the behavior of Timer.Stop() from the time package.
// See here for more details: https://pkg.go.dev/time#Timer.Stop
func (t *TestTimer) Stop() bool {
t.mu.Lock()
defer t.mu.Unlock()
wasActive := t.active
t.active = false
if t.c != nil {
// Drain the channel, guaranteeing that a receive after Stop will block
// and not receive a time value from the timer firing before the stop
// occurred. This complies with the new behavior of Stop as of Go 1.23.
// See: https://pkg.go.dev/time#Timer.Stop
select {
case <-t.c:
default:
}
}
return wasActive
}

// Active returns true if the timer is active, false if the timer has expired
// or been stopped.
func (t *TestTimer) Active() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.active
}

// FireCount returns the number of times the timer has fired.
func (t *TestTimer) FireCount() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.fireCount
}

// Elapse simulates time advancing by the given duration, which potentially
// causes the timer to fire.
//
// The timer will fire if the total elapsed time since the timer was created
// or reset is greater than the timer's duration and the timer has not yet
// fired.
func (t *TestTimer) Elapse(duration time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
t.elapsed += duration
t.maybeFire()
}

// maybeFire fires the timer if the elapsed time is greater than the timer's
// duration. The caller must hold the timer lock.
func (t *TestTimer) maybeFire() {
if t.elapsed >= t.duration {
t.doFire(time.Now())
}
}

// Fire causes the timer to fire. If the timer was created via NewTimer, then
// sends the given current time over the C channel.
//
// To avoid accidental misuse, panics if the timer is not active (if it has
// already fired or been stopped).
func (t *TestTimer) Fire(currTime time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
if !t.active {
panic("cannot fire timer which is not active")
}
t.doFire(currTime)
}

// doFire carries out the timer firing. The caller must hold the timer lock.
func (t *TestTimer) doFire(currTime time.Time) {
if !t.active {
return
}
t.active = false
t.fireCount++
// Either t.callback or t.C should be non-nil, and the other should be nil.
if t.callback != nil {
go t.callback()
} else if t.c != nil {
t.c <- currTime
}
}
Loading

0 comments on commit e716737

Please sign in to comment.