Skip to content

Commit d22fec5

Browse files
authored
fix: support Go 1.23+ timer & ticker channel behavior (#23)
fixes #22 Changes mock ticker and timer implementations based on channels to match Go 1.23+. c.f. https://go.dev/wiki/Go123Timer 1. Channels for tickers and timers are now unbuffered. 2. Channels reads will block after `Stop()` returns. 3. We use goroutines to write to the unbuffered channels. In order to avoid leaking go routines, when the test ends, any uncompleted channel writes are abandoned. This means that reads from the channels will block after the test exits. This is usually what you want, but is a small difference in behavior from before, where we wrote to a _buffered_ channel, and so reads could complete after the test completed.
1 parent 3734877 commit d22fec5

File tree

6 files changed

+211
-33
lines changed

6 files changed

+211
-33
lines changed

.github/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ jobs:
77
- uses: actions/checkout@v2
88
- uses: actions/setup-go@v4
99
with:
10-
go-version: "^1.21"
10+
go-version: "^1.23"
1111
- name: test
1212
run: go test .

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module github.com/coder/quartz
22

3-
go 1.21.8
3+
go 1.23.9

mock.go

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error,
5555
return t
5656
}
5757

58+
// NewTicker creates a mocked ticker attached to this Mock. Note that it will cease sending ticks on its channel at the
59+
// end of the test, to avoid leaking any goroutines. Ticks are suppressed even if the mock clock is advanced after the
60+
// test completes. Best practice is to only manipulate the mock time in the main goroutine of the test.
5861
func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker {
5962
if d <= 0 {
6063
panic("NewTicker called with negative or zero duration")
@@ -64,17 +67,7 @@ func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker {
6467
c := newCall(clockFunctionNewTicker, tags, withDuration(d))
6568
m.matchCallLocked(c)
6669
defer close(c.complete)
67-
// 1 element buffer follows standard library implementation
68-
ticks := make(chan time.Time, 1)
69-
t := &Ticker{
70-
C: ticks,
71-
c: ticks,
72-
d: d,
73-
nxt: m.cur.Add(d),
74-
mock: m,
75-
}
76-
m.addEventLocked(t)
77-
return t
70+
return newMockTickerLocked(m, d)
7871
}
7972

8073
func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer {
@@ -83,7 +76,7 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer {
8376
c := newCall(clockFunctionNewTimer, tags, withDuration(d))
8477
defer close(c.complete)
8578
m.matchCallLocked(c)
86-
ch := make(chan time.Time, 1)
79+
ch := make(chan time.Time)
8780
t := &Timer{
8881
C: ch,
8982
c: ch,
@@ -277,8 +270,8 @@ func (m *Mock) Advance(d time.Duration) AdvanceWaiter {
277270
return w
278271
}
279272
if fin.After(m.nextTime) {
280-
m.tb.Errorf(fmt.Sprintf("cannot advance %s which is beyond next timer/ticker event in %s",
281-
d.String(), m.nextTime.Sub(m.cur)))
273+
m.tb.Errorf("cannot advance %s which is beyond next timer/ticker event in %s",
274+
d.String(), m.nextTime.Sub(m.cur))
282275
m.mu.Unlock()
283276
close(w.ch)
284277
return w

mock_test.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,9 +380,8 @@ func Test_MultipleTrapsDeadlock(t *testing.T) {
380380
trap1 := mClock.Trap().Now("1")
381381
defer trap1.Close()
382382

383-
timeCh := make(chan time.Time)
384383
go func() {
385-
timeCh <- mClock.Now("0", "1")
384+
mClock.Now("0", "1")
386385
}()
387386

388387
c0 := trap0.MustWait(testCtx)
@@ -501,3 +500,76 @@ func (l *testLogger) Log(args ...any) {
501500
func (l *testLogger) Logf(format string, args ...any) {
502501
l.calls = append(l.calls, fmt.Sprintf(format, args...))
503502
}
503+
504+
func TestTimerStop_Go123(t *testing.T) {
505+
t.Parallel()
506+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
507+
defer cancel()
508+
509+
mClock := quartz.NewMock(t)
510+
511+
tmr := mClock.NewTimer(1 * time.Second)
512+
mClock.Advance(1 * time.Second).MustWait(ctx)
513+
if tmr.Stop() {
514+
t.Fatal("timer hadn't already been active")
515+
}
516+
517+
select {
518+
case tme := <-tmr.C:
519+
t.Fatalf("got channel read after stop: %s", tme)
520+
default:
521+
// OK!
522+
}
523+
}
524+
525+
func TestTimerReset_Go123(t *testing.T) {
526+
t.Parallel()
527+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
528+
defer cancel()
529+
530+
mClock := quartz.NewMock(t)
531+
532+
tmr := mClock.NewTimer(1 * time.Second)
533+
mClock.Advance(1 * time.Second).MustWait(ctx)
534+
if tmr.Reset(1 * time.Second) {
535+
t.Fatal("timer hadn't already been active")
536+
}
537+
538+
select {
539+
case tme := <-tmr.C:
540+
t.Fatalf("got channel read after stop: %s", tme)
541+
default:
542+
// OK!
543+
}
544+
}
545+
546+
func TestNoLeak_Go123(t *testing.T) {
547+
t.Parallel()
548+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
549+
defer cancel()
550+
551+
mClock := quartz.NewMock(t)
552+
553+
_ = mClock.NewTimer(1 * time.Second)
554+
_ = mClock.NewTicker(1 * time.Second)
555+
mClock.Advance(1 * time.Second).MustWait(ctx)
556+
}
557+
558+
func TestTickerStop_Go123(t *testing.T) {
559+
t.Parallel()
560+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
561+
defer cancel()
562+
563+
mClock := quartz.NewMock(t)
564+
565+
tkr := mClock.NewTicker(1 * time.Second)
566+
mClock.Advance(1 * time.Second).MustWait(ctx)
567+
tkr.Stop()
568+
569+
select {
570+
case tme := <-tkr.C:
571+
t.Fatalf("got channel read after stop: %s", tme)
572+
default:
573+
// OK!
574+
}
575+
}

ticker.go

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,25 @@ import "time"
66
type Ticker struct {
77
C <-chan time.Time
88
//nolint: revive
9-
c chan time.Time
10-
ticker *time.Ticker // realtime impl, if set
11-
d time.Duration // period, if set
12-
nxt time.Time // next tick time
13-
mock *Mock // mock clock, if set
14-
stopped bool // true if the ticker is not running
9+
c chan time.Time
10+
ticker *time.Ticker // realtime impl, if set
11+
d time.Duration // period, if set
12+
nxt time.Time // next tick time
13+
mock *Mock // mock clock, if set
14+
stopped bool // true if the ticker is not running
15+
internalTicks chan time.Time // used to deliver ticks to the runLoop goroutine
16+
17+
// As of Go 1.23, ticker channels are unbuffered and guaranteed to block forever after a call to stop.
18+
//
19+
// When a mocked ticker fires, we don't want to block on a channel write, because it's fine for the code under test
20+
// not to be reading. That means we need to start a new goroutine to do the channel write (runLoop) if we are a
21+
// channel-based ticker.
22+
//
23+
// They also are not supposed to leak even if they are never read or stopped (Go runtime can garbage collect them).
24+
// We can't garbage-collect because we can't check if any other code besides the mock references, but we can ensure
25+
// that we don't leak goroutines so that the garbage collector can do its job when the mock is no longer
26+
// referenced. The channels below allow us to interrupt the runLoop goroutine.
27+
interrupt chan struct{}
1528
}
1629

1730
func (t *Ticker) fire(tt time.Time) {
@@ -24,9 +37,8 @@ func (t *Ticker) fire(tt time.Time) {
2437
t.nxt = t.nxt.Add(t.d)
2538
}
2639
t.mock.recomputeNextLocked()
27-
select {
28-
case t.c <- tt:
29-
default:
40+
if t.interrupt != nil { // implies runLoop is still going.
41+
t.internalTicks <- tt
3042
}
3143
}
3244

@@ -49,6 +61,11 @@ func (t *Ticker) Stop(tags ...string) {
4961
defer close(c.complete)
5062
t.mock.removeEventLocked(t)
5163
t.stopped = true
64+
// check if we've already fired, and if so, interrupt it.
65+
if t.interrupt != nil {
66+
<-t.interrupt
67+
t.interrupt = nil
68+
}
5269
}
5370

5471
// Reset stops a ticker and resets its period to the specified duration. The
@@ -72,4 +89,63 @@ func (t *Ticker) Reset(d time.Duration, tags ...string) {
7289
} else {
7390
t.mock.recomputeNextLocked()
7491
}
92+
if t.interrupt == nil {
93+
t.startRunLoopLocked()
94+
}
95+
}
96+
97+
func (t *Ticker) runLoop(interrupt chan struct{}) {
98+
defer close(interrupt)
99+
outer:
100+
for {
101+
select {
102+
case tt := <-t.internalTicks:
103+
for {
104+
select {
105+
case t.c <- tt:
106+
continue outer
107+
case <-t.internalTicks:
108+
// Discard future ticks until we can send this one.
109+
case interrupt <- struct{}{}:
110+
return
111+
}
112+
}
113+
case interrupt <- struct{}{}:
114+
return
115+
}
116+
}
117+
}
118+
119+
func (t *Ticker) startRunLoopLocked() {
120+
// assert some assumptions. If these fire, it is a bug in Quartz itself.
121+
if t.interrupt != nil {
122+
t.mock.tb.Error("called startRunLoopLocked when interrupt suggests we are already running")
123+
}
124+
interrupt := make(chan struct{})
125+
t.interrupt = interrupt
126+
go t.runLoop(interrupt)
127+
}
128+
129+
func newMockTickerLocked(m *Mock, d time.Duration) *Ticker {
130+
// no buffer follows Go 1.23+ behavior
131+
ticks := make(chan time.Time)
132+
t := &Ticker{
133+
C: ticks,
134+
c: ticks,
135+
d: d,
136+
nxt: m.cur.Add(d),
137+
mock: m,
138+
internalTicks: make(chan time.Time),
139+
}
140+
m.addEventLocked(t)
141+
m.tb.Cleanup(func() {
142+
m.mu.Lock()
143+
defer m.mu.Unlock()
144+
if t.interrupt != nil {
145+
<-t.interrupt
146+
t.interrupt = nil
147+
}
148+
})
149+
t.startRunLoopLocked()
150+
return t
75151
}

timer.go

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package quartz
22

3-
import "time"
3+
import (
4+
"time"
5+
)
46

57
// The Timer type represents a single event. When the Timer expires, the current time will be sent
68
// on C, unless the Timer was created by AfterFunc. A Timer must be created with NewTimer or
@@ -14,14 +16,43 @@ type Timer struct {
1416
mock *Mock // mock clock, if set
1517
fn func() // AfterFunc function, if set
1618
stopped bool // True if stopped, false if running
19+
20+
// As of Go 1.23, timer channels are unbuffered and guaranteed to block forever after a call to stop.
21+
//
22+
// When a mocked timer fires, we don't want to block on a channel write, because it's fine for the code under test
23+
// not to be reading. That means we need to start a new goroutine to do the channel write if we are a channel-based
24+
// timer.
25+
//
26+
// They also are not supposed to leak even if they are never read or stopped (Go runtime can garbage collect them).
27+
// We can't garbage-collect because we can't check if any other code besides the mock references, but we can ensure
28+
// that we don't leak goroutines so that the garbage collector can do its job when the mock is no longer
29+
// referenced. The channels below allow us to interrupt the channel write goroutine.
30+
interrupt chan struct{}
1731
}
1832

1933
func (t *Timer) fire(tt time.Time) {
20-
t.mock.removeTimer(t)
34+
t.mock.mu.Lock()
35+
t.mock.removeTimerLocked(t)
2136
if t.fn != nil {
37+
t.mock.mu.Unlock()
2238
t.fn()
39+
return
2340
} else {
24-
t.c <- tt
41+
interrupt := make(chan struct{})
42+
// Prevents the goroutine from leaking beyond the test. Side effect is that timer channels cannot be read
43+
// after the test exits.
44+
t.mock.tb.Cleanup(func() {
45+
<-interrupt
46+
})
47+
t.interrupt = interrupt
48+
t.mock.mu.Unlock()
49+
go func() {
50+
defer close(interrupt)
51+
select {
52+
case t.c <- tt:
53+
case interrupt <- struct{}{}:
54+
}
55+
}()
2556
}
2657
}
2758

@@ -45,6 +76,11 @@ func (t *Timer) Stop(tags ...string) bool {
4576
defer close(c.complete)
4677
result := !t.stopped
4778
t.mock.removeTimerLocked(t)
79+
// check if we've already fired, and if so, interrupt it.
80+
if t.interrupt != nil {
81+
<-t.interrupt
82+
t.interrupt = nil
83+
}
4884
return result
4985
}
5086

@@ -62,9 +98,10 @@ func (t *Timer) Reset(d time.Duration, tags ...string) bool {
6298
t.mock.matchCallLocked(c)
6399
defer close(c.complete)
64100
result := !t.stopped
65-
select {
66-
case <-t.c:
67-
default:
101+
// check if we've already fired, and if so, interrupt it.
102+
if t.interrupt != nil {
103+
<-t.interrupt
104+
t.interrupt = nil
68105
}
69106
if d <= 0 {
70107
// zero or negative duration timer means we should immediately re-fire

0 commit comments

Comments
 (0)