From fa3ebdbaafb38f0cc44533ec2f62a3fd7da19eb3 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 31 Oct 2025 12:09:42 +0000 Subject: [PATCH 1/2] test: add goleak to detect goroutine leaks --- go.mod | 2 ++ mock.go | 5 +++++ mock_test.go | 10 +++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2ac6d39..79471ab 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/coder/quartz go 1.23.9 + +require go.uber.org/goleak v1.3.0 diff --git a/mock.go b/mock.go index d168849..d70f245 100644 --- a/mock.go +++ b/mock.go @@ -761,6 +761,11 @@ func (t *Trap) matches(c *apiCall) bool { func (t *Trap) Close() { t.mock.mu.Lock() defer t.mock.mu.Unlock() + select { + case <-t.done: + return // already closed + default: + } if t.unreleasedCalls != 0 { t.mock.tb.Helper() t.mock.tb.Errorf("trap Closed() with %d unreleased calls", t.unreleasedCalls) diff --git a/mock_test.go b/mock_test.go index 79e58e0..de0aab0 100644 --- a/mock_test.go +++ b/mock_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/coder/quartz" + "go.uber.org/goleak" ) func TestTimer_NegativeDuration(t *testing.T) { @@ -403,7 +404,10 @@ func Test_UnreleasedCalls(t *testing.T) { _ = mClock.Now() }() - trap.MustWait(testCtx) // missing release + c := trap.MustWait(testCtx) // missing release + trap.Close() // detect unreleased call and fail + + c.Release(testCtx) // clean up goroutine }) } @@ -573,3 +577,7 @@ func TestTickerStop_Go123(t *testing.T) { // OK! } } + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} From a7c6e08eaadf9a97ebb6f73b5f6788ac352297a7 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 31 Oct 2025 13:47:51 +0000 Subject: [PATCH 2/2] remove dependency --- go.mod | 2 -- mock_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 79471ab..2ac6d39 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,3 @@ module github.com/coder/quartz go 1.23.9 - -require go.uber.org/goleak v1.3.0 diff --git a/mock_test.go b/mock_test.go index de0aab0..23e75b1 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1,14 +1,17 @@ package quartz_test import ( + "bytes" "context" "errors" "fmt" + "os" + "runtime/pprof" + "strings" "testing" "time" "github.com/coder/quartz" - "go.uber.org/goleak" ) func TestTimer_NegativeDuration(t *testing.T) { @@ -579,5 +582,53 @@ func TestTickerStop_Go123(t *testing.T) { } func TestMain(m *testing.M) { - goleak.VerifyTestMain(m) + verifyNoLeakTestMain(m) +} + +func verifyNoLeakTestMain(m *testing.M) { + before := snapshot() + code := m.Run() + now := time.Now() + for { + after := snapshot() + if len(after) > len(before) { + // Allow test cleanup to settle. + if time.Since(now) < 200*time.Millisecond { + time.Sleep(50 * time.Millisecond) + continue + } + fmt.Fprintln(os.Stderr, "Possible goroutine leak(s):") + fmt.Fprintln(os.Stderr, diff(before, after)) + os.Exit(1) + } + os.Exit(code) + } +} + +func snapshot() []string { + var buf bytes.Buffer + _ = pprof.Lookup("goroutine").WriteTo(&buf, 2) + var clean []string + for _, s := range strings.Split(buf.String(), "\n\n") { + if !strings.Contains(s, "runtime/pprof") { + clean = append(clean, s) + } + } + return clean +} + +func diff(a, b []string) string { + m := make(map[string]int) + for _, s := range a { + m[s]++ + } + var leaks []string + for _, s := range b { + if m[s] > 0 { + m[s]-- + continue + } + leaks = append(leaks, s) + } + return strings.Join(leaks, "\n\n") }