From d87ad1fb3a318495e09d0e44180610b79c2fa865 Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 23 May 2025 15:33:54 +0400 Subject: [PATCH 1/3] feat: fail test on trapped but unreleased calls (#15) part of #13 Will fail test with an error message if you trap a call and don't release it. I've also added 2 test cases to the unit test. One is a skipped test that you can unskip to see what it looks like if you don't release the trapped call. One tests what happens if two different traps catch the same call. It gets a little awkward because when you `Release()` the call, it waits for the call to complete. One of the main purposes is to ensure that a timer or ticker is set, and if we don't wait for the call to complete, you can't ensure the timer is set before you advance the clock. The awkward consequence is that the `Release()` calls will deadlock if they happen on the same goroutine because _all_ traps have to release the call before it will return. We separate out the trapping of the call and releasing of the call so that you have a chance to manipulate the clock before the call returns. But, actually, there are really 3 phases to a trapped call: 1. Call is trapped 2. All traps released, we get the time and do the work (e.g. actually setting the timer) 3. Call completes After `trap.Wait()` returns, we know phase 1 is complete. But, `Release()` actually conflates phase 2 and 3, so there is no way to release the trap without waiting for phase 3. Generally we don't care that much about the distinction, it's really only in the case of multple traps that you'd need to release without waiting to avoid the deadlock. We could make those phases explicit: `trap.Wait().Release().WaitForComplete()`, but that seems pretty involved for what I think is generally an edge case. WDYT? --- mock.go | 66 ++++++++++++++++++++++------- mock_test.go | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 15 deletions(-) diff --git a/mock.go b/mock.go index 5d81270..f67354e 100644 --- a/mock.go +++ b/mock.go @@ -190,7 +190,7 @@ func (m *Mock) removeEventLocked(e event) { } } -func (m *Mock) matchCallLocked(c *Call) { +func (m *Mock) matchCallLocked(c *apiCall) { var traps []*Trap for _, t := range m.traps { if t.matches(c) { @@ -435,7 +435,7 @@ func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { fn: fn, tags: tags, mock: m, - calls: make(chan *Call), + calls: make(chan *apiCall), done: make(chan struct{}), } m.traps = append(m.traps, tr) @@ -557,9 +557,10 @@ const ( clockFunctionUntil ) -type callArg func(c *Call) +type callArg func(c *apiCall) -type Call struct { +// apiCall represents a single call to one of the Clock APIs. +type apiCall struct { Time time.Time Duration time.Duration Tags []string @@ -569,25 +570,36 @@ type Call struct { complete chan struct{} } +// Call represents an apiCall that has been trapped. +type Call struct { + Time time.Time + Duration time.Duration + Tags []string + + apiCall *apiCall + trap *Trap +} + func (c *Call) Release() { - c.releases.Done() - <-c.complete + c.apiCall.releases.Done() + <-c.apiCall.complete + c.trap.callReleased() } func withTime(t time.Time) callArg { - return func(c *Call) { + return func(c *apiCall) { c.Time = t } } func withDuration(d time.Duration) callArg { - return func(c *Call) { + return func(c *apiCall) { c.Duration = d } } -func newCall(fn clockFunction, tags []string, args ...callArg) *Call { - c := &Call{ +func newCall(fn clockFunction, tags []string, args ...callArg) *apiCall { + c := &apiCall{ fn: fn, Tags: tags, complete: make(chan struct{}), @@ -602,19 +614,23 @@ type Trap struct { fn clockFunction tags []string mock *Mock - calls chan *Call + calls chan *apiCall done chan struct{} + + // mu protects the unreleasedCalls count + mu sync.Mutex + unreleasedCalls int } -func (t *Trap) catch(c *Call) { +func (t *Trap) catch(c *apiCall) { select { case t.calls <- c: case <-t.done: - c.Release() + c.releases.Done() } } -func (t *Trap) matches(c *Call) bool { +func (t *Trap) matches(c *apiCall) bool { if t.fn != c.fn { return false } @@ -629,6 +645,10 @@ func (t *Trap) matches(c *Call) bool { func (t *Trap) Close() { t.mock.mu.Lock() defer t.mock.mu.Unlock() + if t.unreleasedCalls != 0 { + t.mock.tb.Helper() + t.mock.tb.Errorf("trap Closed() with %d unreleased calls", t.unreleasedCalls) + } for i, tr := range t.mock.traps { if t == tr { t.mock.traps = append(t.mock.traps[:i], t.mock.traps[i+1:]...) @@ -637,6 +657,12 @@ func (t *Trap) Close() { close(t.done) } +func (t *Trap) callReleased() { + t.mu.Lock() + defer t.mu.Unlock() + t.unreleasedCalls-- +} + var ErrTrapClosed = errors.New("trap closed") func (t *Trap) Wait(ctx context.Context) (*Call, error) { @@ -645,7 +671,17 @@ func (t *Trap) Wait(ctx context.Context) (*Call, error) { return nil, ctx.Err() case <-t.done: return nil, ErrTrapClosed - case c := <-t.calls: + case a := <-t.calls: + c := &Call{ + Time: a.Time, + Duration: a.Duration, + Tags: a.Tags, + apiCall: a, + trap: t, + } + t.mu.Lock() + defer t.mu.Unlock() + t.unreleasedCalls++ return c, nil } } diff --git a/mock_test.go b/mock_test.go index 8099738..2d2097a 100644 --- a/mock_test.go +++ b/mock_test.go @@ -319,3 +319,117 @@ func TestTickerFunc_LongCallback(t *testing.T) { } w.MustWait(testCtx) } + +func Test_MultipleTraps(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + mClock := quartz.NewMock(t) + + trap0 := mClock.Trap().Now("0") + defer trap0.Close() + trap1 := mClock.Trap().Now("1") + defer trap1.Close() + + timeCh := make(chan time.Time) + go func() { + timeCh <- mClock.Now("0", "1") + }() + + c0 := trap0.MustWait(testCtx) + mClock.Advance(time.Second) + // the two trapped call instances need to be released on separate goroutines since they each wait for the Now() call + // to return, which is blocked on both releases happening. If you release them on the same goroutine, in either + // order, it will deadlock. + done := make(chan struct{}) + go func() { + defer close(done) + c0.Release() + }() + c1 := trap1.MustWait(testCtx) + mClock.Advance(time.Second) + c1.Release() + + select { + case <-done: + case <-testCtx.Done(): + t.Fatal("timed out waiting for c0.Release()") + } + + select { + case got := <-timeCh: + end := mClock.Now("end") + if !got.Equal(end) { + t.Fatalf("expected %s got %s", end, got) + } + case <-testCtx.Done(): + t.Fatal("timed out waiting for Now()") + } +} + +func Test_UnreleasedCalls(t *testing.T) { + t.Parallel() + tRunFail(t, func(t testing.TB) { + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + mClock := quartz.NewMock(t) + + trap := mClock.Trap().Now() + defer trap.Close() + + go func() { + _ = mClock.Now() + }() + + trap.MustWait(testCtx) // missing release + }) +} + +type captureFailTB struct { + failed bool + testing.TB +} + +func (t *captureFailTB) Errorf(format string, args ...any) { + t.Helper() + t.Logf(format, args...) + t.failed = true +} + +func (t *captureFailTB) Error(args ...any) { + t.Helper() + t.Log(args...) + t.failed = true +} + +func (t *captureFailTB) Fatal(args ...any) { + t.Helper() + t.Log(args...) + t.failed = true +} + +func (t *captureFailTB) Fatalf(format string, args ...any) { + t.Helper() + t.Logf(format, args...) + t.failed = true +} + +func (t *captureFailTB) Fail() { + t.failed = true +} + +func (t *captureFailTB) FailNow() { + t.failed = true +} + +func (t *captureFailTB) Failed() bool { + return t.failed +} + +func tRunFail(t testing.TB, f func(t testing.TB)) { + tb := &captureFailTB{TB: t} + f(tb) + if !tb.Failed() { + t.Fatal("want test to fail") + } +} From 49f235b02ae9a2215a443a69f136f7a7ab8693db Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 23 May 2025 15:34:43 +0400 Subject: [PATCH 2/3] feat: log clock calls, traps, time advancement (#16) fixes #13 Adds logging of traps, calls, and advancing the clock like: ``` === CONT Test_MultipleTraps mock.go:438: Mock Clock - Trap Now(..., [0]) mock.go:438: Mock Clock - Trap Now(..., [1]) mock.go:200: Mock Clock - Now([0 1]) call, matched 2 traps mock_test.go:340: Mock Clock - Advance(1s) mock_test.go:350: Mock Clock - Advance(1s) mock.go:200: Mock Clock - Now([end]) call, matched 0 traps ``` This should make it easier to debug a variety of issues, including not setting the trap before the call happens. --- mock.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/mock.go b/mock.go index f67354e..db14f33 100644 --- a/mock.go +++ b/mock.go @@ -197,6 +197,7 @@ func (m *Mock) matchCallLocked(c *apiCall) { traps = append(traps, t) } } + m.tb.Logf("Mock Clock - %s call, matched %d traps", c, len(traps)) if len(traps) == 0 { return } @@ -260,6 +261,7 @@ func (m *Mock) Advance(d time.Duration) AdvanceWaiter { m.tb.Helper() w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} m.mu.Lock() + m.tb.Logf("Mock Clock - Advance(%s)", d) fin := m.cur.Add(d) // nextTime.IsZero implies no events scheduled. if m.nextTime.IsZero() || fin.Before(m.nextTime) { @@ -307,6 +309,7 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { m.tb.Helper() w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} m.mu.Lock() + m.tb.Logf("Mock Clock - Set(%s)", t) if t.Before(m.cur) { defer close(w.ch) defer m.mu.Unlock() @@ -343,6 +346,7 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { // wait for the timer/tick event(s) to finish. func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { m.mu.Lock() + m.tb.Logf("Mock Clock - AdvanceNext()") m.tb.Helper() w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} if m.nextTime.IsZero() { @@ -431,6 +435,7 @@ func (m *Mock) Trap() Trapper { func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { m.mu.Lock() defer m.mu.Unlock() + m.tb.Logf("Mock Clock - Trap %s(..., %v)", fn, tags) tr := &Trap{ fn: fn, tags: tags, @@ -557,6 +562,37 @@ const ( clockFunctionUntil ) +func (c clockFunction) String() string { + switch c { + case clockFunctionNewTimer: + return "NewTimer" + case clockFunctionAfterFunc: + return "AfterFunc" + case clockFunctionTimerStop: + return "Timer.Stop" + case clockFunctionTimerReset: + return "Timer.Reset" + case clockFunctionTickerFunc: + return "TickerFunc" + case clockFunctionTickerFuncWait: + return "TickerFunc.Wait" + case clockFunctionNewTicker: + return "NewTicker" + case clockFunctionTickerReset: + return "Ticker.Reset" + case clockFunctionTickerStop: + return "Ticker.Stop" + case clockFunctionNow: + return "Now" + case clockFunctionSince: + return "Since" + case clockFunctionUntil: + return "Until" + default: + return "?????" + } +} + type callArg func(c *apiCall) // apiCall represents a single call to one of the Clock APIs. @@ -570,6 +606,37 @@ type apiCall struct { complete chan struct{} } +func (a *apiCall) String() string { + switch a.fn { + case clockFunctionNewTimer: + return fmt.Sprintf("NewTimer(%s, %v)", a.Duration, a.Tags) + case clockFunctionAfterFunc: + return fmt.Sprintf("AfterFunc(%s, , %v)", a.Duration, a.Tags) + case clockFunctionTimerStop: + return fmt.Sprintf("Timer.Stop(%v)", a.Tags) + case clockFunctionTimerReset: + return fmt.Sprintf("Timer.Reset(%s, %v)", a.Duration, a.Tags) + case clockFunctionTickerFunc: + return fmt.Sprintf("TickerFunc(, %s, , %s)", a.Duration, a.Tags) + case clockFunctionTickerFuncWait: + return fmt.Sprintf("TickerFunc.Wait(%v)", a.Tags) + case clockFunctionNewTicker: + return fmt.Sprintf("NewTicker(%s, %v)", a.Duration, a.Tags) + case clockFunctionTickerReset: + return fmt.Sprintf("Ticker.Reset(%s, %v)", a.Duration, a.Tags) + case clockFunctionTickerStop: + return fmt.Sprintf("Ticker.Stop(%v)", a.Tags) + case clockFunctionNow: + return fmt.Sprintf("Now(%v)", a.Tags) + case clockFunctionSince: + return fmt.Sprintf("Since(%s, %v)", a.Time, a.Tags) + case clockFunctionUntil: + return fmt.Sprintf("Until(%s, %v)", a.Time, a.Tags) + default: + return "?????" + } +} + // Call represents an apiCall that has been trapped. type Call struct { Time time.Time From 09f5542992ccc9ad602eb4d9b0a830da9dbfa05f Mon Sep 17 00:00:00 2001 From: Spike Curtis Date: Fri, 23 May 2025 16:29:33 +0400 Subject: [PATCH 3/3] !feat: Call.Release takes context; add MustRelease (#17) **BREAKING CHANGE** Adds a `context.Context` to `(*Call.).Release()` and a new `MustRelease()`, since releasing a call can be blocking. Use like ``` err := call.Release(ctx) if err != nil { t.Error(err.Error()) } ``` or more succinctly ``` call.MustRelease(ctx) ``` This, combined with a per-test timeout context, should make it much easier to debug issues if you have a call that gets trapped by more than one trap. --- README.md | 22 +++++++++++----------- example_test.go | 6 +++--- mock.go | 33 +++++++++++++++++++++++++++++---- mock_test.go | 38 ++++++++++++++++++++++++++++++-------- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f0703c4..b0da401 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ func TestTrap(t *testing.T) { count++ }) call := trap.MustWait(ctx) - call.Release() + call.MustRelease(ctx) if call.Duration != time.Hour { t.Fatal("wrong duration") } @@ -268,15 +268,15 @@ func TestTrap2(t *testing.T) { }(mClock) // start - trap.MustWait(ctx).Release() + trap.MustWait(ctx).MustRelease(ctx) // phase 1 call := trap.MustWait(ctx) mClock.Advance(3*time.Second).MustWait(ctx) - call.Release() + call.MustRelease(ctx) // phase 2 call = trap.MustWait(ctx) mClock.Advance(5*time.Second).MustWait(ctx) - call.Release() + call.MustRelease(ctx) <-done // Now logs contains []string{"Phase 1 took 3s", "Phase 2 took 5s"} @@ -302,7 +302,7 @@ go func(){ }() call := trap.MustWait(ctx) mClock.Advance(time.Second).MustWait(ctx) -call.Release() +call.MustRelease(ctx) // call.Tags contains []string{"foo", "bar"} gotFoo := <-foo // 1s after start @@ -478,8 +478,8 @@ func TestTicker(t *testing.T) { trap := mClock.Trap().TickerFunc() defer trap.Close() // stop trapping at end go runMyTicker(mClock) // async calls TickerFunc() - call := trap.Wait(context.Background()) // waits for a call and blocks its return - call.Release() // allow the TickerFunc() call to return + call := trap.MustWait(context.Background()) // waits for a call and blocks its return + call.MustRelease(ctx) // allow the TickerFunc() call to return // optionally check the duration using call.Duration // Move the clock forward 1 tick mClock.Advance(time.Second).MustWait(context.Background()) @@ -527,9 +527,9 @@ go func(clock quartz.Clock) { measurement = clock.Since(start) }(mClock) -c := trap.Wait(ctx) +c := trap.MustWait(ctx) mClock.Advance(5*time.Second) -c.Release() +c.MustRelease(ctx) ``` We wait until we trap the `clock.Since()` call, which implies that `clock.Now()` has completed, then @@ -617,10 +617,10 @@ func TestInactivityTimer_Late(t *testing.T) { // Trigger the AfterFunc w := mClock.Advance(10*time.Minute) - c := trap.Wait(ctx) + c := trap.MustWait(ctx) // Advance the clock a few ms to simulate a busy system mClock.Advance(3*time.Millisecond) - c.Release() // Until() returns + c.MustRelease(ctx) // Until() returns w.MustWait(ctx) // Wait for the AfterFunc to wrap up // Assert that the timeoutLocked() function was called diff --git a/example_test.go b/example_test.go index f798cdd..489aea7 100644 --- a/example_test.go +++ b/example_test.go @@ -65,7 +65,7 @@ func TestExampleTickerFunc(t *testing.T) { // it's good practice to release calls before any possible t.Fatal() calls // so that we don't leave dangling goroutines waiting for the call to be // released. - call.Release() + call.MustRelease(ctx) if call.Duration != time.Hour { t.Fatal("unexpected duration") } @@ -122,7 +122,7 @@ func TestExampleLatencyMeasurer(t *testing.T) { w := mClock.Advance(10 * time.Second) // triggers first tick c := trap.MustWait(ctx) // call to Since() mClock.Advance(33 * time.Millisecond) - c.Release() + c.MustRelease(ctx) w.MustWait(ctx) if l := lm.LastLatency(); l != 33*time.Millisecond { @@ -133,7 +133,7 @@ func TestExampleLatencyMeasurer(t *testing.T) { d, w2 := mClock.AdvanceNext() c = trap.MustWait(ctx) mClock.Advance(17 * time.Millisecond) - c.Release() + c.MustRelease(ctx) w2.MustWait(ctx) expectedD := 10*time.Second - 33*time.Millisecond diff --git a/mock.go b/mock.go index db14f33..f2c1a19 100644 --- a/mock.go +++ b/mock.go @@ -589,7 +589,7 @@ func (c clockFunction) String() string { case clockFunctionUntil: return "Until" default: - return "?????" + return fmt.Sprintf("Unknown clockFunction(%d)", c) } } @@ -633,7 +633,7 @@ func (a *apiCall) String() string { case clockFunctionUntil: return fmt.Sprintf("Until(%s, %v)", a.Time, a.Tags) default: - return "?????" + return fmt.Sprintf("Unknown clockFunction(%d)", a.fn) } } @@ -643,14 +643,38 @@ type Call struct { Duration time.Duration Tags []string + tb testing.TB apiCall *apiCall trap *Trap } -func (c *Call) Release() { +// Release the call and wait for it to complete. If the provided context expires before the call completes, it returns +// an error. +// +// IMPORTANT: If a call is trapped by more than one trap, they all must release the call before it can complete, and +// they must do so from different goroutines. +func (c *Call) Release(ctx context.Context) error { c.apiCall.releases.Done() - <-c.apiCall.complete + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for release; did more than one trap capture the call?: %w", ctx.Err()) + case <-c.apiCall.complete: + // OK + } c.trap.callReleased() + return nil +} + +// MustRelease releases the call and waits for it to complete. If the provided context expires before the call +// completes, it fails the test. +// +// IMPORTANT: If a call is trapped by more than one trap, they all must release the call before it can complete, and +// they must do so from different goroutines. +func (c *Call) MustRelease(ctx context.Context) { + if err := c.Release(ctx); err != nil { + c.tb.Helper() + c.tb.Fatal(err.Error()) + } } func withTime(t time.Time) callArg { @@ -745,6 +769,7 @@ func (t *Trap) Wait(ctx context.Context) (*Call, error) { Tags: a.Tags, apiCall: a, trap: t, + tb: t.mock.tb, } t.mu.Lock() defer t.mu.Unlock() diff --git a/mock_test.go b/mock_test.go index 2d2097a..5663762 100644 --- a/mock_test.go +++ b/mock_test.go @@ -24,7 +24,7 @@ func TestTimer_NegativeDuration(t *testing.T) { timers <- mClock.NewTimer(-time.Second) }() c := trap.MustWait(ctx) - c.Release() + c.MustRelease(ctx) // trap returns the actual passed value if c.Duration != -time.Second { t.Fatalf("expected -time.Second, got: %v", c.Duration) @@ -62,7 +62,7 @@ func TestAfterFunc_NegativeDuration(t *testing.T) { }) }() c := trap.MustWait(ctx) - c.Release() + c.MustRelease(ctx) // trap returns the actual passed value if c.Duration != -time.Second { t.Fatalf("expected -time.Second, got: %v", c.Duration) @@ -99,7 +99,7 @@ func TestNewTicker(t *testing.T) { tickers <- mClock.NewTicker(time.Hour, "new") }() c := trapNT.MustWait(ctx) - c.Release() + c.MustRelease(ctx) if c.Duration != time.Hour { t.Fatalf("expected time.Hour, got: %v", c.Duration) } @@ -123,7 +123,7 @@ func TestNewTicker(t *testing.T) { go tkr.Reset(time.Minute, "reset") c = trapReset.MustWait(ctx) mClock.Advance(time.Second).MustWait(ctx) - c.Release() + c.MustRelease(ctx) if c.Duration != time.Minute { t.Fatalf("expected time.Minute, got: %v", c.Duration) } @@ -142,7 +142,7 @@ func TestNewTicker(t *testing.T) { } go tkr.Stop("stop") - trapStop.MustWait(ctx).Release() + trapStop.MustWait(ctx).MustRelease(ctx) mClock.Advance(time.Hour).MustWait(ctx) select { case <-tkr.C: @@ -153,7 +153,7 @@ func TestNewTicker(t *testing.T) { // Resetting after stop go tkr.Reset(time.Minute, "reset") - trapReset.MustWait(ctx).Release() + trapReset.MustWait(ctx).MustRelease(ctx) mClock.Advance(time.Minute).MustWait(ctx) tTime = mClock.Now() select { @@ -344,11 +344,11 @@ func Test_MultipleTraps(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - c0.Release() + c0.MustRelease(testCtx) }() c1 := trap1.MustWait(testCtx) mClock.Advance(time.Second) - c1.Release() + c1.MustRelease(testCtx) select { case <-done: @@ -367,6 +367,28 @@ func Test_MultipleTraps(t *testing.T) { } } +func Test_MultipleTrapsDeadlock(t *testing.T) { + t.Parallel() + tRunFail(t, func(t testing.TB) { + testCtx, testCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer testCancel() + mClock := quartz.NewMock(t) + + trap0 := mClock.Trap().Now("0") + defer trap0.Close() + trap1 := mClock.Trap().Now("1") + defer trap1.Close() + + timeCh := make(chan time.Time) + go func() { + timeCh <- mClock.Now("0", "1") + }() + + c0 := trap0.MustWait(testCtx) + c0.MustRelease(testCtx) // deadlocks, test failure + }) +} + func Test_UnreleasedCalls(t *testing.T) { t.Parallel() tRunFail(t, func(t testing.TB) {