From b8553796e9071a652728ec7b4de1d006198bf8a2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:37:30 +0000 Subject: [PATCH 1/2] fix: copy tags passed to mock clock calls and traps newCall and newTrap kept the caller's variadic tags slice. Code that reused its slice after a clock call returned raced with a test reading Call.Tags, and the test saw the new values. Reusing a slice passed to Trap().Now(...) also changed which calls the trap matched. --- mock.go | 7 +++++-- mock_test.go | 20 +++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/mock.go b/mock.go index 7255aff..f302b0f 100644 --- a/mock.go +++ b/mock.go @@ -454,9 +454,10 @@ func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { if !m.testOver { m.logger.Logf("Mock Clock - Trap %s(..., %v)", fn, tags) } + // Copy tags because the caller may reuse its slice while the trap still matches calls. tr := &Trap{ fn: fn, - tags: tags, + tags: slices.Clone(tags), mock: m, calls: make(chan *apiCall), done: make(chan struct{}), @@ -728,9 +729,11 @@ func withDuration(d time.Duration) callArg { } func newCall(fn clockFunction, tags []string, args ...callArg) *apiCall { + // Copy tags because the caller may reuse its slice after the call returns, while a trapped call + // still exposes them as Call.Tags. c := &apiCall{ fn: fn, - Tags: tags, + Tags: slices.Clone(tags), complete: make(chan struct{}), } for _, a := range args { diff --git a/mock_test.go b/mock_test.go index 7bc822d..ffd0221 100644 --- a/mock_test.go +++ b/mock_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "runtime/pprof" + "slices" "strings" "testing" "time" @@ -331,14 +332,21 @@ func Test_MultipleTraps(t *testing.T) { defer testCancel() mClock := quartz.NewMock(t) - trap0 := mClock.Trap().Now("0") + // Reusing a tag slice after the call returns must not change the traps or the trapped calls. + trapTags := []string{"0"} + trap0 := mClock.Trap().Now(trapTags...) defer trap0.Close() - trap1 := mClock.Trap().Now("1") + trapTags[0] = "1" + trap1 := mClock.Trap().Now(trapTags...) defer trap1.Close() + trapTags[0] = "reused" timeCh := make(chan time.Time) go func() { - timeCh <- mClock.Now("0", "1") + callTags := []string{"0", "1"} + now := mClock.Now(callTags...) + callTags[0], callTags[1] = "reused", "reused" + timeCh <- now }() c0 := trap0.MustWait(testCtx) @@ -370,6 +378,12 @@ func Test_MultipleTraps(t *testing.T) { case <-testCtx.Done(): t.Fatal("timed out waiting for Now()") } + + for _, c := range []*quartz.Call{c0, c1} { + if want := []string{"0", "1"}; !slices.Equal(c.Tags, want) { + t.Fatalf("expected tags %v, got %v", want, c.Tags) + } + } } func Test_MultipleTrapsDeadlock(t *testing.T) { From d7447bc862fedaa48eccb75b984a14349c3296d8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:27:46 +0000 Subject: [PATCH 2/2] test: check tag slice reuse in a dedicated test Test_MultipleTraps covers releasing a call caught by two traps. Tag slice reuse is a separate behavior, so it gets its own test and Test_MultipleTraps goes back to its original form. --- mock_test.go | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/mock_test.go b/mock_test.go index ffd0221..17d7ea4 100644 --- a/mock_test.go +++ b/mock_test.go @@ -332,21 +332,14 @@ func Test_MultipleTraps(t *testing.T) { defer testCancel() mClock := quartz.NewMock(t) - // Reusing a tag slice after the call returns must not change the traps or the trapped calls. - trapTags := []string{"0"} - trap0 := mClock.Trap().Now(trapTags...) + trap0 := mClock.Trap().Now("0") defer trap0.Close() - trapTags[0] = "1" - trap1 := mClock.Trap().Now(trapTags...) + trap1 := mClock.Trap().Now("1") defer trap1.Close() - trapTags[0] = "reused" timeCh := make(chan time.Time) go func() { - callTags := []string{"0", "1"} - now := mClock.Now(callTags...) - callTags[0], callTags[1] = "reused", "reused" - timeCh <- now + timeCh <- mClock.Now("0", "1") }() c0 := trap0.MustWait(testCtx) @@ -378,12 +371,6 @@ func Test_MultipleTraps(t *testing.T) { case <-testCtx.Done(): t.Fatal("timed out waiting for Now()") } - - for _, c := range []*quartz.Call{c0, c1} { - if want := []string{"0", "1"}; !slices.Equal(c.Tags, want) { - t.Fatalf("expected tags %v, got %v", want, c.Tags) - } - } } func Test_MultipleTrapsDeadlock(t *testing.T) { @@ -428,6 +415,33 @@ func Test_UnreleasedCalls(t *testing.T) { }) } +func TestTags_CallerReusesSlice(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + mClock := quartz.NewMock(t) + + trapTags := []string{"foo"} + trap := mClock.Trap().Now(trapTags...) + defer trap.Close() + trapTags[0] = "reused" + + done := make(chan struct{}) + go func() { + defer close(done) + callTags := []string{"foo"} + mClock.Now(callTags...) + callTags[0] = "reused" + }() + + call := trap.MustWait(testCtx) + call.MustRelease(testCtx) + <-done + if want := []string{"foo"}; !slices.Equal(call.Tags, want) { + t.Fatalf("expected tags %v, got %v", want, call.Tags) + } +} + func Test_WithLogger(t *testing.T) { t.Parallel()