detached 11a76eb

test: run timing-dependent tests in synctest bubbles with real delays

- maintenance debounce, event watchers, idle revalidation, event bus and the
  ermite-git repo cache use the fake clock with exact boundaries
- the unsupported repo retry delay gets a test
- merge request integration tests drain events without waiting on quiet time
- removes the test-only Maintainer.Idle hook
commit
11a76eb6a727c30dce1e800e153d9634435224a3
author
brice · · 2026-10-11 18:58 UTC
parent
c505b30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +920 -704
M internal/adapters/ermitegit/cache_test.go +109 -35
@@ -11,6 +11,7 @@ import (
1111 "path/filepath"
1212 "sync"
1313 "testing"
14+ "testing/synctest"
1415 "time"
1516
1617 "github.com/bnema/zerowrap"
@@ -207,56 +208,129 @@ func TestEncodedCommitIsServedByGit(t *testing.T) {
207208 assert.Equal(t, "café", got.Commits[0].Message)
208209 }
209210
211+// The idle tests run in a synctest bubble: the clock is fake, so IdleTTL is the
212+// production one and the boundaries are exact. The sweeper ticks every
213+// IdleTTL/4 from the creation of the adapter and closes what has been idle for
214+// more than IdleTTL, so a repository last used at t is closed at the first tick
215+// after t+IdleTTL, which is t+IdleTTL+IdleTTL/4 here (no time passes while the
216+// fixtures are built: the bubble's clock only moves when everything sleeps).
217+const idleTTL = ermitegit.DefaultIdleTTL
218+
210219 // A repository that nobody reads for IdleTTL is closed, without a goroutine
211220 // per repository.
212221 func TestIdleRepositoriesAreClosed(t *testing.T) {
213- ctx := context.Background()
214- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond })
215- f := commitOf(t, data, "idle")
216- _, err := a.ListRefs(ctx, f.Name)
217- require.NoError(t, err)
218- require.Equal(t, 1, a.OpenRepos())
219- require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond)
222+ synctest.Test(t, func(t *testing.T) {
223+ ctx := context.Background()
224+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
225+ f := commitOf(t, data, "idle")
226+ _, err := a.ListRefs(ctx, f.Name)
227+ require.NoError(t, err)
228+ require.Equal(t, 1, a.OpenRepos())
220229
221- // It opens again on the next read.
222- _, err = a.ListRefs(ctx, f.Name)
223- require.NoError(t, err)
224- assert.Equal(t, 1, a.OpenRepos())
230+ synctest.Sleep(idleTTL) // idle for exactly the TTL: not more
231+ assert.Equal(t, 1, a.OpenRepos(), "not idle for more than the TTL yet")
232+ synctest.Sleep(idleTTL/4 - time.Nanosecond)
233+ assert.Equal(t, 1, a.OpenRepos(), "the sweeper has not ticked yet")
234+ synctest.Sleep(time.Nanosecond)
235+ assert.Equal(t, 0, a.OpenRepos(), "closed by the first sweep after the TTL")
236+
237+ // It opens again on the next read.
238+ _, err = a.ListRefs(ctx, f.Name)
239+ require.NoError(t, err)
240+ assert.Equal(t, 1, a.OpenRepos())
241+ })
242+}
243+
244+// A read keeps the repository open: the TTL counts from the last use.
245+func TestReadsKeepARepositoryOpen(t *testing.T) {
246+ synctest.Test(t, func(t *testing.T) {
247+ ctx := context.Background()
248+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
249+ f := commitOf(t, data, "busy")
250+ for range 10 {
251+ _, err := a.ListRefs(ctx, f.Name)
252+ require.NoError(t, err)
253+ synctest.Sleep(idleTTL / 2)
254+ require.Equal(t, 1, a.OpenRepos(), "used within the TTL")
255+ }
256+ synctest.Sleep(idleTTL)
257+ assert.Equal(t, 0, a.OpenRepos(), "closed once nobody reads it")
258+ })
225259 }
226260
227261 // A Refresh after a write is not a read: a repository pushed to but never read
228262 // still closes after IdleTTL.
229263 func TestRefreshIsNotAUse(t *testing.T) {
230- ctx := context.Background()
231- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond })
232- f := commitOf(t, data, "pushed")
233- _, err := a.ListRefs(ctx, f.Name)
234- require.NoError(t, err)
235- start := time.Now()
236- for time.Since(start) < 5*time.Second && a.OpenRepos() == 1 {
237- a.Refresh(ctx, f.Name)
238- time.Sleep(20 * time.Millisecond)
239- }
240- assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open")
264+ synctest.Test(t, func(t *testing.T) {
265+ ctx := context.Background()
266+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
267+ f := commitOf(t, data, "pushed")
268+ _, err := a.ListRefs(ctx, f.Name)
269+ require.NoError(t, err)
270+ // A push every 30 seconds, until the sweep that follows the TTL.
271+ for elapsed := time.Duration(0); elapsed < idleTTL+idleTTL/4; elapsed += 30 * time.Second {
272+ require.Equal(t, 1, a.OpenRepos(), "open after %v", elapsed)
273+ a.Refresh(ctx, f.Name)
274+ synctest.Sleep(30 * time.Second)
275+ }
276+ assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open")
277+ })
241278 }
242279
243280 // The idle close does not cut a blob stream the reader still holds: the library
244281 // keeps the stream valid after Close.
245282 func TestIdleCloseDoesNotCutAStream(t *testing.T) {
246- ctx := context.Background()
247- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 50 * time.Millisecond })
248- f := readertest.NewFixture(t, data, "stream")
249- content := readertest.RandomBytes(3<<20, 12)
250- f.WriteBytes("big.bin", content)
251- f.Commit("big")
283+ synctest.Test(t, func(t *testing.T) {
284+ ctx := context.Background()
285+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
286+ f := readertest.NewFixture(t, data, "stream")
287+ content := readertest.RandomBytes(3<<20, 12)
288+ f.WriteBytes("big.bin", content)
289+ f.Commit("big")
290+
291+ _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))
292+ require.NoError(t, err)
293+ synctest.Sleep(idleTTL + idleTTL/4)
294+ require.Equal(t, 0, a.OpenRepos())
295+ got, err := io.ReadAll(rc)
296+ require.NoError(t, err)
297+ require.NoError(t, rc.Close())
298+ assert.True(t, bytes.Equal(content, got))
299+ })
300+}
252301
253- _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))
254- require.NoError(t, err)
255- require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond)
256- got, err := io.ReadAll(rc)
257- require.NoError(t, err)
258- require.NoError(t, rc.Close())
259- assert.True(t, bytes.Equal(content, got))
302+// A repository ermite-git could not read is served by git for UnsupportedRetry,
303+// then opened again: a repack, or the removal of a replace ref, can make it
304+// readable. The commit-graph of the history fixture is mapped only while the
305+// library has the repository open.
306+func TestUnsupportedRepositoryIsTriedAgainAfterTheRetryDelay(t *testing.T) {
307+ synctest.Test(t, func(t *testing.T) {
308+ ctx := context.Background()
309+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
310+ h := readertest.NewHistory(t, data)
311+ h.Maintain()
312+ alternates := filepath.Join(h.Bare, "objects", "info", "alternates")
313+ require.NoError(t, os.WriteFile(alternates, []byte(filepath.Join(t.TempDir(), "objects")+"\n"), 0o644))
314+ read := func() {
315+ t.Helper()
316+ // ListRefs: git's answer is a one-shot command, where ListCommits
317+ // would start a long-lived cat-file process, whose pipes keep the
318+ // bubble from ever being idle.
319+ _, err := a.ListRefs(ctx, h.Name)
320+ require.NoError(t, err)
321+ }
322+
323+ read()
324+ require.False(t, mapped(t, h.Bare), "unsupported: served by git")
325+ require.NoError(t, os.Remove(alternates)) // readable now
326+
327+ synctest.Sleep(ermitegit.UnsupportedRetry)
328+ read()
329+ assert.False(t, mapped(t, h.Bare), "still served by git: the delay is not over")
330+ synctest.Sleep(time.Nanosecond)
331+ read()
332+ assert.True(t, mapped(t, h.Bare), "tried again once the delay is over")
333+ })
260334 }
261335
262336 // mapped reports whether the process maps a file under dir (Linux).
@@ -11,6 +11,7 @@ import (
11 "path/filepath"11 "path/filepath"
12 "sync"12 "sync"
13 "testing"13 "testing"
14+ "testing/synctest"
14 "time"15 "time"
15 16
16 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
@@ -207,56 +208,129 @@ func TestEncodedCommitIsServedByGit(t *testing.T) {
207 assert.Equal(t, "café", got.Commits[0].Message)208 assert.Equal(t, "café", got.Commits[0].Message)
208 }209 }
209 210
211+// The idle tests run in a synctest bubble: the clock is fake, so IdleTTL is the
212+// production one and the boundaries are exact. The sweeper ticks every
213+// IdleTTL/4 from the creation of the adapter and closes what has been idle for
214+// more than IdleTTL, so a repository last used at t is closed at the first tick
215+// after t+IdleTTL, which is t+IdleTTL+IdleTTL/4 here (no time passes while the
216+// fixtures are built: the bubble's clock only moves when everything sleeps).
217+const idleTTL = ermitegit.DefaultIdleTTL
218+
210 // A repository that nobody reads for IdleTTL is closed, without a goroutine219 // A repository that nobody reads for IdleTTL is closed, without a goroutine
211 // per repository.220 // per repository.
212 func TestIdleRepositoriesAreClosed(t *testing.T) {221 func TestIdleRepositoriesAreClosed(t *testing.T) {
213- ctx := context.Background()222+ synctest.Test(t, func(t *testing.T) {
214- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond })223+ ctx := context.Background()
215- f := commitOf(t, data, "idle")224+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
216- _, err := a.ListRefs(ctx, f.Name)225+ f := commitOf(t, data, "idle")
217- require.NoError(t, err)226+ _, err := a.ListRefs(ctx, f.Name)
218- require.Equal(t, 1, a.OpenRepos())227+ require.NoError(t, err)
219- require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond)228+ require.Equal(t, 1, a.OpenRepos())
220 229
221- // It opens again on the next read.230+ synctest.Sleep(idleTTL) // idle for exactly the TTL: not more
222- _, err = a.ListRefs(ctx, f.Name)231+ assert.Equal(t, 1, a.OpenRepos(), "not idle for more than the TTL yet")
223- require.NoError(t, err)232+ synctest.Sleep(idleTTL/4 - time.Nanosecond)
224- assert.Equal(t, 1, a.OpenRepos())233+ assert.Equal(t, 1, a.OpenRepos(), "the sweeper has not ticked yet")
234+ synctest.Sleep(time.Nanosecond)
235+ assert.Equal(t, 0, a.OpenRepos(), "closed by the first sweep after the TTL")
236+
237+ // It opens again on the next read.
238+ _, err = a.ListRefs(ctx, f.Name)
239+ require.NoError(t, err)
240+ assert.Equal(t, 1, a.OpenRepos())
241+ })
242+}
243+
244+// A read keeps the repository open: the TTL counts from the last use.
245+func TestReadsKeepARepositoryOpen(t *testing.T) {
246+ synctest.Test(t, func(t *testing.T) {
247+ ctx := context.Background()
248+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
249+ f := commitOf(t, data, "busy")
250+ for range 10 {
251+ _, err := a.ListRefs(ctx, f.Name)
252+ require.NoError(t, err)
253+ synctest.Sleep(idleTTL / 2)
254+ require.Equal(t, 1, a.OpenRepos(), "used within the TTL")
255+ }
256+ synctest.Sleep(idleTTL)
257+ assert.Equal(t, 0, a.OpenRepos(), "closed once nobody reads it")
258+ })
225 }259 }
226 260
227 // A Refresh after a write is not a read: a repository pushed to but never read261 // A Refresh after a write is not a read: a repository pushed to but never read
228 // still closes after IdleTTL.262 // still closes after IdleTTL.
229 func TestRefreshIsNotAUse(t *testing.T) {263 func TestRefreshIsNotAUse(t *testing.T) {
230- ctx := context.Background()264+ synctest.Test(t, func(t *testing.T) {
231- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond })265+ ctx := context.Background()
232- f := commitOf(t, data, "pushed")266+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
233- _, err := a.ListRefs(ctx, f.Name)267+ f := commitOf(t, data, "pushed")
234- require.NoError(t, err)268+ _, err := a.ListRefs(ctx, f.Name)
235- start := time.Now()269+ require.NoError(t, err)
236- for time.Since(start) < 5*time.Second && a.OpenRepos() == 1 {270+ // A push every 30 seconds, until the sweep that follows the TTL.
237- a.Refresh(ctx, f.Name)271+ for elapsed := time.Duration(0); elapsed < idleTTL+idleTTL/4; elapsed += 30 * time.Second {
238- time.Sleep(20 * time.Millisecond)272+ require.Equal(t, 1, a.OpenRepos(), "open after %v", elapsed)
239- }273+ a.Refresh(ctx, f.Name)
240- assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open")274+ synctest.Sleep(30 * time.Second)
275+ }
276+ assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open")
277+ })
241 }278 }
242 279
243 // The idle close does not cut a blob stream the reader still holds: the library280 // The idle close does not cut a blob stream the reader still holds: the library
244 // keeps the stream valid after Close.281 // keeps the stream valid after Close.
245 func TestIdleCloseDoesNotCutAStream(t *testing.T) {282 func TestIdleCloseDoesNotCutAStream(t *testing.T) {
246- ctx := context.Background()283+ synctest.Test(t, func(t *testing.T) {
247- a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 50 * time.Millisecond })284+ ctx := context.Background()
248- f := readertest.NewFixture(t, data, "stream")285+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL })
249- content := readertest.RandomBytes(3<<20, 12)286+ f := readertest.NewFixture(t, data, "stream")
250- f.WriteBytes("big.bin", content)287+ content := readertest.RandomBytes(3<<20, 12)
251- f.Commit("big")288+ f.WriteBytes("big.bin", content)
289+ f.Commit("big")
290+
291+ _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))
292+ require.NoError(t, err)
293+ synctest.Sleep(idleTTL + idleTTL/4)
294+ require.Equal(t, 0, a.OpenRepos())
295+ got, err := io.ReadAll(rc)
296+ require.NoError(t, err)
297+ require.NoError(t, rc.Close())
298+ assert.True(t, bytes.Equal(content, got))
299+ })
300+}
252 301
253- _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin"))302+// A repository ermite-git could not read is served by git for UnsupportedRetry,
254- require.NoError(t, err)303+// then opened again: a repack, or the removal of a replace ref, can make it
255- require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond)304+// readable. The commit-graph of the history fixture is mapped only while the
256- got, err := io.ReadAll(rc)305+// library has the repository open.
257- require.NoError(t, err)306+func TestUnsupportedRepositoryIsTriedAgainAfterTheRetryDelay(t *testing.T) {
258- require.NoError(t, rc.Close())307+ synctest.Test(t, func(t *testing.T) {
259- assert.True(t, bytes.Equal(content, got))308+ ctx := context.Background()
309+ a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil)
310+ h := readertest.NewHistory(t, data)
311+ h.Maintain()
312+ alternates := filepath.Join(h.Bare, "objects", "info", "alternates")
313+ require.NoError(t, os.WriteFile(alternates, []byte(filepath.Join(t.TempDir(), "objects")+"\n"), 0o644))
314+ read := func() {
315+ t.Helper()
316+ // ListRefs: git's answer is a one-shot command, where ListCommits
317+ // would start a long-lived cat-file process, whose pipes keep the
318+ // bubble from ever being idle.
319+ _, err := a.ListRefs(ctx, h.Name)
320+ require.NoError(t, err)
321+ }
322+
323+ read()
324+ require.False(t, mapped(t, h.Bare), "unsupported: served by git")
325+ require.NoError(t, os.Remove(alternates)) // readable now
326+
327+ synctest.Sleep(ermitegit.UnsupportedRetry)
328+ read()
329+ assert.False(t, mapped(t, h.Bare), "still served by git: the delay is not over")
330+ synctest.Sleep(time.Nanosecond)
331+ read()
332+ assert.True(t, mapped(t, h.Bare), "tried again once the delay is over")
333+ })
260 }334 }
261 335
262 // mapped reports whether the process maps a file under dir (Linux).336 // mapped reports whether the process maps a file under dir (Linux).
A internal/adapters/ermitegit/export_test.go +6 -0
@@ -0,0 +1,6 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit
4+
5+// UnsupportedRetry exposes unsupportedRetry to the tests.
6+const UnsupportedRetry = unsupportedRetry
@@ -0,0 +1,6 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit
4+
5+// UnsupportedRetry exposes unsupportedRetry to the tests.
6+const UnsupportedRetry = unsupportedRetry
M internal/adapters/eventbus/bus_test.go +100 -85
@@ -4,9 +4,10 @@ package eventbus_test
44
55 import (
66 "context"
7+ "runtime"
78 "sync"
89 "testing"
9- "time"
10+ "testing/synctest"
1011
1112 "github.com/stretchr/testify/assert"
1213 "github.com/stretchr/testify/require"
@@ -19,121 +20,135 @@ func ev(ref string) domain.Event {
1920 return domain.PushEvent{Updates: []domain.RefUpdate{{Ref: ref}}}
2021 }
2122
23+// recv takes the event a subscriber already has. Publish delivers into the
24+// buffer before it returns, so there is nothing to wait for.
2225 func recv(t *testing.T, ch <-chan domain.Event) domain.Event {
2326 t.Helper()
27+ synctest.Wait()
2428 select {
2529 case e, ok := <-ch:
2630 require.True(t, ok, "channel closed")
2731 return e
28- case <-time.After(2 * time.Second):
32+ default:
2933 t.Fatal("no event")
3034 return nil
3135 }
3236 }
3337
3438 func TestFanOut(t *testing.T) {
35- ctx, cancel := context.WithCancel(context.Background())
36- defer cancel()
37- b := eventbus.New()
38- a, c := b.Subscribe(ctx), b.Subscribe(ctx)
39+ synctest.Test(t, func(t *testing.T) {
40+ ctx, cancel := context.WithCancel(context.Background())
41+ defer cancel()
42+ b := eventbus.New()
43+ a, c := b.Subscribe(ctx), b.Subscribe(ctx)
3944
40- b.Publish(ctx, ev("1"))
41- b.Publish(ctx, domain.RepoCreated{})
42- assert.Equal(t, ev("1"), recv(t, a))
43- assert.Equal(t, ev("1"), recv(t, c))
44- assert.Equal(t, "repo.created", recv(t, a).EventKind())
45- assert.Equal(t, "repo.created", recv(t, c).EventKind())
45+ b.Publish(ctx, ev("1"))
46+ b.Publish(ctx, domain.RepoCreated{})
47+ assert.Equal(t, ev("1"), recv(t, a))
48+ assert.Equal(t, ev("1"), recv(t, c))
49+ assert.Equal(t, "repo.created", recv(t, a).EventKind())
50+ assert.Equal(t, "repo.created", recv(t, c).EventKind())
51+ })
4652 }
4753
4854 func TestOnlyEventsFromSubscription(t *testing.T) {
49- ctx, cancel := context.WithCancel(context.Background())
50- defer cancel()
51- b := eventbus.New()
52- b.Publish(ctx, ev("before"))
53- ch := b.Subscribe(ctx)
54- b.Publish(ctx, ev("after"))
55- assert.Equal(t, ev("after"), recv(t, ch))
55+ synctest.Test(t, func(t *testing.T) {
56+ ctx, cancel := context.WithCancel(context.Background())
57+ defer cancel()
58+ b := eventbus.New()
59+ b.Publish(ctx, ev("before"))
60+ ch := b.Subscribe(ctx)
61+ b.Publish(ctx, ev("after"))
62+ assert.Equal(t, ev("after"), recv(t, ch))
63+ synctest.Wait()
64+ assert.Empty(t, ch, "nothing else is buffered")
65+ })
5666 }
5767
5868 func TestSlowSubscriberGetsGapAndDoesNotBlock(t *testing.T) {
59- ctx, cancel := context.WithCancel(context.Background())
60- defer cancel()
61- b := eventbus.NewWithBuffer(4)
62- slow, fast := b.Subscribe(ctx), b.Subscribe(ctx)
69+ synctest.Test(t, func(t *testing.T) {
70+ ctx, cancel := context.WithCancel(context.Background())
71+ defer cancel()
72+ b := eventbus.NewWithBuffer(4)
73+ slow, fast := b.Subscribe(ctx), b.Subscribe(ctx)
6374
64- done := make(chan struct{})
65- go func() {
66- defer close(done)
75+ // Publish runs in this goroutine: one that blocked on the slow
76+ // subscriber would deadlock the bubble and fail the test.
6777 for i := range 20 {
6878 b.Publish(ctx, ev(string(rune('a'+i))))
6979 // The fast subscriber keeps up.
70- recv(t, fast)
80+ assert.Equal(t, ev(string(rune('a'+i))), recv(t, fast))
7181 }
72- }()
73- select {
74- case <-done:
75- case <-time.After(5 * time.Second):
76- t.Fatal("Publish blocked on a slow subscriber")
77- }
7882
79- // The slow one lost events: it sees a gap marker, then only recent events.
80- var got []domain.Event
81- for len(slow) > 0 {
82- got = append(got, <-slow)
83- }
84- require.NotEmpty(t, got)
85- var gapAt = -1
86- for i, e := range got {
87- if _, ok := e.(domain.EventGap); ok {
88- gapAt = i
83+ // The slow one lost events: it sees a gap marker, then only recent events.
84+ var got []domain.Event
85+ for len(slow) > 0 {
86+ got = append(got, <-slow)
8987 }
90- }
91- assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered")
92- assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered")
93- assert.LessOrEqual(t, len(got), 4)
88+ require.NotEmpty(t, got)
89+ var gapAt = -1
90+ for i, e := range got {
91+ if _, ok := e.(domain.EventGap); ok {
92+ gapAt = i
93+ }
94+ }
95+ assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered")
96+ assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered")
97+ assert.LessOrEqual(t, len(got), 4)
98+ })
9499 }
95100
96101 func TestUnsubscribeOnContext(t *testing.T) {
97- b := eventbus.New()
98- ctx, cancel := context.WithCancel(context.Background())
99- ch := b.Subscribe(ctx)
100- require.Equal(t, 1, b.Subscribers())
101- cancel()
102- select {
103- case _, ok := <-ch:
104- assert.False(t, ok)
105- case <-time.After(2 * time.Second):
106- t.Fatal("channel not closed")
107- }
108- assert.Equal(t, 0, b.Subscribers())
109- b.Publish(context.Background(), ev("x")) // no panic
102+ synctest.Test(t, func(t *testing.T) {
103+ b := eventbus.New()
104+ ctx, cancel := context.WithCancel(context.Background())
105+ ch := b.Subscribe(ctx)
106+ require.Equal(t, 1, b.Subscribers())
107+ synctest.Wait()
108+ require.Equal(t, 1, b.Subscribers(), "still subscribed while ctx lives")
109+ cancel()
110+ synctest.Wait() // the AfterFunc of the context has run
111+ select {
112+ case _, ok := <-ch:
113+ assert.False(t, ok)
114+ default:
115+ t.Fatal("channel not closed")
116+ }
117+ assert.Equal(t, 0, b.Subscribers())
118+ b.Publish(context.Background(), ev("x")) // no panic
119+ })
110120 }
111121
112122 func TestConcurrentPublishSubscribe(t *testing.T) {
113- b := eventbus.NewWithBuffer(8)
114- ctx, cancel := context.WithCancel(context.Background())
115- var wg sync.WaitGroup
116- for range 4 {
117- wg.Add(2)
118- go func() {
119- defer wg.Done()
120- for range 200 {
121- b.Publish(ctx, ev("x"))
122- }
123- }()
124- go func() {
125- defer wg.Done()
126- sctx, scancel := context.WithCancel(ctx)
127- ch := b.Subscribe(sctx)
128- for range 5 {
129- select {
130- case <-ch:
131- case <-time.After(10 * time.Millisecond):
123+ synctest.Test(t, func(t *testing.T) {
124+ b := eventbus.NewWithBuffer(8)
125+ ctx, cancel := context.WithCancel(context.Background())
126+ var wg sync.WaitGroup
127+ for range 4 {
128+ wg.Add(2)
129+ go func() {
130+ defer wg.Done()
131+ for range 200 {
132+ b.Publish(ctx, ev("x"))
132133 }
133- }
134- scancel()
135- }()
136- }
137- wg.Wait()
138- cancel()
134+ }()
135+ go func() {
136+ defer wg.Done()
137+ sctx, scancel := context.WithCancel(ctx)
138+ ch := b.Subscribe(sctx)
139+ for range 5 {
140+ select {
141+ case <-ch:
142+ default: // nothing buffered yet: Publish never blocks, nor does this
143+ }
144+ runtime.Gosched()
145+ }
146+ scancel()
147+ }()
148+ }
149+ wg.Wait()
150+ cancel()
151+ synctest.Wait()
152+ assert.Zero(t, b.Subscribers(), "every subscription ended")
153+ })
139154 }
@@ -4,9 +4,10 @@ package eventbus_test
4 4
5 import (5 import (
6 "context"6 "context"
7+ "runtime"
7 "sync"8 "sync"
8 "testing"9 "testing"
9- "time"10+ "testing/synctest"
10 11
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
@@ -19,121 +20,135 @@ func ev(ref string) domain.Event {
19 return domain.PushEvent{Updates: []domain.RefUpdate{{Ref: ref}}}20 return domain.PushEvent{Updates: []domain.RefUpdate{{Ref: ref}}}
20 }21 }
21 22
23+// recv takes the event a subscriber already has. Publish delivers into the
24+// buffer before it returns, so there is nothing to wait for.
22 func recv(t *testing.T, ch <-chan domain.Event) domain.Event {25 func recv(t *testing.T, ch <-chan domain.Event) domain.Event {
23 t.Helper()26 t.Helper()
27+ synctest.Wait()
24 select {28 select {
25 case e, ok := <-ch:29 case e, ok := <-ch:
26 require.True(t, ok, "channel closed")30 require.True(t, ok, "channel closed")
27 return e31 return e
28- case <-time.After(2 * time.Second):32+ default:
29 t.Fatal("no event")33 t.Fatal("no event")
30 return nil34 return nil
31 }35 }
32 }36 }
33 37
34 func TestFanOut(t *testing.T) {38 func TestFanOut(t *testing.T) {
35- ctx, cancel := context.WithCancel(context.Background())39+ synctest.Test(t, func(t *testing.T) {
36- defer cancel()40+ ctx, cancel := context.WithCancel(context.Background())
37- b := eventbus.New()41+ defer cancel()
38- a, c := b.Subscribe(ctx), b.Subscribe(ctx)42+ b := eventbus.New()
43+ a, c := b.Subscribe(ctx), b.Subscribe(ctx)
39 44
40- b.Publish(ctx, ev("1"))45+ b.Publish(ctx, ev("1"))
41- b.Publish(ctx, domain.RepoCreated{})46+ b.Publish(ctx, domain.RepoCreated{})
42- assert.Equal(t, ev("1"), recv(t, a))47+ assert.Equal(t, ev("1"), recv(t, a))
43- assert.Equal(t, ev("1"), recv(t, c))48+ assert.Equal(t, ev("1"), recv(t, c))
44- assert.Equal(t, "repo.created", recv(t, a).EventKind())49+ assert.Equal(t, "repo.created", recv(t, a).EventKind())
45- assert.Equal(t, "repo.created", recv(t, c).EventKind())50+ assert.Equal(t, "repo.created", recv(t, c).EventKind())
51+ })
46 }52 }
47 53
48 func TestOnlyEventsFromSubscription(t *testing.T) {54 func TestOnlyEventsFromSubscription(t *testing.T) {
49- ctx, cancel := context.WithCancel(context.Background())55+ synctest.Test(t, func(t *testing.T) {
50- defer cancel()56+ ctx, cancel := context.WithCancel(context.Background())
51- b := eventbus.New()57+ defer cancel()
52- b.Publish(ctx, ev("before"))58+ b := eventbus.New()
53- ch := b.Subscribe(ctx)59+ b.Publish(ctx, ev("before"))
54- b.Publish(ctx, ev("after"))60+ ch := b.Subscribe(ctx)
55- assert.Equal(t, ev("after"), recv(t, ch))61+ b.Publish(ctx, ev("after"))
62+ assert.Equal(t, ev("after"), recv(t, ch))
63+ synctest.Wait()
64+ assert.Empty(t, ch, "nothing else is buffered")
65+ })
56 }66 }
57 67
58 func TestSlowSubscriberGetsGapAndDoesNotBlock(t *testing.T) {68 func TestSlowSubscriberGetsGapAndDoesNotBlock(t *testing.T) {
59- ctx, cancel := context.WithCancel(context.Background())69+ synctest.Test(t, func(t *testing.T) {
60- defer cancel()70+ ctx, cancel := context.WithCancel(context.Background())
61- b := eventbus.NewWithBuffer(4)71+ defer cancel()
62- slow, fast := b.Subscribe(ctx), b.Subscribe(ctx)72+ b := eventbus.NewWithBuffer(4)
73+ slow, fast := b.Subscribe(ctx), b.Subscribe(ctx)
63 74
64- done := make(chan struct{})75+ // Publish runs in this goroutine: one that blocked on the slow
65- go func() {76+ // subscriber would deadlock the bubble and fail the test.
66- defer close(done)
67 for i := range 20 {77 for i := range 20 {
68 b.Publish(ctx, ev(string(rune('a'+i))))78 b.Publish(ctx, ev(string(rune('a'+i))))
69 // The fast subscriber keeps up.79 // The fast subscriber keeps up.
70- recv(t, fast)80+ assert.Equal(t, ev(string(rune('a'+i))), recv(t, fast))
71 }81 }
72- }()
73- select {
74- case <-done:
75- case <-time.After(5 * time.Second):
76- t.Fatal("Publish blocked on a slow subscriber")
77- }
78 82
79- // The slow one lost events: it sees a gap marker, then only recent events.83+ // The slow one lost events: it sees a gap marker, then only recent events.
80- var got []domain.Event84+ var got []domain.Event
81- for len(slow) > 0 {85+ for len(slow) > 0 {
82- got = append(got, <-slow)86+ got = append(got, <-slow)
83- }
84- require.NotEmpty(t, got)
85- var gapAt = -1
86- for i, e := range got {
87- if _, ok := e.(domain.EventGap); ok {
88- gapAt = i
89 }87 }
90- }88+ require.NotEmpty(t, got)
91- assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered")89+ var gapAt = -1
92- assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered")90+ for i, e := range got {
93- assert.LessOrEqual(t, len(got), 4)91+ if _, ok := e.(domain.EventGap); ok {
92+ gapAt = i
93+ }
94+ }
95+ assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered")
96+ assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered")
97+ assert.LessOrEqual(t, len(got), 4)
98+ })
94 }99 }
95 100
96 func TestUnsubscribeOnContext(t *testing.T) {101 func TestUnsubscribeOnContext(t *testing.T) {
97- b := eventbus.New()102+ synctest.Test(t, func(t *testing.T) {
98- ctx, cancel := context.WithCancel(context.Background())103+ b := eventbus.New()
99- ch := b.Subscribe(ctx)104+ ctx, cancel := context.WithCancel(context.Background())
100- require.Equal(t, 1, b.Subscribers())105+ ch := b.Subscribe(ctx)
101- cancel()106+ require.Equal(t, 1, b.Subscribers())
102- select {107+ synctest.Wait()
103- case _, ok := <-ch:108+ require.Equal(t, 1, b.Subscribers(), "still subscribed while ctx lives")
104- assert.False(t, ok)109+ cancel()
105- case <-time.After(2 * time.Second):110+ synctest.Wait() // the AfterFunc of the context has run
106- t.Fatal("channel not closed")111+ select {
107- }112+ case _, ok := <-ch:
108- assert.Equal(t, 0, b.Subscribers())113+ assert.False(t, ok)
109- b.Publish(context.Background(), ev("x")) // no panic114+ default:
115+ t.Fatal("channel not closed")
116+ }
117+ assert.Equal(t, 0, b.Subscribers())
118+ b.Publish(context.Background(), ev("x")) // no panic
119+ })
110 }120 }
111 121
112 func TestConcurrentPublishSubscribe(t *testing.T) {122 func TestConcurrentPublishSubscribe(t *testing.T) {
113- b := eventbus.NewWithBuffer(8)123+ synctest.Test(t, func(t *testing.T) {
114- ctx, cancel := context.WithCancel(context.Background())124+ b := eventbus.NewWithBuffer(8)
115- var wg sync.WaitGroup125+ ctx, cancel := context.WithCancel(context.Background())
116- for range 4 {126+ var wg sync.WaitGroup
117- wg.Add(2)127+ for range 4 {
118- go func() {128+ wg.Add(2)
119- defer wg.Done()129+ go func() {
120- for range 200 {130+ defer wg.Done()
121- b.Publish(ctx, ev("x"))131+ for range 200 {
122- }132+ b.Publish(ctx, ev("x"))
123- }()
124- go func() {
125- defer wg.Done()
126- sctx, scancel := context.WithCancel(ctx)
127- ch := b.Subscribe(sctx)
128- for range 5 {
129- select {
130- case <-ch:
131- case <-time.After(10 * time.Millisecond):
132 }133 }
133- }134+ }()
134- scancel()135+ go func() {
135- }()136+ defer wg.Done()
136- }137+ sctx, scancel := context.WithCancel(ctx)
137- wg.Wait()138+ ch := b.Subscribe(sctx)
138- cancel()139+ for range 5 {
140+ select {
141+ case <-ch:
142+ default: // nothing buffered yet: Publish never blocks, nor does this
143+ }
144+ runtime.Gosched()
145+ }
146+ scancel()
147+ }()
148+ }
149+ wg.Wait()
150+ cancel()
151+ synctest.Wait()
152+ assert.Zero(t, b.Subscribers(), "every subscription ended")
153+ })
139 }154 }
M internal/app/events_test.go +543 -437
@@ -7,7 +7,9 @@ import (
77 "errors"
88 "fmt"
99 "sync"
10+ "sync/atomic"
1011 "testing"
12+ "testing/synctest"
1113 "time"
1214
1315 "github.com/stretchr/testify/assert"
@@ -22,13 +24,14 @@ import (
2224
2325 // eventRig is an Events use case fed by a channel the test writes to.
2426 type eventRig struct {
25- t *testing.T
26- meta *mocks.RepoMetaStore
27- git *mocks.GitRepos
28- feed chan domain.Event
29- auth *appmocks.Revalidator
30- revoked sync.Map // token IDs revoked by the test
31- events *app.Events
27+ t *testing.T
28+ meta *mocks.RepoMetaStore
29+ git *mocks.GitRepos
30+ feed chan domain.Event
31+ auth *appmocks.Revalidator
32+ revoked sync.Map // token IDs revoked by the test
33+ revalidations atomic.Int32 // calls to auth.Revalidate
34+ events *app.Events
3235 }
3336
3437 func newEventRig(t *testing.T, size int) *eventRig {
@@ -47,6 +50,7 @@ func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {
4750 }
4851 // Tokens are valid until the test revokes them.
4952 r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error {
53+ r.revalidations.Add(1)
5054 if _, gone := r.revoked.Load(p.TokenID()); gone {
5155 return domain.ErrUnauthenticated
5256 }
@@ -108,12 +112,13 @@ func (r *eventRig) watch(ctx context.Context, p domain.Principal, params app.Wat
108112
109113 func (w *watcher) next(t *testing.T) app.WatchEvent {
110114 t.Helper()
115+ synctest.Wait() // everything the event can reach has run
111116 select {
112117 case e := <-w.got:
113118 return e
114119 case err := <-w.done:
115120 t.Fatalf("watch ended: %v", err)
116- case <-time.After(5 * time.Second):
121+ default:
117122 t.Fatal("no event")
118123 }
119124 return app.WatchEvent{}
@@ -137,127 +142,149 @@ func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) {
137142 }
138143
139144 func TestWatchLiveAndFiltered(t *testing.T) {
140- r := newEventRig(t, 0)
141- pub := r.repo("pub", domain.VisibilityPublic)
142- priv := r.repo("priv", domain.VisibilityPrivate)
143- ctx, cancel := context.WithCancel(context.Background())
144- defer cancel()
145-
146- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
147- root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}})
148- scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{})
149- onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}})
150- require.Eventually(t, func() bool { return r.events.Watchers() == 4 }, 5*time.Second, time.Millisecond)
151-
152- r.push(priv, 1)
153- r.push(pub, 2)
154-
155- // Anonymous and a token limited to another repository never see priv.
156- for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
157- e := w.next(t)
158- assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
159- w.quiet(t, r, pub)
160- }
161- first, second := root.next(t), root.next(t)
162- assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
163- assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)
164- assert.NotEmpty(t, first.ID)
165- assert.NotEqual(t, first.ID, second.ID)
166-
167- assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
168- onlyPriv.quiet(t, r, priv)
145+ synctest.Test(t, func(t *testing.T) {
146+ r := newEventRig(t, 0)
147+ pub := r.repo("pub", domain.VisibilityPublic)
148+ priv := r.repo("priv", domain.VisibilityPrivate)
149+ ctx, cancel := context.WithCancel(context.Background())
150+ defer cancel()
151+
152+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
153+ root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}})
154+ scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{})
155+ onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}})
156+ synctest.Wait()
157+ require.Equal(t, 4, r.events.Watchers())
158+
159+ r.push(priv, 1)
160+ r.push(pub, 2)
161+
162+ // Anonymous and a token limited to another repository never see priv.
163+ for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
164+ e := w.next(t)
165+ assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
166+ w.quiet(t, r, pub)
167+ }
168+ first, second := root.next(t), root.next(t)
169+ assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
170+ assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)
171+ assert.NotEmpty(t, first.ID)
172+ assert.NotEqual(t, first.ID, second.ID)
173+
174+ assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
175+ onlyPriv.quiet(t, r, priv)
176+ })
169177 }
170178
171179 func TestWatchRechecksAccessPerEvent(t *testing.T) {
172- r := newEventRig(t, 0)
173- n := name(t, "flip")
174- vis := domain.VisibilityPublic
175- var mu sync.Mutex
176- r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
180+ synctest.Test(t, func(t *testing.T) {
181+ r := newEventRig(t, 0)
182+ n := name(t, "flip")
183+ vis := domain.VisibilityPublic
184+ var mu sync.Mutex
185+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
186+ mu.Lock()
187+ defer mu.Unlock()
188+ return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil
189+ })
190+ ctx, cancel := context.WithCancel(context.Background())
191+ defer cancel()
192+ w := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
193+ synctest.Wait()
194+ require.Equal(t, 1, r.events.Watchers())
195+
196+ r.push(n, 1)
197+ w.next(t)
198+
177199 mu.Lock()
178- defer mu.Unlock()
179- return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil
200+ vis = domain.VisibilityPrivate
201+ mu.Unlock()
202+ r.push(n, 2)
203+ w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
180204 })
181- ctx, cancel := context.WithCancel(context.Background())
182- defer cancel()
183- w := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
184- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
185-
186- r.push(n, 1)
187- w.next(t)
188-
189- mu.Lock()
190- vis = domain.VisibilityPrivate
191- mu.Unlock()
192- r.push(n, 2)
193- w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
194205 }
195206
196207 func TestWatchResumeAndGap(t *testing.T) {
197208 const size = 8
198- r := newEventRig(t, size)
199- n := r.repo("pub", domain.VisibilityPublic)
200- ctx, cancel := context.WithCancel(context.Background())
201- defer cancel()
202-
203- // Record the ids of 20 events through a first watcher.
204- first := r.watch(ctx, domain.Root(), app.WatchParams{})
205- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
206- // One at a time: this watcher must not fall behind the small ring.
207- var ids []string
208- for i := range 20 {
209- r.push(n, i)
210- ids = append(ids, first.next(t).ID)
209+ // Each subtest gets its own bubble and a fresh run of 20 events recorded
210+ // through a first watcher; the ring then holds the last 8.
211+ setup := func(t *testing.T) (r *eventRig, n domain.RepoName, ctx context.Context, ids []string) {
212+ r = newEventRig(t, size)
213+ n = r.repo("pub", domain.VisibilityPublic)
214+ ctx, cancel := context.WithCancel(context.Background())
215+ t.Cleanup(cancel)
216+
217+ first := r.watch(ctx, domain.Root(), app.WatchParams{})
218+ synctest.Wait()
219+ require.Equal(t, 1, r.events.Watchers())
220+ // One at a time: this watcher must not fall behind the small ring.
221+ for i := range 20 {
222+ r.push(n, i)
223+ ids = append(ids, first.next(t).ID)
224+ }
225+ require.Len(t, ids, 20)
226+ return r, n, ctx, ids
211227 }
212- require.Len(t, ids, 20)
213228
214- // The subtests that read the state of the ring come first: the ones that
215- // need a sentinel add events to it.
216229 t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
217- // 20 events were taken, the buffer holds the last 8: ids[12] is the
218- // oldest still present; ids[0] is long gone.
219- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]})
220- gap := w.next(t)
221- assert.True(t, gap.Gap)
222- assert.Empty(t, gap.ID)
223- oldest := w.next(t)
224- assert.False(t, oldest.Gap)
225- assert.Equal(t, ids[12], oldest.ID)
230+ synctest.Test(t, func(t *testing.T) {
231+ r, _, ctx, ids := setup(t)
232+ // 20 events were taken, the buffer holds the last 8: ids[12] is the
233+ // oldest still present; ids[0] is long gone.
234+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]})
235+ gap := w.next(t)
236+ assert.True(t, gap.Gap)
237+ assert.Empty(t, gap.ID)
238+ oldest := w.next(t)
239+ assert.False(t, oldest.Gap)
240+ assert.Equal(t, ids[12], oldest.ID)
241+ })
226242 })
227243
228244 t.Run("an unknown id gives a gap", func(t *testing.T) {
229- for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {
230- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id})
231- assert.True(t, w.next(t).Gap, id)
232- }
245+ synctest.Test(t, func(t *testing.T) {
246+ r, _, ctx, _ := setup(t)
247+ for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {
248+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id})
249+ assert.True(t, w.next(t).Gap, id)
250+ }
251+ })
233252 })
234253
235254 t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) {
236- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"})
237- assert.True(t, w.next(t).Gap)
255+ synctest.Test(t, func(t *testing.T) {
256+ r, _, ctx, _ := setup(t)
257+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"})
258+ assert.True(t, w.next(t).Gap)
259+ })
238260 })
239261
240262 t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
241- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
242- for _, want := range ids[17:] {
243- e := w.next(t)
244- assert.False(t, e.Gap)
245- assert.Equal(t, want, e.ID)
246- }
247- // Nothing else was replayed: the next event is the one pushed now.
248- r.push(n, 98)
249- live := w.next(t)
250- assert.False(t, live.Gap)
251- assert.NotContains(t, ids, live.ID)
252- ids = append(ids, live.ID)
263+ synctest.Test(t, func(t *testing.T) {
264+ r, n, ctx, ids := setup(t)
265+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
266+ for _, want := range ids[17:] {
267+ e := w.next(t)
268+ assert.False(t, e.Gap)
269+ assert.Equal(t, want, e.ID)
270+ }
271+ // Nothing else was replayed: the next event is the one pushed now.
272+ r.push(n, 98)
273+ live := w.next(t)
274+ assert.False(t, live.Gap)
275+ assert.NotContains(t, ids, live.ID)
276+ })
253277 })
254278
255279 t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) {
256- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]})
257- r.push(n, 99)
258- e := w.next(t)
259- assert.False(t, e.Gap)
260- assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")
280+ synctest.Test(t, func(t *testing.T) {
281+ r, n, ctx, ids := setup(t)
282+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]})
283+ r.push(n, 99)
284+ e := w.next(t)
285+ assert.False(t, e.Gap)
286+ assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")
287+ })
261288 })
262289 }
263290
@@ -284,133 +311,147 @@ func TestWatchTopicErrors(t *testing.T) {
284311
285312 func TestWatchEndsCleanly(t *testing.T) {
286313 t.Run("on context cancellation", func(t *testing.T) {
287- r := newEventRig(t, 0)
288- ctx, cancel := context.WithCancel(context.Background())
289- w := r.watch(ctx, domain.Root(), app.WatchParams{})
290- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
291- cancel()
292- select {
293- case err := <-w.done:
294- assert.NoError(t, err)
295- case <-time.After(5 * time.Second):
296- t.Fatal("watch did not end")
297- }
298- assert.Zero(t, r.events.Watchers())
314+ synctest.Test(t, func(t *testing.T) {
315+ r := newEventRig(t, 0)
316+ ctx, cancel := context.WithCancel(context.Background())
317+ w := r.watch(ctx, domain.Root(), app.WatchParams{})
318+ synctest.Wait()
319+ require.Equal(t, 1, r.events.Watchers())
320+ cancel()
321+ synctest.Wait()
322+ select {
323+ case err := <-w.done:
324+ assert.NoError(t, err)
325+ default:
326+ t.Fatal("watch did not end")
327+ }
328+ assert.Zero(t, r.events.Watchers())
329+ })
299330 })
300331
301332 t.Run("on Close", func(t *testing.T) {
302- r := newEventRig(t, 0)
303- w := r.watch(context.Background(), domain.Root(), app.WatchParams{})
304- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
305- r.events.Close(context.Background())
306- r.events.Close(context.Background()) // twice is fine
307- select {
308- case err := <-w.done:
309- assert.NoError(t, err)
310- case <-time.After(5 * time.Second):
311- t.Fatal("watch did not end")
312- }
333+ synctest.Test(t, func(t *testing.T) {
334+ r := newEventRig(t, 0)
335+ w := r.watch(context.Background(), domain.Root(), app.WatchParams{})
336+ synctest.Wait()
337+ require.Equal(t, 1, r.events.Watchers())
338+ r.events.Close(context.Background())
339+ r.events.Close(context.Background()) // twice is fine
340+ synctest.Wait()
341+ select {
342+ case err := <-w.done:
343+ assert.NoError(t, err)
344+ default:
345+ t.Fatal("watch did not end")
346+ }
347+ })
313348 })
314349
315350 t.Run("a failing send ends the watch with its error", func(t *testing.T) {
316- r := newEventRig(t, 0)
317- n := r.repo("pub", domain.VisibilityPublic)
318- boom := errors.New("client went away")
319- done := make(chan error, 1)
320- go func() {
321- done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom })
322- }()
323- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
324- r.push(n, 1)
325- assert.ErrorIs(t, <-done, boom)
351+ synctest.Test(t, func(t *testing.T) {
352+ r := newEventRig(t, 0)
353+ n := r.repo("pub", domain.VisibilityPublic)
354+ boom := errors.New("client went away")
355+ done := make(chan error, 1)
356+ go func() {
357+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom })
358+ }()
359+ synctest.Wait()
360+ require.Equal(t, 1, r.events.Watchers())
361+ r.push(n, 1)
362+ assert.ErrorIs(t, <-done, boom)
363+ })
326364 })
327365 }
328366
329367 func TestWatchSlowWatcherGetsAGap(t *testing.T) {
330- const size = 4
331- r := newEventRig(t, size)
332- n := r.repo("pub", domain.VisibilityPublic)
333-
334- release := make(chan struct{})
335- var mu sync.Mutex
336- var got []app.WatchEvent
337- done := make(chan error, 1)
338- blocked := make(chan struct{})
339- first := true
340- go func() {
341- done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error {
342- if first { // block on the first event while the ring turns over
343- first = false
344- close(blocked)
345- <-release
368+ synctest.Test(t, func(t *testing.T) {
369+ const size = 4
370+ r := newEventRig(t, size)
371+ n := r.repo("pub", domain.VisibilityPublic)
372+
373+ release := make(chan struct{})
374+ var mu sync.Mutex
375+ var got []app.WatchEvent
376+ done := make(chan error, 1)
377+ blocked := make(chan struct{})
378+ first := true
379+ go func() {
380+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error {
381+ if first { // block on the first event while the ring turns over
382+ first = false
383+ close(blocked)
384+ <-release
385+ }
386+ mu.Lock()
387+ got = append(got, e)
388+ mu.Unlock()
389+ return nil
390+ })
391+ }()
392+ // A second, fast watcher tells when the use case has taken every event:
393+ // it receives them in order.
394+ fast := r.watch(context.Background(), domain.Root(), app.WatchParams{})
395+ synctest.Wait()
396+ require.Equal(t, 2, r.events.Watchers())
397+ r.push(n, 0)
398+ <-blocked
399+ for i := 1; i < 30; i++ {
400+ r.push(n, i)
401+ }
402+ // The ring holds four events: the fast watcher may get a gap, but it ends
403+ // on the last push, which proves the use case took all of them.
404+ last := fmt.Sprintf("%040x", 29+1)
405+ for {
406+ e := fast.next(t)
407+ if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
408+ break
346409 }
347- mu.Lock()
348- got = append(got, e)
349- mu.Unlock()
350- return nil
351- })
352- }()
353- // A second, fast watcher tells when the use case has taken every event:
354- // it receives them in order.
355- fast := r.watch(context.Background(), domain.Root(), app.WatchParams{})
356- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
357- r.push(n, 0)
358- <-blocked
359- for i := 1; i < 30; i++ {
360- r.push(n, i)
361- }
362- // The ring holds four events: the fast watcher may get a gap, but it ends
363- // on the last push, which proves the use case took all of them.
364- last := fmt.Sprintf("%040x", 29+1)
365- for {
366- e := fast.next(t)
367- if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
368- break
369410 }
370- }
371- close(release)
411+ close(release)
372412
373- require.Eventually(t, func() bool {
413+ synctest.Wait()
374414 mu.Lock()
375415 defer mu.Unlock()
376- return len(got) >= 2
377- }, 5*time.Second, time.Millisecond)
378- mu.Lock()
379- defer mu.Unlock()
380- hasGap := false
381- for _, e := range got[1:] {
382- hasGap = hasGap || e.Gap
383- }
384- assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
416+ require.GreaterOrEqual(t, len(got), 2)
417+ hasGap := false
418+ for _, e := range got[1:] {
419+ hasGap = hasGap || e.Gap
420+ }
421+ assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
422+ })
385423 }
386424
387425 func TestEventsRepoPayloads(t *testing.T) {
388- r := newEventRig(t, 0)
389- pub := r.repo("pub", domain.VisibilityPublic)
390- priv := r.repo("priv", domain.VisibilityPrivate)
391- ctx, cancel := context.WithCancel(context.Background())
392- defer cancel()
393- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
394- root := r.watch(ctx, domain.Root(), app.WatchParams{})
395- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
396-
397- r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
398- r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now}
399- // A deleted repository cannot be read any more: its visibility decides.
400- r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
401- r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
402-
403- for _, w := range []*watcher{anon, root} {
404- created := w.next(t)
405- require.NotNil(t, created.Repo)
406- assert.Equal(t, "main", created.Repo.DefaultBranch)
407- assert.Equal(t, pub, created.Repo.Name)
408- require.NotNil(t, w.next(t).Repo)
409- }
410- assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
411- assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
412- assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
413- anon.quiet(t, r, pub)
426+ synctest.Test(t, func(t *testing.T) {
427+ r := newEventRig(t, 0)
428+ pub := r.repo("pub", domain.VisibilityPublic)
429+ priv := r.repo("priv", domain.VisibilityPrivate)
430+ ctx, cancel := context.WithCancel(context.Background())
431+ defer cancel()
432+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
433+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
434+ synctest.Wait()
435+ require.Equal(t, 2, r.events.Watchers())
436+
437+ r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
438+ r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now}
439+ // A deleted repository cannot be read any more: its visibility decides.
440+ r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
441+ r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
442+
443+ for _, w := range []*watcher{anon, root} {
444+ created := w.next(t)
445+ require.NotNil(t, created.Repo)
446+ assert.Equal(t, "main", created.Repo.DefaultBranch)
447+ assert.Equal(t, pub, created.Repo.Name)
448+ require.NotNil(t, w.next(t).Repo)
449+ }
450+ assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
451+ assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
452+ assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
453+ anon.quiet(t, r, pub)
454+ })
414455 }
415456
416457 // tokenWith builds the principal of a stored token with an id and an expiry.
@@ -427,88 +468,118 @@ func tokenWith(id string, expires time.Time, repos ...string) domain.Principal {
427468 }
428469
429470 func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
430- // Asking at every turn keeps the test independent of the clock.
431- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
432- pub := r.repo("pub", domain.VisibilityPublic)
433- priv := r.repo("priv", domain.VisibilityPrivate)
434- p := tokenWith("tok", time.Time{})
435- ctx, cancel := context.WithCancel(context.Background())
436- defer cancel()
437-
438- w := r.watch(ctx, p, app.WatchParams{})
439- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
440- r.push(pub, 1)
441- w.next(t)
442-
443- r.revoked.Store("tok", true)
444- r.push(priv, 2) // would be delivered to a token that may read priv
445- select {
446- case err := <-w.done:
447- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
448- case <-time.After(5 * time.Second):
449- t.Fatal("the watch of a revoked token did not end")
450- }
451- select {
452- case e := <-w.got:
453- t.Fatalf("an event was sent after the revocation: %+v", e)
454- default:
455- }
456-}
471+ synctest.Test(t, func(t *testing.T) {
472+ // Asking at every turn keeps the test independent of the clock.
473+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
474+ pub := r.repo("pub", domain.VisibilityPublic)
475+ priv := r.repo("priv", domain.VisibilityPrivate)
476+ p := tokenWith("tok", time.Time{})
477+ ctx, cancel := context.WithCancel(context.Background())
478+ defer cancel()
457479
458-func TestWatchEndsWhenTheTokenExpires(t *testing.T) {
459- r := newEventRig(t, 0)
460- pub := r.repo("pub", domain.VisibilityPublic)
461- // The rig's clock stands at now: this token expired at now.
462- p := tokenWith("tok", now)
463- w := r.watch(context.Background(), p, app.WatchParams{})
464- r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated
465- select {
466- case err := <-w.done:
467- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
468- case <-time.After(5 * time.Second):
469- t.Fatal("the watch of an expired token did not end")
470- }
471- assert.Empty(t, w.got)
472-}
480+ w := r.watch(ctx, p, app.WatchParams{})
481+ synctest.Wait()
482+ require.Equal(t, 1, r.events.Watchers())
483+ r.push(pub, 1)
484+ w.next(t)
473485
474-func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
475- t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
476- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})
477- w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
478- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
479486 r.revoked.Store("tok", true)
487+ r.push(priv, 2) // would be delivered to a token that may read priv
488+ synctest.Wait()
480489 select {
481490 case err := <-w.done:
482491 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
483- case <-time.After(5 * time.Second):
484- t.Fatal("an idle watch of a revoked token did not end")
492+ default:
493+ t.Fatal("the watch of a revoked token did not end")
494+ }
495+ select {
496+ case e := <-w.got:
497+ t.Fatalf("an event was sent after the revocation: %+v", e)
498+ default:
485499 }
486500 })
501+}
487502
488- t.Run("the answer is kept between checks", func(t *testing.T) {
489- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})
503+func TestWatchEndsWhenTheTokenExpires(t *testing.T) {
504+ synctest.Test(t, func(t *testing.T) {
505+ r := newEventRig(t, 0)
490506 pub := r.repo("pub", domain.VisibilityPublic)
491- w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
492- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
493- for i := range 20 {
494- r.push(pub, i)
495- }
496- for range 20 {
497- w.next(t)
507+ // The rig's clock stands at now: this token expired at now.
508+ p := tokenWith("tok", now)
509+ w := r.watch(context.Background(), p, app.WatchParams{})
510+ r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated
511+ synctest.Wait()
512+ select {
513+ case err := <-w.done:
514+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
515+ default:
516+ t.Fatal("the watch of an expired token did not end")
498517 }
499- r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) // no store lookup within the interval
518+ assert.Empty(t, w.got)
519+ })
520+}
521+
522+func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
523+ t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
524+ synctest.Test(t, func(t *testing.T) {
525+ r := newEventRigConfig(t, app.EventsConfig{}) // the production default interval
526+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
527+ synctest.Wait()
528+ require.Equal(t, 1, r.events.Watchers())
529+ r.revoked.Store("tok", true)
530+ synctest.Sleep(app.DefaultRevalidateEvery - time.Nanosecond)
531+ select {
532+ case err := <-w.done:
533+ t.Fatalf("the watch ended before the interval: %v", err)
534+ default:
535+ }
536+ assert.Zero(t, r.revalidations.Load(), "no store lookup before the interval")
537+ synctest.Sleep(time.Nanosecond)
538+ select {
539+ case err := <-w.done:
540+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
541+ default:
542+ t.Fatal("an idle watch of a revoked token did not end at the interval")
543+ }
544+ assert.Equal(t, int32(1), r.revalidations.Load())
545+ })
546+ })
547+
548+ t.Run("the answer is kept between checks", func(t *testing.T) {
549+ synctest.Test(t, func(t *testing.T) {
550+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})
551+ pub := r.repo("pub", domain.VisibilityPublic)
552+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
553+ synctest.Wait()
554+ require.Equal(t, 1, r.events.Watchers())
555+ for i := range 20 {
556+ r.push(pub, i)
557+ }
558+ for range 20 {
559+ w.next(t)
560+ }
561+ // Idle, the watcher wakes up every few seconds but keeps its answer
562+ // until the interval is over.
563+ synctest.Sleep(time.Hour - time.Nanosecond)
564+ assert.Zero(t, r.revalidations.Load(), "no store lookup within the interval")
565+ synctest.Sleep(time.Nanosecond)
566+ assert.Equal(t, int32(1), r.revalidations.Load(), "one lookup when the interval is over")
567+ })
500568 })
501569
502570 t.Run("root and anonymous never ask", func(t *testing.T) {
503- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
504- pub := r.repo("pub", domain.VisibilityPublic)
505- a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{})
506- b := r.watch(context.Background(), domain.Root(), app.WatchParams{})
507- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
508- r.push(pub, 1)
509- a.next(t)
510- b.next(t)
511- r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)
571+ synctest.Test(t, func(t *testing.T) {
572+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
573+ pub := r.repo("pub", domain.VisibilityPublic)
574+ a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{})
575+ b := r.watch(context.Background(), domain.Root(), app.WatchParams{})
576+ synctest.Wait()
577+ require.Equal(t, 2, r.events.Watchers())
578+ r.push(pub, 1)
579+ a.next(t)
580+ b.next(t)
581+ r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)
582+ })
512583 })
513584 }
514585
@@ -516,60 +587,71 @@ func TestWatchLimits(t *testing.T) {
516587 noop := func(app.WatchEvent) error { return nil }
517588
518589 t.Run("too many topics", func(t *testing.T) {
519- r := newEventRig(t, 0)
520- r.repo("pub", domain.VisibilityPublic)
521- ok := make([]string, app.MaxWatchTopics)
522- for i := range ok {
523- ok[i] = "home"
524- }
525- ctx, cancel := context.WithCancel(context.Background())
526- cancel() // returns at once once the topics are accepted
527- assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop))
528- err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop)
529- assert.ErrorIs(t, err, domain.ErrInvalid)
590+ synctest.Test(t, func(t *testing.T) {
591+ r := newEventRig(t, 0)
592+ r.repo("pub", domain.VisibilityPublic)
593+ ok := make([]string, app.MaxWatchTopics)
594+ for i := range ok {
595+ ok[i] = "home"
596+ }
597+ ctx, cancel := context.WithCancel(context.Background())
598+ cancel() // returns at once once the topics are accepted
599+ assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop))
600+ err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop)
601+ assert.ErrorIs(t, err, domain.ErrInvalid)
602+ })
530603 })
531604
532605 t.Run("total watchers", func(t *testing.T) {
533- r := newEventRig(t, 0)
534- ctx, cancel := context.WithCancel(context.Background())
535- defer cancel()
536- for range app.MaxWatchers {
537- r.watch(ctx, domain.Root(), app.WatchParams{})
538- }
539- require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchers }, 5*time.Second, time.Millisecond)
540- err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop)
541- assert.ErrorIs(t, err, domain.ErrResourceExhausted)
542- assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")
543-
544- cancel() // all end; a new one is accepted again
545- require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)
546- again, stop := context.WithCancel(context.Background())
547- stop()
548- assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop))
606+ synctest.Test(t, func(t *testing.T) {
607+ r := newEventRig(t, 0)
608+ ctx, cancel := context.WithCancel(context.Background())
609+ defer cancel()
610+ for range app.MaxWatchers {
611+ r.watch(ctx, domain.Root(), app.WatchParams{})
612+ }
613+ synctest.Wait()
614+ require.Equal(t, app.MaxWatchers, r.events.Watchers())
615+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop)
616+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
617+ assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")
618+
619+ cancel() // all end; a new one is accepted again
620+ synctest.Wait()
621+ require.Equal(t, 0, r.events.Watchers())
622+ again, stop := context.WithCancel(context.Background())
623+ stop()
624+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop))
625+ })
549626 })
550627
551628 t.Run("watchers per client", func(t *testing.T) {
552- r := newEventRig(t, 0)
553- ctx, cancel := context.WithCancel(context.Background())
554- defer cancel()
555- for range app.MaxWatchersPerClient {
556- r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"})
557- }
558- require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)
559-
560- err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop)
561- assert.ErrorIs(t, err, domain.ErrResourceExhausted)
629+ synctest.Test(t, func(t *testing.T) {
630+ r := newEventRig(t, 0)
631+ ctx, cancel := context.WithCancel(context.Background())
632+ defer cancel()
633+ for range app.MaxWatchersPerClient {
634+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"})
635+ }
636+ synctest.Wait()
637+ require.Equal(t, app.MaxWatchersPerClient, r.events.Watchers())
562638
563- // Another client, and callers in process, are not affected.
564- r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"})
565- r.watch(ctx, domain.Root(), app.WatchParams{})
566- require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient+2 }, 5*time.Second, time.Millisecond)
639+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop)
640+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
567641
568- cancel()
569- require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)
570- again, stop := context.WithCancel(context.Background())
571- stop()
572- assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")
642+ // Another client, and callers in process, are not affected.
643+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"})
644+ r.watch(ctx, domain.Root(), app.WatchParams{})
645+ synctest.Wait()
646+ require.Equal(t, app.MaxWatchersPerClient+2, r.events.Watchers())
647+
648+ cancel()
649+ synctest.Wait()
650+ require.Equal(t, 0, r.events.Watchers())
651+ again, stop := context.WithCancel(context.Background())
652+ stop()
653+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")
654+ })
573655 })
574656 }
575657
@@ -597,105 +679,119 @@ func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time)
597679 }
598680
599681 func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {
600- ctx, cancel := context.WithCancel(context.Background())
601- defer cancel()
602-
603682 t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {
604- r := newEventRig(t, 0)
605- l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
606- root := r.watch(ctx, domain.Root(), app.WatchParams{})
607- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
608- r.push(n, 1)
609- r.push(n, 2)
610- root.next(t)
611- root.next(t) // both are buffered now
612-
613- l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })
614- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}
615- root.next(t)
616- r.push(n, 3) // public era
617-
618- // An anonymous caller that resumes with an id nobody issued gets what
619- // is buffered, filtered: the public update and the public push, not the
620- // pushes of the private era.
621- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
622- assert.True(t, anon.next(t).Gap)
623- upd := anon.next(t)
624- require.IsType(t, domain.RepoUpdated{}, upd.Event)
625- push := anon.next(t)
626- require.IsType(t, domain.PushEvent{}, push.Event)
627- assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)
628- anon.quiet(t, r, n)
683+ synctest.Test(t, func(t *testing.T) {
684+ ctx, cancel := context.WithCancel(context.Background())
685+ defer cancel()
686+ r := newEventRig(t, 0)
687+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
688+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
689+ synctest.Wait()
690+ require.Equal(t, 1, r.events.Watchers())
691+ r.push(n, 1)
692+ r.push(n, 2)
693+ root.next(t)
694+ root.next(t) // both are buffered now
695+
696+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })
697+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}
698+ root.next(t)
699+ r.push(n, 3) // public era
700+
701+ // An anonymous caller that resumes with an id nobody issued gets what
702+ // is buffered, filtered: the public update and the public push, not the
703+ // pushes of the private era.
704+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
705+ assert.True(t, anon.next(t).Gap)
706+ upd := anon.next(t)
707+ require.IsType(t, domain.RepoUpdated{}, upd.Event)
708+ push := anon.next(t)
709+ require.IsType(t, domain.PushEvent{}, push.Event)
710+ assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)
711+ anon.quiet(t, r, n)
712+ })
629713 })
630714
631715 t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) {
632- r := newEventRig(t, 0)
633- first := now.Add(-time.Hour)
634- l, n := r.liveRepo("again", domain.VisibilityPublic, first)
635- root := r.watch(ctx, domain.Root(), app.WatchParams{})
636- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
637- r.push(n, 1)
638- root.next(t)
639-
640- l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again
641- r.push(n, 2)
642- root.next(t)
643-
644- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
645- assert.True(t, anon.next(t).Gap)
646- e := anon.next(t)
647- require.IsType(t, domain.PushEvent{}, e.Event)
648- assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")
649- anon.quiet(t, r, n)
716+ synctest.Test(t, func(t *testing.T) {
717+ ctx, cancel := context.WithCancel(context.Background())
718+ defer cancel()
719+ r := newEventRig(t, 0)
720+ first := now.Add(-time.Hour)
721+ l, n := r.liveRepo("again", domain.VisibilityPublic, first)
722+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
723+ synctest.Wait()
724+ require.Equal(t, 1, r.events.Watchers())
725+ r.push(n, 1)
726+ root.next(t)
727+
728+ l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again
729+ r.push(n, 2)
730+ root.next(t)
731+
732+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
733+ assert.True(t, anon.next(t).Gap)
734+ e := anon.next(t)
735+ require.IsType(t, domain.PushEvent{}, e.Event)
736+ assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")
737+ anon.quiet(t, r, n)
738+ })
650739 })
651740
652741 t.Run("a repository made private tells those who could read it, once", func(t *testing.T) {
653- r := newEventRig(t, 0)
654- l, n := r.liveRepo("hide", domain.VisibilityPublic, now)
655- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
656- reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{})
657- other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{})
658- root := r.watch(ctx, domain.Root(), app.WatchParams{})
659- require.Eventually(t, func() bool { return r.events.Watchers() >= 4 }, 5*time.Second, time.Millisecond)
660-
661- l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })
662- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}
663-
664- for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {
665- e := w.next(t)
666- up, ok := e.Event.(domain.RepoUpdated)
667- require.True(t, ok, name)
668- assert.Equal(t, n, up.Repo, name)
669- require.NotNil(t, e.Repo, name)
670- assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)
671- assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)
672- }
742+ synctest.Test(t, func(t *testing.T) {
743+ ctx, cancel := context.WithCancel(context.Background())
744+ defer cancel()
745+ r := newEventRig(t, 0)
746+ l, n := r.liveRepo("hide", domain.VisibilityPublic, now)
747+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
748+ reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{})
749+ other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{})
750+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
751+ synctest.Wait()
752+ require.Equal(t, 4, r.events.Watchers())
753+
754+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })
755+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}
756+
757+ for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {
758+ e := w.next(t)
759+ up, ok := e.Event.(domain.RepoUpdated)
760+ require.True(t, ok, name)
761+ assert.Equal(t, n, up.Repo, name)
762+ require.NotNil(t, e.Repo, name)
763+ assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)
764+ assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)
765+ }
673766
674- // Later events of the repository reach those who may read it now, only.
675- r.push(n, 1)
676- assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)
677- assert.IsType(t, domain.PushEvent{}, root.next(t).Event)
678- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
679- other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
767+ // Later events of the repository reach those who may read it now, only.
768+ r.push(n, 1)
769+ assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)
770+ assert.IsType(t, domain.PushEvent{}, root.next(t).Event)
771+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
772+ other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
773+ })
680774 })
681775
682776 t.Run("a private repository that never was public stays hidden", func(t *testing.T) {
683- r := newEventRig(t, 0)
684- _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)
685- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
686- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
687- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
688- r.push(n, 1)
689- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
777+ synctest.Test(t, func(t *testing.T) {
778+ ctx, cancel := context.WithCancel(context.Background())
779+ defer cancel()
780+ r := newEventRig(t, 0)
781+ _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)
782+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
783+ synctest.Wait()
784+ require.Equal(t, 1, r.events.Watchers())
785+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
786+ r.push(n, 1)
787+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
788+ })
690789 })
691790 }
692791
693792 // TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was
694793 // when published, not as the store says when the feeder gets to it.
695794 func TestPushEventsKeepTheirSnapshot(t *testing.T) {
696- ctx, cancel := context.WithCancel(context.Background())
697- defer cancel()
698-
699795 tests := []struct {
700796 name string
701797 swap func(repo *domain.Repo)
@@ -709,36 +805,46 @@ func TestPushEventsKeepTheirSnapshot(t *testing.T) {
709805 }
710806 for _, tt := range tests {
711807 t.Run(tt.name, func(t *testing.T) {
712- r := newEventRig(t, 0)
713- l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
714- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
715- root := r.watch(ctx, domain.Root(), app.WatchParams{})
716- require.Eventually(t, func() bool { return r.events.Watchers() >= 2 }, 5*time.Second, time.Millisecond)
717-
718- // Published while private; the store changes before the feeder
719- // can have looked at it: the event is built, then the state is
720- // swapped, and only then is it handed to the feeder.
721- ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main")
722- l.set(tt.swap)
723- r.feed <- ev
724- if tt.rootSees {
725- root.next(t)
726- }
727-
728- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
729-
730- late := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
731- assert.True(t, late.next(t).Gap)
732- late.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
808+ synctest.Test(t, func(t *testing.T) {
809+ ctx, cancel := context.WithCancel(context.Background())
810+ defer cancel()
811+ r := newEventRig(t, 0)
812+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
813+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
814+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
815+ synctest.Wait()
816+ require.Equal(t, 2, r.events.Watchers())
817+
818+ // Published while private; the store changes before the feeder
819+ // can have looked at it: the event is built, then the state is
820+ // swapped, and only then is it handed to the feeder.
821+ ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main")
822+ l.set(tt.swap)
823+ r.feed <- ev
824+ if tt.rootSees {
825+ root.next(t)
826+ }
827+
828+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
829+
830+ late := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
831+ assert.True(t, late.next(t).Gap)
832+ late.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
833+ })
733834 })
734835 }
735836
736837 t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) {
737- r := newEventRig(t, 0)
738- n := r.repo("ghost", domain.VisibilityPublic)
739- root := r.watch(ctx, domain.Root(), app.WatchParams{})
740- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)
741- r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now}
742- root.quiet(t, r, n)
838+ synctest.Test(t, func(t *testing.T) {
839+ ctx, cancel := context.WithCancel(context.Background())
840+ defer cancel()
841+ r := newEventRig(t, 0)
842+ n := r.repo("ghost", domain.VisibilityPublic)
843+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
844+ synctest.Wait()
845+ require.Equal(t, 1, r.events.Watchers())
846+ r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now}
847+ root.quiet(t, r, n)
848+ })
743849 })
744850 }
@@ -7,7 +7,9 @@ import (
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9 "sync"9 "sync"
10+ "sync/atomic"
10 "testing"11 "testing"
12+ "testing/synctest"
11 "time"13 "time"
12 14
13 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
@@ -22,13 +24,14 @@ import (
22 24
23 // eventRig is an Events use case fed by a channel the test writes to.25 // eventRig is an Events use case fed by a channel the test writes to.
24 type eventRig struct {26 type eventRig struct {
25- t *testing.T27+ t *testing.T
26- meta *mocks.RepoMetaStore28+ meta *mocks.RepoMetaStore
27- git *mocks.GitRepos29+ git *mocks.GitRepos
28- feed chan domain.Event30+ feed chan domain.Event
29- auth *appmocks.Revalidator31+ auth *appmocks.Revalidator
30- revoked sync.Map // token IDs revoked by the test32+ revoked sync.Map // token IDs revoked by the test
31- events *app.Events33+ revalidations atomic.Int32 // calls to auth.Revalidate
34+ events *app.Events
32 }35 }
33 36
34 func newEventRig(t *testing.T, size int) *eventRig {37 func newEventRig(t *testing.T, size int) *eventRig {
@@ -47,6 +50,7 @@ func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig {
47 }50 }
48 // Tokens are valid until the test revokes them.51 // Tokens are valid until the test revokes them.
49 r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error {52 r.auth.EXPECT().Revalidate(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal) error {
53+ r.revalidations.Add(1)
50 if _, gone := r.revoked.Load(p.TokenID()); gone {54 if _, gone := r.revoked.Load(p.TokenID()); gone {
51 return domain.ErrUnauthenticated55 return domain.ErrUnauthenticated
52 }56 }
@@ -108,12 +112,13 @@ func (r *eventRig) watch(ctx context.Context, p domain.Principal, params app.Wat
108 112
109 func (w *watcher) next(t *testing.T) app.WatchEvent {113 func (w *watcher) next(t *testing.T) app.WatchEvent {
110 t.Helper()114 t.Helper()
115+ synctest.Wait() // everything the event can reach has run
111 select {116 select {
112 case e := <-w.got:117 case e := <-w.got:
113 return e118 return e
114 case err := <-w.done:119 case err := <-w.done:
115 t.Fatalf("watch ended: %v", err)120 t.Fatalf("watch ended: %v", err)
116- case <-time.After(5 * time.Second):121+ default:
117 t.Fatal("no event")122 t.Fatal("no event")
118 }123 }
119 return app.WatchEvent{}124 return app.WatchEvent{}
@@ -137,127 +142,149 @@ func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) {
137 }142 }
138 143
139 func TestWatchLiveAndFiltered(t *testing.T) {144 func TestWatchLiveAndFiltered(t *testing.T) {
140- r := newEventRig(t, 0)145+ synctest.Test(t, func(t *testing.T) {
141- pub := r.repo("pub", domain.VisibilityPublic)146+ r := newEventRig(t, 0)
142- priv := r.repo("priv", domain.VisibilityPrivate)147+ pub := r.repo("pub", domain.VisibilityPublic)
143- ctx, cancel := context.WithCancel(context.Background())148+ priv := r.repo("priv", domain.VisibilityPrivate)
144- defer cancel()149+ ctx, cancel := context.WithCancel(context.Background())
145-150+ defer cancel()
146- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})151+
147- root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}})152+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
148- scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{})153+ root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}})
149- onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}})154+ scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{})
150- require.Eventually(t, func() bool { return r.events.Watchers() == 4 }, 5*time.Second, time.Millisecond)155+ onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}})
151-156+ synctest.Wait()
152- r.push(priv, 1)157+ require.Equal(t, 4, r.events.Watchers())
153- r.push(pub, 2)158+
154-159+ r.push(priv, 1)
155- // Anonymous and a token limited to another repository never see priv.160+ r.push(pub, 2)
156- for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {161+
157- e := w.next(t)162+ // Anonymous and a token limited to another repository never see priv.
158- assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)163+ for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} {
159- w.quiet(t, r, pub)164+ e := w.next(t)
160- }165+ assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name)
161- first, second := root.next(t), root.next(t)166+ w.quiet(t, r, pub)
162- assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)167+ }
163- assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)168+ first, second := root.next(t), root.next(t)
164- assert.NotEmpty(t, first.ID)169+ assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo)
165- assert.NotEqual(t, first.ID, second.ID)170+ assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo)
166-171+ assert.NotEmpty(t, first.ID)
167- assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)172+ assert.NotEqual(t, first.ID, second.ID)
168- onlyPriv.quiet(t, r, priv)173+
174+ assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo)
175+ onlyPriv.quiet(t, r, priv)
176+ })
169 }177 }
170 178
171 func TestWatchRechecksAccessPerEvent(t *testing.T) {179 func TestWatchRechecksAccessPerEvent(t *testing.T) {
172- r := newEventRig(t, 0)180+ synctest.Test(t, func(t *testing.T) {
173- n := name(t, "flip")181+ r := newEventRig(t, 0)
174- vis := domain.VisibilityPublic182+ n := name(t, "flip")
175- var mu sync.Mutex183+ vis := domain.VisibilityPublic
176- r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {184+ var mu sync.Mutex
185+ r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
186+ mu.Lock()
187+ defer mu.Unlock()
188+ return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil
189+ })
190+ ctx, cancel := context.WithCancel(context.Background())
191+ defer cancel()
192+ w := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
193+ synctest.Wait()
194+ require.Equal(t, 1, r.events.Watchers())
195+
196+ r.push(n, 1)
197+ w.next(t)
198+
177 mu.Lock()199 mu.Lock()
178- defer mu.Unlock()200+ vis = domain.VisibilityPrivate
179- return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil201+ mu.Unlock()
202+ r.push(n, 2)
203+ w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
180 })204 })
181- ctx, cancel := context.WithCancel(context.Background())
182- defer cancel()
183- w := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
184- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
185-
186- r.push(n, 1)
187- w.next(t)
188-
189- mu.Lock()
190- vis = domain.VisibilityPrivate
191- mu.Unlock()
192- r.push(n, 2)
193- w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous
194 }205 }
195 206
196 func TestWatchResumeAndGap(t *testing.T) {207 func TestWatchResumeAndGap(t *testing.T) {
197 const size = 8208 const size = 8
198- r := newEventRig(t, size)209+ // Each subtest gets its own bubble and a fresh run of 20 events recorded
199- n := r.repo("pub", domain.VisibilityPublic)210+ // through a first watcher; the ring then holds the last 8.
200- ctx, cancel := context.WithCancel(context.Background())211+ setup := func(t *testing.T) (r *eventRig, n domain.RepoName, ctx context.Context, ids []string) {
201- defer cancel()212+ r = newEventRig(t, size)
202-213+ n = r.repo("pub", domain.VisibilityPublic)
203- // Record the ids of 20 events through a first watcher.214+ ctx, cancel := context.WithCancel(context.Background())
204- first := r.watch(ctx, domain.Root(), app.WatchParams{})215+ t.Cleanup(cancel)
205- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)216+
206- // One at a time: this watcher must not fall behind the small ring.217+ first := r.watch(ctx, domain.Root(), app.WatchParams{})
207- var ids []string218+ synctest.Wait()
208- for i := range 20 {219+ require.Equal(t, 1, r.events.Watchers())
209- r.push(n, i)220+ // One at a time: this watcher must not fall behind the small ring.
210- ids = append(ids, first.next(t).ID)221+ for i := range 20 {
222+ r.push(n, i)
223+ ids = append(ids, first.next(t).ID)
224+ }
225+ require.Len(t, ids, 20)
226+ return r, n, ctx, ids
211 }227 }
212- require.Len(t, ids, 20)
213 228
214- // The subtests that read the state of the ring come first: the ones that
215- // need a sentinel add events to it.
216 t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {229 t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) {
217- // 20 events were taken, the buffer holds the last 8: ids[12] is the230+ synctest.Test(t, func(t *testing.T) {
218- // oldest still present; ids[0] is long gone.231+ r, _, ctx, ids := setup(t)
219- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]})232+ // 20 events were taken, the buffer holds the last 8: ids[12] is the
220- gap := w.next(t)233+ // oldest still present; ids[0] is long gone.
221- assert.True(t, gap.Gap)234+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]})
222- assert.Empty(t, gap.ID)235+ gap := w.next(t)
223- oldest := w.next(t)236+ assert.True(t, gap.Gap)
224- assert.False(t, oldest.Gap)237+ assert.Empty(t, gap.ID)
225- assert.Equal(t, ids[12], oldest.ID)238+ oldest := w.next(t)
239+ assert.False(t, oldest.Gap)
240+ assert.Equal(t, ids[12], oldest.ID)
241+ })
226 })242 })
227 243
228 t.Run("an unknown id gives a gap", func(t *testing.T) {244 t.Run("an unknown id gives a gap", func(t *testing.T) {
229- for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {245+ synctest.Test(t, func(t *testing.T) {
230- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id})246+ r, _, ctx, _ := setup(t)
231- assert.True(t, w.next(t).Gap, id)247+ for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} {
232- }248+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id})
249+ assert.True(t, w.next(t).Gap, id)
250+ }
251+ })
233 })252 })
234 253
235 t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) {254 t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) {
236- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"})255+ synctest.Test(t, func(t *testing.T) {
237- assert.True(t, w.next(t).Gap)256+ r, _, ctx, _ := setup(t)
257+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"})
258+ assert.True(t, w.next(t).Gap)
259+ })
238 })260 })
239 261
240 t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {262 t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) {
241- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})263+ synctest.Test(t, func(t *testing.T) {
242- for _, want := range ids[17:] {264+ r, n, ctx, ids := setup(t)
243- e := w.next(t)265+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]})
244- assert.False(t, e.Gap)266+ for _, want := range ids[17:] {
245- assert.Equal(t, want, e.ID)267+ e := w.next(t)
246- }268+ assert.False(t, e.Gap)
247- // Nothing else was replayed: the next event is the one pushed now.269+ assert.Equal(t, want, e.ID)
248- r.push(n, 98)270+ }
249- live := w.next(t)271+ // Nothing else was replayed: the next event is the one pushed now.
250- assert.False(t, live.Gap)272+ r.push(n, 98)
251- assert.NotContains(t, ids, live.ID)273+ live := w.next(t)
252- ids = append(ids, live.ID)274+ assert.False(t, live.Gap)
275+ assert.NotContains(t, ids, live.ID)
276+ })
253 })277 })
254 278
255 t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) {279 t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) {
256- w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]})280+ synctest.Test(t, func(t *testing.T) {
257- r.push(n, 99)281+ r, n, ctx, ids := setup(t)
258- e := w.next(t)282+ w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]})
259- assert.False(t, e.Gap)283+ r.push(n, 99)
260- assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")284+ e := w.next(t)
285+ assert.False(t, e.Gap)
286+ assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed")
287+ })
261 })288 })
262 }289 }
263 290
@@ -284,133 +311,147 @@ func TestWatchTopicErrors(t *testing.T) {
284 311
285 func TestWatchEndsCleanly(t *testing.T) {312 func TestWatchEndsCleanly(t *testing.T) {
286 t.Run("on context cancellation", func(t *testing.T) {313 t.Run("on context cancellation", func(t *testing.T) {
287- r := newEventRig(t, 0)314+ synctest.Test(t, func(t *testing.T) {
288- ctx, cancel := context.WithCancel(context.Background())315+ r := newEventRig(t, 0)
289- w := r.watch(ctx, domain.Root(), app.WatchParams{})316+ ctx, cancel := context.WithCancel(context.Background())
290- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)317+ w := r.watch(ctx, domain.Root(), app.WatchParams{})
291- cancel()318+ synctest.Wait()
292- select {319+ require.Equal(t, 1, r.events.Watchers())
293- case err := <-w.done:320+ cancel()
294- assert.NoError(t, err)321+ synctest.Wait()
295- case <-time.After(5 * time.Second):322+ select {
296- t.Fatal("watch did not end")323+ case err := <-w.done:
297- }324+ assert.NoError(t, err)
298- assert.Zero(t, r.events.Watchers())325+ default:
326+ t.Fatal("watch did not end")
327+ }
328+ assert.Zero(t, r.events.Watchers())
329+ })
299 })330 })
300 331
301 t.Run("on Close", func(t *testing.T) {332 t.Run("on Close", func(t *testing.T) {
302- r := newEventRig(t, 0)333+ synctest.Test(t, func(t *testing.T) {
303- w := r.watch(context.Background(), domain.Root(), app.WatchParams{})334+ r := newEventRig(t, 0)
304- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)335+ w := r.watch(context.Background(), domain.Root(), app.WatchParams{})
305- r.events.Close(context.Background())336+ synctest.Wait()
306- r.events.Close(context.Background()) // twice is fine337+ require.Equal(t, 1, r.events.Watchers())
307- select {338+ r.events.Close(context.Background())
308- case err := <-w.done:339+ r.events.Close(context.Background()) // twice is fine
309- assert.NoError(t, err)340+ synctest.Wait()
310- case <-time.After(5 * time.Second):341+ select {
311- t.Fatal("watch did not end")342+ case err := <-w.done:
312- }343+ assert.NoError(t, err)
344+ default:
345+ t.Fatal("watch did not end")
346+ }
347+ })
313 })348 })
314 349
315 t.Run("a failing send ends the watch with its error", func(t *testing.T) {350 t.Run("a failing send ends the watch with its error", func(t *testing.T) {
316- r := newEventRig(t, 0)351+ synctest.Test(t, func(t *testing.T) {
317- n := r.repo("pub", domain.VisibilityPublic)352+ r := newEventRig(t, 0)
318- boom := errors.New("client went away")353+ n := r.repo("pub", domain.VisibilityPublic)
319- done := make(chan error, 1)354+ boom := errors.New("client went away")
320- go func() {355+ done := make(chan error, 1)
321- done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom })356+ go func() {
322- }()357+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom })
323- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)358+ }()
324- r.push(n, 1)359+ synctest.Wait()
325- assert.ErrorIs(t, <-done, boom)360+ require.Equal(t, 1, r.events.Watchers())
361+ r.push(n, 1)
362+ assert.ErrorIs(t, <-done, boom)
363+ })
326 })364 })
327 }365 }
328 366
329 func TestWatchSlowWatcherGetsAGap(t *testing.T) {367 func TestWatchSlowWatcherGetsAGap(t *testing.T) {
330- const size = 4368+ synctest.Test(t, func(t *testing.T) {
331- r := newEventRig(t, size)369+ const size = 4
332- n := r.repo("pub", domain.VisibilityPublic)370+ r := newEventRig(t, size)
333-371+ n := r.repo("pub", domain.VisibilityPublic)
334- release := make(chan struct{})372+
335- var mu sync.Mutex373+ release := make(chan struct{})
336- var got []app.WatchEvent374+ var mu sync.Mutex
337- done := make(chan error, 1)375+ var got []app.WatchEvent
338- blocked := make(chan struct{})376+ done := make(chan error, 1)
339- first := true377+ blocked := make(chan struct{})
340- go func() {378+ first := true
341- done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error {379+ go func() {
342- if first { // block on the first event while the ring turns over380+ done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error {
343- first = false381+ if first { // block on the first event while the ring turns over
344- close(blocked)382+ first = false
345- <-release383+ close(blocked)
384+ <-release
385+ }
386+ mu.Lock()
387+ got = append(got, e)
388+ mu.Unlock()
389+ return nil
390+ })
391+ }()
392+ // A second, fast watcher tells when the use case has taken every event:
393+ // it receives them in order.
394+ fast := r.watch(context.Background(), domain.Root(), app.WatchParams{})
395+ synctest.Wait()
396+ require.Equal(t, 2, r.events.Watchers())
397+ r.push(n, 0)
398+ <-blocked
399+ for i := 1; i < 30; i++ {
400+ r.push(n, i)
401+ }
402+ // The ring holds four events: the fast watcher may get a gap, but it ends
403+ // on the last push, which proves the use case took all of them.
404+ last := fmt.Sprintf("%040x", 29+1)
405+ for {
406+ e := fast.next(t)
407+ if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
408+ break
346 }409 }
347- mu.Lock()
348- got = append(got, e)
349- mu.Unlock()
350- return nil
351- })
352- }()
353- // A second, fast watcher tells when the use case has taken every event:
354- // it receives them in order.
355- fast := r.watch(context.Background(), domain.Root(), app.WatchParams{})
356- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)
357- r.push(n, 0)
358- <-blocked
359- for i := 1; i < 30; i++ {
360- r.push(n, i)
361- }
362- // The ring holds four events: the fast watcher may get a gap, but it ends
363- // on the last push, which proves the use case took all of them.
364- last := fmt.Sprintf("%040x", 29+1)
365- for {
366- e := fast.next(t)
367- if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last {
368- break
369 }410 }
370- }411+ close(release)
371- close(release)
372 412
373- require.Eventually(t, func() bool {413+ synctest.Wait()
374 mu.Lock()414 mu.Lock()
375 defer mu.Unlock()415 defer mu.Unlock()
376- return len(got) >= 2416+ require.GreaterOrEqual(t, len(got), 2)
377- }, 5*time.Second, time.Millisecond)417+ hasGap := false
378- mu.Lock()418+ for _, e := range got[1:] {
379- defer mu.Unlock()419+ hasGap = hasGap || e.Gap
380- hasGap := false420+ }
381- for _, e := range got[1:] {421+ assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
382- hasGap = hasGap || e.Gap422+ })
383- }
384- assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got)
385 }423 }
386 424
387 func TestEventsRepoPayloads(t *testing.T) {425 func TestEventsRepoPayloads(t *testing.T) {
388- r := newEventRig(t, 0)426+ synctest.Test(t, func(t *testing.T) {
389- pub := r.repo("pub", domain.VisibilityPublic)427+ r := newEventRig(t, 0)
390- priv := r.repo("priv", domain.VisibilityPrivate)428+ pub := r.repo("pub", domain.VisibilityPublic)
391- ctx, cancel := context.WithCancel(context.Background())429+ priv := r.repo("priv", domain.VisibilityPrivate)
392- defer cancel()430+ ctx, cancel := context.WithCancel(context.Background())
393- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})431+ defer cancel()
394- root := r.watch(ctx, domain.Root(), app.WatchParams{})432+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
395- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)433+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
396-434+ synctest.Wait()
397- r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}435+ require.Equal(t, 2, r.events.Watchers())
398- r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now}436+
399- // A deleted repository cannot be read any more: its visibility decides.437+ r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
400- r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}438+ r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now}
401- r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}439+ // A deleted repository cannot be read any more: its visibility decides.
402-440+ r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now}
403- for _, w := range []*watcher{anon, root} {441+ r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now}
404- created := w.next(t)442+
405- require.NotNil(t, created.Repo)443+ for _, w := range []*watcher{anon, root} {
406- assert.Equal(t, "main", created.Repo.DefaultBranch)444+ created := w.next(t)
407- assert.Equal(t, pub, created.Repo.Name)445+ require.NotNil(t, created.Repo)
408- require.NotNil(t, w.next(t).Repo)446+ assert.Equal(t, "main", created.Repo.DefaultBranch)
409- }447+ assert.Equal(t, pub, created.Repo.Name)
410- assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)448+ require.NotNil(t, w.next(t).Repo)
411- assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)449+ }
412- assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")450+ assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo)
413- anon.quiet(t, r, pub)451+ assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo)
452+ assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped")
453+ anon.quiet(t, r, pub)
454+ })
414 }455 }
415 456
416 // tokenWith builds the principal of a stored token with an id and an expiry.457 // tokenWith builds the principal of a stored token with an id and an expiry.
@@ -427,88 +468,118 @@ func tokenWith(id string, expires time.Time, repos ...string) domain.Principal {
427 }468 }
428 469
429 func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {470 func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) {
430- // Asking at every turn keeps the test independent of the clock.471+ synctest.Test(t, func(t *testing.T) {
431- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})472+ // Asking at every turn keeps the test independent of the clock.
432- pub := r.repo("pub", domain.VisibilityPublic)473+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
433- priv := r.repo("priv", domain.VisibilityPrivate)474+ pub := r.repo("pub", domain.VisibilityPublic)
434- p := tokenWith("tok", time.Time{})475+ priv := r.repo("priv", domain.VisibilityPrivate)
435- ctx, cancel := context.WithCancel(context.Background())476+ p := tokenWith("tok", time.Time{})
436- defer cancel()477+ ctx, cancel := context.WithCancel(context.Background())
437-478+ defer cancel()
438- w := r.watch(ctx, p, app.WatchParams{})
439- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
440- r.push(pub, 1)
441- w.next(t)
442-
443- r.revoked.Store("tok", true)
444- r.push(priv, 2) // would be delivered to a token that may read priv
445- select {
446- case err := <-w.done:
447- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
448- case <-time.After(5 * time.Second):
449- t.Fatal("the watch of a revoked token did not end")
450- }
451- select {
452- case e := <-w.got:
453- t.Fatalf("an event was sent after the revocation: %+v", e)
454- default:
455- }
456-}
457 479
458-func TestWatchEndsWhenTheTokenExpires(t *testing.T) {480+ w := r.watch(ctx, p, app.WatchParams{})
459- r := newEventRig(t, 0)481+ synctest.Wait()
460- pub := r.repo("pub", domain.VisibilityPublic)482+ require.Equal(t, 1, r.events.Watchers())
461- // The rig's clock stands at now: this token expired at now.483+ r.push(pub, 1)
462- p := tokenWith("tok", now)484+ w.next(t)
463- w := r.watch(context.Background(), p, app.WatchParams{})
464- r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated
465- select {
466- case err := <-w.done:
467- assert.ErrorIs(t, err, domain.ErrUnauthenticated)
468- case <-time.After(5 * time.Second):
469- t.Fatal("the watch of an expired token did not end")
470- }
471- assert.Empty(t, w.got)
472-}
473 485
474-func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
475- t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
476- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond})
477- w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
478- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)
479 r.revoked.Store("tok", true)486 r.revoked.Store("tok", true)
487+ r.push(priv, 2) // would be delivered to a token that may read priv
488+ synctest.Wait()
480 select {489 select {
481 case err := <-w.done:490 case err := <-w.done:
482 assert.ErrorIs(t, err, domain.ErrUnauthenticated)491 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
483- case <-time.After(5 * time.Second):492+ default:
484- t.Fatal("an idle watch of a revoked token did not end")493+ t.Fatal("the watch of a revoked token did not end")
494+ }
495+ select {
496+ case e := <-w.got:
497+ t.Fatalf("an event was sent after the revocation: %+v", e)
498+ default:
485 }499 }
486 })500 })
501+}
487 502
488- t.Run("the answer is kept between checks", func(t *testing.T) {503+func TestWatchEndsWhenTheTokenExpires(t *testing.T) {
489- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})504+ synctest.Test(t, func(t *testing.T) {
505+ r := newEventRig(t, 0)
490 pub := r.repo("pub", domain.VisibilityPublic)506 pub := r.repo("pub", domain.VisibilityPublic)
491- w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})507+ // The rig's clock stands at now: this token expired at now.
492- require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond)508+ p := tokenWith("tok", now)
493- for i := range 20 {509+ w := r.watch(context.Background(), p, app.WatchParams{})
494- r.push(pub, i)510+ r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated
495- }511+ synctest.Wait()
496- for range 20 {512+ select {
497- w.next(t)513+ case err := <-w.done:
514+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
515+ default:
516+ t.Fatal("the watch of an expired token did not end")
498 }517 }
499- r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything) // no store lookup within the interval518+ assert.Empty(t, w.got)
519+ })
520+}
521+
522+func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) {
523+ t.Run("a quiet forge still notices a revocation", func(t *testing.T) {
524+ synctest.Test(t, func(t *testing.T) {
525+ r := newEventRigConfig(t, app.EventsConfig{}) // the production default interval
526+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
527+ synctest.Wait()
528+ require.Equal(t, 1, r.events.Watchers())
529+ r.revoked.Store("tok", true)
530+ synctest.Sleep(app.DefaultRevalidateEvery - time.Nanosecond)
531+ select {
532+ case err := <-w.done:
533+ t.Fatalf("the watch ended before the interval: %v", err)
534+ default:
535+ }
536+ assert.Zero(t, r.revalidations.Load(), "no store lookup before the interval")
537+ synctest.Sleep(time.Nanosecond)
538+ select {
539+ case err := <-w.done:
540+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
541+ default:
542+ t.Fatal("an idle watch of a revoked token did not end at the interval")
543+ }
544+ assert.Equal(t, int32(1), r.revalidations.Load())
545+ })
546+ })
547+
548+ t.Run("the answer is kept between checks", func(t *testing.T) {
549+ synctest.Test(t, func(t *testing.T) {
550+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour})
551+ pub := r.repo("pub", domain.VisibilityPublic)
552+ w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{})
553+ synctest.Wait()
554+ require.Equal(t, 1, r.events.Watchers())
555+ for i := range 20 {
556+ r.push(pub, i)
557+ }
558+ for range 20 {
559+ w.next(t)
560+ }
561+ // Idle, the watcher wakes up every few seconds but keeps its answer
562+ // until the interval is over.
563+ synctest.Sleep(time.Hour - time.Nanosecond)
564+ assert.Zero(t, r.revalidations.Load(), "no store lookup within the interval")
565+ synctest.Sleep(time.Nanosecond)
566+ assert.Equal(t, int32(1), r.revalidations.Load(), "one lookup when the interval is over")
567+ })
500 })568 })
501 569
502 t.Run("root and anonymous never ask", func(t *testing.T) {570 t.Run("root and anonymous never ask", func(t *testing.T) {
503- r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})571+ synctest.Test(t, func(t *testing.T) {
504- pub := r.repo("pub", domain.VisibilityPublic)572+ r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1})
505- a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{})573+ pub := r.repo("pub", domain.VisibilityPublic)
506- b := r.watch(context.Background(), domain.Root(), app.WatchParams{})574+ a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{})
507- require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond)575+ b := r.watch(context.Background(), domain.Root(), app.WatchParams{})
508- r.push(pub, 1)576+ synctest.Wait()
509- a.next(t)577+ require.Equal(t, 2, r.events.Watchers())
510- b.next(t)578+ r.push(pub, 1)
511- r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)579+ a.next(t)
580+ b.next(t)
581+ r.auth.AssertNotCalled(t, "Revalidate", mock.Anything, mock.Anything)
582+ })
512 })583 })
513 }584 }
514 585
@@ -516,60 +587,71 @@ func TestWatchLimits(t *testing.T) {
516 noop := func(app.WatchEvent) error { return nil }587 noop := func(app.WatchEvent) error { return nil }
517 588
518 t.Run("too many topics", func(t *testing.T) {589 t.Run("too many topics", func(t *testing.T) {
519- r := newEventRig(t, 0)590+ synctest.Test(t, func(t *testing.T) {
520- r.repo("pub", domain.VisibilityPublic)591+ r := newEventRig(t, 0)
521- ok := make([]string, app.MaxWatchTopics)592+ r.repo("pub", domain.VisibilityPublic)
522- for i := range ok {593+ ok := make([]string, app.MaxWatchTopics)
523- ok[i] = "home"594+ for i := range ok {
524- }595+ ok[i] = "home"
525- ctx, cancel := context.WithCancel(context.Background())596+ }
526- cancel() // returns at once once the topics are accepted597+ ctx, cancel := context.WithCancel(context.Background())
527- assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop))598+ cancel() // returns at once once the topics are accepted
528- err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop)599+ assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop))
529- assert.ErrorIs(t, err, domain.ErrInvalid)600+ err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop)
601+ assert.ErrorIs(t, err, domain.ErrInvalid)
602+ })
530 })603 })
531 604
532 t.Run("total watchers", func(t *testing.T) {605 t.Run("total watchers", func(t *testing.T) {
533- r := newEventRig(t, 0)606+ synctest.Test(t, func(t *testing.T) {
534- ctx, cancel := context.WithCancel(context.Background())607+ r := newEventRig(t, 0)
535- defer cancel()608+ ctx, cancel := context.WithCancel(context.Background())
536- for range app.MaxWatchers {609+ defer cancel()
537- r.watch(ctx, domain.Root(), app.WatchParams{})610+ for range app.MaxWatchers {
538- }611+ r.watch(ctx, domain.Root(), app.WatchParams{})
539- require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchers }, 5*time.Second, time.Millisecond)612+ }
540- err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop)613+ synctest.Wait()
541- assert.ErrorIs(t, err, domain.ErrResourceExhausted)614+ require.Equal(t, app.MaxWatchers, r.events.Watchers())
542- assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")615+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop)
543-616+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
544- cancel() // all end; a new one is accepted again617+ assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted")
545- require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)618+
546- again, stop := context.WithCancel(context.Background())619+ cancel() // all end; a new one is accepted again
547- stop()620+ synctest.Wait()
548- assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop))621+ require.Equal(t, 0, r.events.Watchers())
622+ again, stop := context.WithCancel(context.Background())
623+ stop()
624+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop))
625+ })
549 })626 })
550 627
551 t.Run("watchers per client", func(t *testing.T) {628 t.Run("watchers per client", func(t *testing.T) {
552- r := newEventRig(t, 0)629+ synctest.Test(t, func(t *testing.T) {
553- ctx, cancel := context.WithCancel(context.Background())630+ r := newEventRig(t, 0)
554- defer cancel()631+ ctx, cancel := context.WithCancel(context.Background())
555- for range app.MaxWatchersPerClient {632+ defer cancel()
556- r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"})633+ for range app.MaxWatchersPerClient {
557- }634+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"})
558- require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond)635+ }
559-636+ synctest.Wait()
560- err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop)637+ require.Equal(t, app.MaxWatchersPerClient, r.events.Watchers())
561- assert.ErrorIs(t, err, domain.ErrResourceExhausted)
562 638
563- // Another client, and callers in process, are not affected.639+ err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop)
564- r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"})640+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
565- r.watch(ctx, domain.Root(), app.WatchParams{})
566- require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient+2 }, 5*time.Second, time.Millisecond)
567 641
568- cancel()642+ // Another client, and callers in process, are not affected.
569- require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond)643+ r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"})
570- again, stop := context.WithCancel(context.Background())644+ r.watch(ctx, domain.Root(), app.WatchParams{})
571- stop()645+ synctest.Wait()
572- assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")646+ require.Equal(t, app.MaxWatchersPerClient+2, r.events.Watchers())
647+
648+ cancel()
649+ synctest.Wait()
650+ require.Equal(t, 0, r.events.Watchers())
651+ again, stop := context.WithCancel(context.Background())
652+ stop()
653+ assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released")
654+ })
573 })655 })
574 }656 }
575 657
@@ -597,105 +679,119 @@ func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time)
597 }679 }
598 680
599 func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {681 func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) {
600- ctx, cancel := context.WithCancel(context.Background())
601- defer cancel()
602-
603 t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {682 t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) {
604- r := newEventRig(t, 0)683+ synctest.Test(t, func(t *testing.T) {
605- l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)684+ ctx, cancel := context.WithCancel(context.Background())
606- root := r.watch(ctx, domain.Root(), app.WatchParams{})685+ defer cancel()
607- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)686+ r := newEventRig(t, 0)
608- r.push(n, 1)687+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
609- r.push(n, 2)688+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
610- root.next(t)689+ synctest.Wait()
611- root.next(t) // both are buffered now690+ require.Equal(t, 1, r.events.Watchers())
612-691+ r.push(n, 1)
613- l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })692+ r.push(n, 2)
614- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}693+ root.next(t)
615- root.next(t)694+ root.next(t) // both are buffered now
616- r.push(n, 3) // public era695+
617-696+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic })
618- // An anonymous caller that resumes with an id nobody issued gets what697+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now}
619- // is buffered, filtered: the public update and the public push, not the698+ root.next(t)
620- // pushes of the private era.699+ r.push(n, 3) // public era
621- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})700+
622- assert.True(t, anon.next(t).Gap)701+ // An anonymous caller that resumes with an id nobody issued gets what
623- upd := anon.next(t)702+ // is buffered, filtered: the public update and the public push, not the
624- require.IsType(t, domain.RepoUpdated{}, upd.Event)703+ // pushes of the private era.
625- push := anon.next(t)704+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
626- require.IsType(t, domain.PushEvent{}, push.Event)705+ assert.True(t, anon.next(t).Gap)
627- assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)706+ upd := anon.next(t)
628- anon.quiet(t, r, n)707+ require.IsType(t, domain.RepoUpdated{}, upd.Event)
708+ push := anon.next(t)
709+ require.IsType(t, domain.PushEvent{}, push.Event)
710+ assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New)
711+ anon.quiet(t, r, n)
712+ })
629 })713 })
630 714
631 t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) {715 t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) {
632- r := newEventRig(t, 0)716+ synctest.Test(t, func(t *testing.T) {
633- first := now.Add(-time.Hour)717+ ctx, cancel := context.WithCancel(context.Background())
634- l, n := r.liveRepo("again", domain.VisibilityPublic, first)718+ defer cancel()
635- root := r.watch(ctx, domain.Root(), app.WatchParams{})719+ r := newEventRig(t, 0)
636- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)720+ first := now.Add(-time.Hour)
637- r.push(n, 1)721+ l, n := r.liveRepo("again", domain.VisibilityPublic, first)
638- root.next(t)722+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
639-723+ synctest.Wait()
640- l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again724+ require.Equal(t, 1, r.events.Watchers())
641- r.push(n, 2)725+ r.push(n, 1)
642- root.next(t)726+ root.next(t)
643-727+
644- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})728+ l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again
645- assert.True(t, anon.next(t).Gap)729+ r.push(n, 2)
646- e := anon.next(t)730+ root.next(t)
647- require.IsType(t, domain.PushEvent{}, e.Event)731+
648- assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")732+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
649- anon.quiet(t, r, n)733+ assert.True(t, anon.next(t).Gap)
734+ e := anon.next(t)
735+ require.IsType(t, domain.PushEvent{}, e.Event)
736+ assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository")
737+ anon.quiet(t, r, n)
738+ })
650 })739 })
651 740
652 t.Run("a repository made private tells those who could read it, once", func(t *testing.T) {741 t.Run("a repository made private tells those who could read it, once", func(t *testing.T) {
653- r := newEventRig(t, 0)742+ synctest.Test(t, func(t *testing.T) {
654- l, n := r.liveRepo("hide", domain.VisibilityPublic, now)743+ ctx, cancel := context.WithCancel(context.Background())
655- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})744+ defer cancel()
656- reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{})745+ r := newEventRig(t, 0)
657- other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{})746+ l, n := r.liveRepo("hide", domain.VisibilityPublic, now)
658- root := r.watch(ctx, domain.Root(), app.WatchParams{})747+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
659- require.Eventually(t, func() bool { return r.events.Watchers() >= 4 }, 5*time.Second, time.Millisecond)748+ reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{})
660-749+ other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{})
661- l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })750+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
662- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}751+ synctest.Wait()
663-752+ require.Equal(t, 4, r.events.Watchers())
664- for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {753+
665- e := w.next(t)754+ l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate })
666- up, ok := e.Event.(domain.RepoUpdated)755+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now}
667- require.True(t, ok, name)756+
668- assert.Equal(t, n, up.Repo, name)757+ for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} {
669- require.NotNil(t, e.Repo, name)758+ e := w.next(t)
670- assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)759+ up, ok := e.Event.(domain.RepoUpdated)
671- assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)760+ require.True(t, ok, name)
672- }761+ assert.Equal(t, n, up.Repo, name)
762+ require.NotNil(t, e.Repo, name)
763+ assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name)
764+ assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name)
765+ }
673 766
674- // Later events of the repository reach those who may read it now, only.767+ // Later events of the repository reach those who may read it now, only.
675- r.push(n, 1)768+ r.push(n, 1)
676- assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)769+ assert.IsType(t, domain.PushEvent{}, reader.next(t).Event)
677- assert.IsType(t, domain.PushEvent{}, root.next(t).Event)770+ assert.IsType(t, domain.PushEvent{}, root.next(t).Event)
678- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))771+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
679- other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))772+ other.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
773+ })
680 })774 })
681 775
682 t.Run("a private repository that never was public stays hidden", func(t *testing.T) {776 t.Run("a private repository that never was public stays hidden", func(t *testing.T) {
683- r := newEventRig(t, 0)777+ synctest.Test(t, func(t *testing.T) {
684- _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)778+ ctx, cancel := context.WithCancel(context.Background())
685- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})779+ defer cancel()
686- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)780+ r := newEventRig(t, 0)
687- r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}781+ _, n := r.liveRepo("closed", domain.VisibilityPrivate, now)
688- r.push(n, 1)782+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
689- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))783+ synctest.Wait()
784+ require.Equal(t, 1, r.events.Watchers())
785+ r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now}
786+ r.push(n, 1)
787+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
788+ })
690 })789 })
691 }790 }
692 791
693 // TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was792 // TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was
694 // when published, not as the store says when the feeder gets to it.793 // when published, not as the store says when the feeder gets to it.
695 func TestPushEventsKeepTheirSnapshot(t *testing.T) {794 func TestPushEventsKeepTheirSnapshot(t *testing.T) {
696- ctx, cancel := context.WithCancel(context.Background())
697- defer cancel()
698-
699 tests := []struct {795 tests := []struct {
700 name string796 name string
701 swap func(repo *domain.Repo)797 swap func(repo *domain.Repo)
@@ -709,36 +805,46 @@ func TestPushEventsKeepTheirSnapshot(t *testing.T) {
709 }805 }
710 for _, tt := range tests {806 for _, tt := range tests {
711 t.Run(tt.name, func(t *testing.T) {807 t.Run(tt.name, func(t *testing.T) {
712- r := newEventRig(t, 0)808+ synctest.Test(t, func(t *testing.T) {
713- l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)809+ ctx, cancel := context.WithCancel(context.Background())
714- anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})810+ defer cancel()
715- root := r.watch(ctx, domain.Root(), app.WatchParams{})811+ r := newEventRig(t, 0)
716- require.Eventually(t, func() bool { return r.events.Watchers() >= 2 }, 5*time.Second, time.Millisecond)812+ l, n := r.liveRepo("secret", domain.VisibilityPrivate, now)
717-813+ anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{})
718- // Published while private; the store changes before the feeder814+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
719- // can have looked at it: the event is built, then the state is815+ synctest.Wait()
720- // swapped, and only then is it handed to the feeder.816+ require.Equal(t, 2, r.events.Watchers())
721- ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main")817+
722- l.set(tt.swap)818+ // Published while private; the store changes before the feeder
723- r.feed <- ev819+ // can have looked at it: the event is built, then the state is
724- if tt.rootSees {820+ // swapped, and only then is it handed to the feeder.
725- root.next(t)821+ ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main")
726- }822+ l.set(tt.swap)
727-823+ r.feed <- ev
728- anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))824+ if tt.rootSees {
729-825+ root.next(t)
730- late := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})826+ }
731- assert.True(t, late.next(t).Gap)827+
732- late.quiet(t, r, r.repo("open2", domain.VisibilityPublic))828+ anon.quiet(t, r, r.repo("open", domain.VisibilityPublic))
829+
830+ late := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"})
831+ assert.True(t, late.next(t).Gap)
832+ late.quiet(t, r, r.repo("open2", domain.VisibilityPublic))
833+ })
733 })834 })
734 }835 }
735 836
736 t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) {837 t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) {
737- r := newEventRig(t, 0)838+ synctest.Test(t, func(t *testing.T) {
738- n := r.repo("ghost", domain.VisibilityPublic)839+ ctx, cancel := context.WithCancel(context.Background())
739- root := r.watch(ctx, domain.Root(), app.WatchParams{})840+ defer cancel()
740- require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond)841+ r := newEventRig(t, 0)
741- r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now}842+ n := r.repo("ghost", domain.VisibilityPublic)
742- root.quiet(t, r, n)843+ root := r.watch(ctx, domain.Root(), app.WatchParams{})
844+ synctest.Wait()
845+ require.Equal(t, 1, r.events.Watchers())
846+ r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now}
847+ root.quiet(t, r, n)
848+ })
743 })849 })
744 }850 }
M internal/app/export_test.go +0 -8
@@ -4,13 +4,5 @@ package app
44
55 import "bnema.dev/ermite/internal/domain"
66
7-// Idle reports whether nothing is pending or running: tests wait for it
8-// instead of sleeping.
9-func (m *Maintainer) Idle() bool {
10- m.mu.Lock()
11- defer m.mu.Unlock()
12- return len(m.entries) == 0
13-}
14-
157 // AuthorOf exposes authorOf to the tests.
168 func AuthorOf(p domain.Principal) string { return authorOf(p) }
@@ -4,13 +4,5 @@ package app
4 4
5 import "bnema.dev/ermite/internal/domain"5 import "bnema.dev/ermite/internal/domain"
6 6
7-// Idle reports whether nothing is pending or running: tests wait for it
8-// instead of sleeping.
9-func (m *Maintainer) Idle() bool {
10- m.mu.Lock()
11- defer m.mu.Unlock()
12- return len(m.entries) == 0
13-}
14-
15 // AuthorOf exposes authorOf to the tests.7 // AuthorOf exposes authorOf to the tests.
16 func AuthorOf(p domain.Principal) string { return authorOf(p) }8 func AuthorOf(p domain.Principal) string { return authorOf(p) }
M internal/app/maintenance_test.go +157 -136
@@ -9,126 +9,147 @@ import (
99 "sync"
1010 "sync/atomic"
1111 "testing"
12+ "testing/synctest"
1213 "time"
1314
1415 "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
1517
1618 "bnema.dev/ermite/internal/app"
1719 "bnema.dev/ermite/internal/domain"
1820 "bnema.dev/ermite/internal/ports/mocks"
1921 )
2022
21-func waitFor(t *testing.T, cond func() bool, what string) {
22- t.Helper()
23- deadline := time.Now().Add(5 * time.Second)
24- for !cond() {
25- if time.Now().After(deadline) {
26- t.Fatalf("timeout waiting for %s", what)
27- }
28- time.Sleep(2 * time.Millisecond)
29- }
30-}
23+// Every test runs in a synctest bubble with a fake clock: the delays are the
24+// real ones, a run happens exactly when the clock reaches it, and a Close that
25+// hangs is reported as a bubble deadlock instead of a timeout.
3126
3227 func TestMaintainerDebounces(t *testing.T) {
33- ctx := context.Background()
34- n := name(t, "tools")
35- git := mocks.NewGitRepos(t)
36- var runs atomic.Int32
37- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
38- runs.Add(1)
39- return nil
40- })
41- m := app.NewMaintainer(ctx, git, 200*time.Millisecond)
42- defer m.Close(ctx)
28+ synctest.Test(t, func(t *testing.T) {
29+ ctx := context.Background()
30+ n := name(t, "tools")
31+ git := mocks.NewGitRepos(t)
32+ var runs atomic.Int32
33+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
34+ runs.Add(1)
35+ return nil
36+ })
37+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
38+ defer m.Close(ctx)
39+
40+ for range 10 {
41+ m.Schedule(ctx, n)
42+ }
43+ synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
44+ assert.Equal(t, int32(0), runs.Load(), "waits for the full delay")
45+ synctest.Sleep(time.Nanosecond)
46+ assert.Equal(t, int32(1), runs.Load(), "a burst makes one run")
47+ synctest.Sleep(time.Hour)
48+ assert.Equal(t, int32(1), runs.Load(), "no second run is coming")
4349
44- for range 10 {
4550 m.Schedule(ctx, n)
46- }
47- assert.Equal(t, int32(0), runs.Load(), "waits for the delay")
48- waitFor(t, func() bool { return runs.Load() == 1 }, "one run")
49- // Idle: nothing is pending or running, so no second run is coming.
50- waitFor(t, m.Idle, "the maintainer to be idle")
51- assert.Equal(t, int32(1), runs.Load(), "a burst makes one run")
52-
53- m.Schedule(ctx, n)
54- waitFor(t, func() bool { return runs.Load() == 2 }, "a second burst, a second run")
51+ synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
52+ assert.Equal(t, int32(1), runs.Load(), "a second burst waits for its own delay")
53+ synctest.Sleep(time.Nanosecond)
54+ assert.Equal(t, int32(2), runs.Load(), "a second burst, a second run")
55+ })
5556 }
5657
5758 func TestMaintainerOneRunAtATimePerRepo(t *testing.T) {
58- ctx := context.Background()
59- n, other := name(t, "tools"), name(t, "other")
60- git := mocks.NewGitRepos(t)
61- var concurrent, maxConcurrent, runs atomic.Int32
62- release := make(chan struct{})
63- var otherRan atomic.Bool
64- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
65- c := concurrent.Add(1)
66- for {
67- m := maxConcurrent.Load()
68- if c <= m || maxConcurrent.CompareAndSwap(m, c) {
69- break
59+ synctest.Test(t, func(t *testing.T) {
60+ ctx := context.Background()
61+ n, other := name(t, "tools"), name(t, "other")
62+ git := mocks.NewGitRepos(t)
63+ var concurrent, maxConcurrent, runs atomic.Int32
64+ release := make(chan struct{})
65+ var otherRan atomic.Bool
66+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
67+ c := concurrent.Add(1)
68+ for {
69+ m := maxConcurrent.Load()
70+ if c <= m || maxConcurrent.CompareAndSwap(m, c) {
71+ break
72+ }
7073 }
71- }
72- runs.Add(1)
73- <-release
74- concurrent.Add(-1)
75- return nil
76- })
77- git.EXPECT().Maintain(mockAny, other).RunAndReturn(func(context.Context, domain.RepoName) error {
78- otherRan.Store(true)
79- return nil
74+ runs.Add(1)
75+ <-release
76+ concurrent.Add(-1)
77+ return nil
78+ })
79+ git.EXPECT().Maintain(mockAny, other).RunAndReturn(func(context.Context, domain.RepoName) error {
80+ otherRan.Store(true)
81+ return nil
82+ })
83+ const d = app.MaintenanceDelay
84+ m := app.NewMaintainer(ctx, git, d)
85+ defer m.Close(ctx)
86+
87+ m.Schedule(ctx, n)
88+ synctest.Sleep(d)
89+ require.Equal(t, int32(1), runs.Load(), "first run started")
90+ m.Schedule(ctx, n) // during the run: queues exactly one more
91+ m.Schedule(ctx, n)
92+ m.Schedule(ctx, other)
93+ synctest.Sleep(d)
94+ assert.True(t, otherRan.Load(), "another repository is not blocked")
95+ assert.Equal(t, int32(1), runs.Load(), "the second run waits for the first")
96+
97+ release <- struct{}{} // finish the first run: the queued one is armed
98+ synctest.Sleep(d - time.Nanosecond)
99+ assert.Equal(t, int32(1), runs.Load(), "the queued run waits for its delay")
100+ synctest.Sleep(time.Nanosecond)
101+ assert.Equal(t, int32(2), runs.Load(), "queued run")
102+ release <- struct{}{}
103+ synctest.Sleep(time.Hour)
104+ assert.Equal(t, int32(2), runs.Load(), "exactly one queued run")
105+ assert.Equal(t, int32(1), maxConcurrent.Load())
80106 })
81- m := app.NewMaintainer(ctx, git, 10*time.Millisecond)
82- defer m.Close(ctx)
83-
84- m.Schedule(ctx, n)
85- waitFor(t, func() bool { return runs.Load() == 1 }, "first run to start")
86- m.Schedule(ctx, n) // during the run: queues exactly one more
87- m.Schedule(ctx, n)
88- m.Schedule(ctx, other)
89- waitFor(t, otherRan.Load, "another repository is not blocked")
90- assert.Equal(t, int32(1), runs.Load(), "the second run waits for the first")
91-
92- release <- struct{}{} // finish first run
93- waitFor(t, func() bool { return runs.Load() == 2 }, "queued run")
94- release <- struct{}{}
95- waitFor(t, m.Idle, "the maintainer to be idle")
96- assert.Equal(t, int32(2), runs.Load(), "exactly one queued run")
97- assert.Equal(t, int32(1), maxConcurrent.Load())
98107 }
99108
100109 func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
101- ctx := context.Background()
102- n := name(t, "tools")
103- for _, failure := range []error{nil, errors.New("boom")} {
104- git := mocks.NewGitRepos(t)
105- git.EXPECT().Maintain(mockAny, n).Return(failure)
106- told := make(chan struct{}, 2)
107- fresh := mocks.NewRepoFreshener(t)
108- fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()
109- m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh)
110- m.Schedule(ctx, n)
111- waitFor(t, func() bool { return len(told) == 1 }, "the reader to be told")
112- m.Close(ctx)
113- assert.Len(t, told, 1, "told exactly once")
110+ for label, failure := range map[string]error{"success": nil, "failure": errors.New("boom")} {
111+ t.Run(label, func(t *testing.T) {
112+ synctest.Test(t, func(t *testing.T) {
113+ ctx := context.Background()
114+ n := name(t, "tools")
115+ git := mocks.NewGitRepos(t)
116+ git.EXPECT().Maintain(mockAny, n).Return(failure)
117+ told := make(chan struct{}, 2)
118+ fresh := mocks.NewRepoFreshener(t)
119+ fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()
120+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay).WithFreshener(fresh)
121+ m.Schedule(ctx, n)
122+ synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
123+ assert.Empty(t, told, "not before the run")
124+ synctest.Sleep(time.Nanosecond)
125+ assert.Len(t, told, 1, "the reader is told when the run ends")
126+ m.Close(ctx)
127+ synctest.Sleep(time.Hour)
128+ assert.Len(t, told, 1, "told exactly once")
129+ })
130+ })
114131 }
115132 }
116133
117134 func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {
118- ctx := context.Background()
119- n := name(t, "tools")
120- git := mocks.NewGitRepos(t)
121- var runs atomic.Int32
122- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
123- runs.Add(1)
124- return errors.New("boom")
135+ synctest.Test(t, func(t *testing.T) {
136+ ctx := context.Background()
137+ n := name(t, "tools")
138+ git := mocks.NewGitRepos(t)
139+ var runs atomic.Int32
140+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
141+ runs.Add(1)
142+ return errors.New("boom")
143+ })
144+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
145+ defer m.Close(ctx)
146+ m.Schedule(ctx, n)
147+ synctest.Sleep(app.MaintenanceDelay)
148+ assert.Equal(t, int32(1), runs.Load())
149+ m.Schedule(ctx, n)
150+ synctest.Sleep(app.MaintenanceDelay)
151+ assert.Equal(t, int32(2), runs.Load(), "later runs still happen")
125152 })
126- m := app.NewMaintainer(ctx, git, 5*time.Millisecond)
127- defer m.Close(ctx)
128- m.Schedule(ctx, n)
129- waitFor(t, func() bool { return runs.Load() == 1 }, "run")
130- m.Schedule(ctx, n)
131- waitFor(t, func() bool { return runs.Load() == 2 }, "later runs still happen")
132153 }
133154
134155 func TestMaintainerClose(t *testing.T) {
@@ -136,54 +157,54 @@ func TestMaintainerClose(t *testing.T) {
136157 n := name(t, "tools")
137158
138159 t.Run("pending runs are dropped", func(t *testing.T) {
139- git := mocks.NewGitRepos(t) // no Maintain expected
140- m := app.NewMaintainer(ctx, git, time.Hour)
141- m.Schedule(ctx, n)
142- m.Close(ctx)
143- m.Schedule(ctx, n) // after Close: ignored
144- m.Close(ctx) // twice is fine
160+ synctest.Test(t, func(t *testing.T) {
161+ git := mocks.NewGitRepos(t) // no Maintain expected
162+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
163+ m.Schedule(ctx, n)
164+ m.Close(ctx)
165+ m.Schedule(ctx, n) // after Close: ignored
166+ m.Close(ctx) // twice is fine
167+ synctest.Sleep(time.Hour)
168+ })
145169 })
146170
147171 t.Run("a run in progress is cancelled and awaited", func(t *testing.T) {
148- git := mocks.NewGitRepos(t)
149- started := make(chan struct{})
150- var cancelled atomic.Bool
151- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(ctx context.Context, _ domain.RepoName) error {
152- close(started)
153- <-ctx.Done()
154- cancelled.Store(true)
155- return ctx.Err()
172+ synctest.Test(t, func(t *testing.T) {
173+ git := mocks.NewGitRepos(t)
174+ var cancelled atomic.Bool
175+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(ctx context.Context, _ domain.RepoName) error {
176+ <-ctx.Done()
177+ cancelled.Store(true)
178+ return ctx.Err()
179+ })
180+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
181+ m.Schedule(ctx, n)
182+ synctest.Sleep(app.MaintenanceDelay) // the run is blocked in Maintain
183+ assert.False(t, cancelled.Load())
184+ m.Close(ctx) // a hang is a bubble deadlock: reported, no timeout needed
185+ assert.True(t, cancelled.Load())
156186 })
157- m := app.NewMaintainer(ctx, git, time.Millisecond)
158- m.Schedule(ctx, n)
159- <-started
160- done := make(chan struct{})
161- go func() { m.Close(ctx); close(done) }()
162- select {
163- case <-done:
164- case <-time.After(5 * time.Second):
165- t.Fatal("Close did not return")
166- }
167- assert.True(t, cancelled.Load())
168187 })
169188 }
170189
171190 func TestMaintainerConcurrentSchedule(t *testing.T) {
172- ctx := context.Background()
173- git := mocks.NewGitRepos(t)
174- git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()
175- m := app.NewMaintainer(ctx, git, time.Millisecond)
176- var wg sync.WaitGroup
177- for i := range 8 {
178- wg.Add(1)
179- go func() {
180- defer wg.Done()
181- for range 50 {
182- m.Schedule(ctx, name(t, string(rune('a'+i%3))))
183- runtime.Gosched()
184- }
185- }()
186- }
187- wg.Wait()
188- m.Close(ctx)
191+ synctest.Test(t, func(t *testing.T) {
192+ ctx := context.Background()
193+ git := mocks.NewGitRepos(t)
194+ git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()
195+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
196+ var wg sync.WaitGroup
197+ for i := range 8 {
198+ wg.Add(1)
199+ go func() {
200+ defer wg.Done()
201+ for range 50 {
202+ m.Schedule(ctx, name(t, string(rune('a'+i%3))))
203+ runtime.Gosched()
204+ }
205+ }()
206+ }
207+ wg.Wait()
208+ m.Close(ctx)
209+ })
189210 }
@@ -9,126 +9,147 @@ import (
9 "sync"9 "sync"
10 "sync/atomic"10 "sync/atomic"
11 "testing"11 "testing"
12+ "testing/synctest"
12 "time"13 "time"
13 14
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
15 17
16 "bnema.dev/ermite/internal/app"18 "bnema.dev/ermite/internal/app"
17 "bnema.dev/ermite/internal/domain"19 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports/mocks"20 "bnema.dev/ermite/internal/ports/mocks"
19 )21 )
20 22
21-func waitFor(t *testing.T, cond func() bool, what string) {23+// Every test runs in a synctest bubble with a fake clock: the delays are the
22- t.Helper()24+// real ones, a run happens exactly when the clock reaches it, and a Close that
23- deadline := time.Now().Add(5 * time.Second)25+// hangs is reported as a bubble deadlock instead of a timeout.
24- for !cond() {
25- if time.Now().After(deadline) {
26- t.Fatalf("timeout waiting for %s", what)
27- }
28- time.Sleep(2 * time.Millisecond)
29- }
30-}
31 26
32 func TestMaintainerDebounces(t *testing.T) {27 func TestMaintainerDebounces(t *testing.T) {
33- ctx := context.Background()28+ synctest.Test(t, func(t *testing.T) {
34- n := name(t, "tools")29+ ctx := context.Background()
35- git := mocks.NewGitRepos(t)30+ n := name(t, "tools")
36- var runs atomic.Int3231+ git := mocks.NewGitRepos(t)
37- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {32+ var runs atomic.Int32
38- runs.Add(1)33+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
39- return nil34+ runs.Add(1)
40- })35+ return nil
41- m := app.NewMaintainer(ctx, git, 200*time.Millisecond)36+ })
42- defer m.Close(ctx)37+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
38+ defer m.Close(ctx)
39+
40+ for range 10 {
41+ m.Schedule(ctx, n)
42+ }
43+ synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
44+ assert.Equal(t, int32(0), runs.Load(), "waits for the full delay")
45+ synctest.Sleep(time.Nanosecond)
46+ assert.Equal(t, int32(1), runs.Load(), "a burst makes one run")
47+ synctest.Sleep(time.Hour)
48+ assert.Equal(t, int32(1), runs.Load(), "no second run is coming")
43 49
44- for range 10 {
45 m.Schedule(ctx, n)50 m.Schedule(ctx, n)
46- }51+ synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
47- assert.Equal(t, int32(0), runs.Load(), "waits for the delay")52+ assert.Equal(t, int32(1), runs.Load(), "a second burst waits for its own delay")
48- waitFor(t, func() bool { return runs.Load() == 1 }, "one run")53+ synctest.Sleep(time.Nanosecond)
49- // Idle: nothing is pending or running, so no second run is coming.54+ assert.Equal(t, int32(2), runs.Load(), "a second burst, a second run")
50- waitFor(t, m.Idle, "the maintainer to be idle")55+ })
51- assert.Equal(t, int32(1), runs.Load(), "a burst makes one run")
52-
53- m.Schedule(ctx, n)
54- waitFor(t, func() bool { return runs.Load() == 2 }, "a second burst, a second run")
55 }56 }
56 57
57 func TestMaintainerOneRunAtATimePerRepo(t *testing.T) {58 func TestMaintainerOneRunAtATimePerRepo(t *testing.T) {
58- ctx := context.Background()59+ synctest.Test(t, func(t *testing.T) {
59- n, other := name(t, "tools"), name(t, "other")60+ ctx := context.Background()
60- git := mocks.NewGitRepos(t)61+ n, other := name(t, "tools"), name(t, "other")
61- var concurrent, maxConcurrent, runs atomic.Int3262+ git := mocks.NewGitRepos(t)
62- release := make(chan struct{})63+ var concurrent, maxConcurrent, runs atomic.Int32
63- var otherRan atomic.Bool64+ release := make(chan struct{})
64- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {65+ var otherRan atomic.Bool
65- c := concurrent.Add(1)66+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
66- for {67+ c := concurrent.Add(1)
67- m := maxConcurrent.Load()68+ for {
68- if c <= m || maxConcurrent.CompareAndSwap(m, c) {69+ m := maxConcurrent.Load()
69- break70+ if c <= m || maxConcurrent.CompareAndSwap(m, c) {
71+ break
72+ }
70 }73 }
71- }74+ runs.Add(1)
72- runs.Add(1)75+ <-release
73- <-release76+ concurrent.Add(-1)
74- concurrent.Add(-1)77+ return nil
75- return nil78+ })
76- })79+ git.EXPECT().Maintain(mockAny, other).RunAndReturn(func(context.Context, domain.RepoName) error {
77- git.EXPECT().Maintain(mockAny, other).RunAndReturn(func(context.Context, domain.RepoName) error {80+ otherRan.Store(true)
78- otherRan.Store(true)81+ return nil
79- return nil82+ })
83+ const d = app.MaintenanceDelay
84+ m := app.NewMaintainer(ctx, git, d)
85+ defer m.Close(ctx)
86+
87+ m.Schedule(ctx, n)
88+ synctest.Sleep(d)
89+ require.Equal(t, int32(1), runs.Load(), "first run started")
90+ m.Schedule(ctx, n) // during the run: queues exactly one more
91+ m.Schedule(ctx, n)
92+ m.Schedule(ctx, other)
93+ synctest.Sleep(d)
94+ assert.True(t, otherRan.Load(), "another repository is not blocked")
95+ assert.Equal(t, int32(1), runs.Load(), "the second run waits for the first")
96+
97+ release <- struct{}{} // finish the first run: the queued one is armed
98+ synctest.Sleep(d - time.Nanosecond)
99+ assert.Equal(t, int32(1), runs.Load(), "the queued run waits for its delay")
100+ synctest.Sleep(time.Nanosecond)
101+ assert.Equal(t, int32(2), runs.Load(), "queued run")
102+ release <- struct{}{}
103+ synctest.Sleep(time.Hour)
104+ assert.Equal(t, int32(2), runs.Load(), "exactly one queued run")
105+ assert.Equal(t, int32(1), maxConcurrent.Load())
80 })106 })
81- m := app.NewMaintainer(ctx, git, 10*time.Millisecond)
82- defer m.Close(ctx)
83-
84- m.Schedule(ctx, n)
85- waitFor(t, func() bool { return runs.Load() == 1 }, "first run to start")
86- m.Schedule(ctx, n) // during the run: queues exactly one more
87- m.Schedule(ctx, n)
88- m.Schedule(ctx, other)
89- waitFor(t, otherRan.Load, "another repository is not blocked")
90- assert.Equal(t, int32(1), runs.Load(), "the second run waits for the first")
91-
92- release <- struct{}{} // finish first run
93- waitFor(t, func() bool { return runs.Load() == 2 }, "queued run")
94- release <- struct{}{}
95- waitFor(t, m.Idle, "the maintainer to be idle")
96- assert.Equal(t, int32(2), runs.Load(), "exactly one queued run")
97- assert.Equal(t, int32(1), maxConcurrent.Load())
98 }107 }
99 108
100 func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {109 func TestMaintainerTellsTheReaderAfterARun(t *testing.T) {
101- ctx := context.Background()110+ for label, failure := range map[string]error{"success": nil, "failure": errors.New("boom")} {
102- n := name(t, "tools")111+ t.Run(label, func(t *testing.T) {
103- for _, failure := range []error{nil, errors.New("boom")} {112+ synctest.Test(t, func(t *testing.T) {
104- git := mocks.NewGitRepos(t)113+ ctx := context.Background()
105- git.EXPECT().Maintain(mockAny, n).Return(failure)114+ n := name(t, "tools")
106- told := make(chan struct{}, 2)115+ git := mocks.NewGitRepos(t)
107- fresh := mocks.NewRepoFreshener(t)116+ git.EXPECT().Maintain(mockAny, n).Return(failure)
108- fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()117+ told := make(chan struct{}, 2)
109- m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh)118+ fresh := mocks.NewRepoFreshener(t)
110- m.Schedule(ctx, n)119+ fresh.EXPECT().Refresh(mockAny, n).Run(func(context.Context, domain.RepoName) { told <- struct{}{} }).Return().Once()
111- waitFor(t, func() bool { return len(told) == 1 }, "the reader to be told")120+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay).WithFreshener(fresh)
112- m.Close(ctx)121+ m.Schedule(ctx, n)
113- assert.Len(t, told, 1, "told exactly once")122+ synctest.Sleep(app.MaintenanceDelay - time.Nanosecond)
123+ assert.Empty(t, told, "not before the run")
124+ synctest.Sleep(time.Nanosecond)
125+ assert.Len(t, told, 1, "the reader is told when the run ends")
126+ m.Close(ctx)
127+ synctest.Sleep(time.Hour)
128+ assert.Len(t, told, 1, "told exactly once")
129+ })
130+ })
114 }131 }
115 }132 }
116 133
117 func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {134 func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {
118- ctx := context.Background()135+ synctest.Test(t, func(t *testing.T) {
119- n := name(t, "tools")136+ ctx := context.Background()
120- git := mocks.NewGitRepos(t)137+ n := name(t, "tools")
121- var runs atomic.Int32138+ git := mocks.NewGitRepos(t)
122- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {139+ var runs atomic.Int32
123- runs.Add(1)140+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
124- return errors.New("boom")141+ runs.Add(1)
142+ return errors.New("boom")
143+ })
144+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
145+ defer m.Close(ctx)
146+ m.Schedule(ctx, n)
147+ synctest.Sleep(app.MaintenanceDelay)
148+ assert.Equal(t, int32(1), runs.Load())
149+ m.Schedule(ctx, n)
150+ synctest.Sleep(app.MaintenanceDelay)
151+ assert.Equal(t, int32(2), runs.Load(), "later runs still happen")
125 })152 })
126- m := app.NewMaintainer(ctx, git, 5*time.Millisecond)
127- defer m.Close(ctx)
128- m.Schedule(ctx, n)
129- waitFor(t, func() bool { return runs.Load() == 1 }, "run")
130- m.Schedule(ctx, n)
131- waitFor(t, func() bool { return runs.Load() == 2 }, "later runs still happen")
132 }153 }
133 154
134 func TestMaintainerClose(t *testing.T) {155 func TestMaintainerClose(t *testing.T) {
@@ -136,54 +157,54 @@ func TestMaintainerClose(t *testing.T) {
136 n := name(t, "tools")157 n := name(t, "tools")
137 158
138 t.Run("pending runs are dropped", func(t *testing.T) {159 t.Run("pending runs are dropped", func(t *testing.T) {
139- git := mocks.NewGitRepos(t) // no Maintain expected160+ synctest.Test(t, func(t *testing.T) {
140- m := app.NewMaintainer(ctx, git, time.Hour)161+ git := mocks.NewGitRepos(t) // no Maintain expected
141- m.Schedule(ctx, n)162+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
142- m.Close(ctx)163+ m.Schedule(ctx, n)
143- m.Schedule(ctx, n) // after Close: ignored164+ m.Close(ctx)
144- m.Close(ctx) // twice is fine165+ m.Schedule(ctx, n) // after Close: ignored
166+ m.Close(ctx) // twice is fine
167+ synctest.Sleep(time.Hour)
168+ })
145 })169 })
146 170
147 t.Run("a run in progress is cancelled and awaited", func(t *testing.T) {171 t.Run("a run in progress is cancelled and awaited", func(t *testing.T) {
148- git := mocks.NewGitRepos(t)172+ synctest.Test(t, func(t *testing.T) {
149- started := make(chan struct{})173+ git := mocks.NewGitRepos(t)
150- var cancelled atomic.Bool174+ var cancelled atomic.Bool
151- git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(ctx context.Context, _ domain.RepoName) error {175+ git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(ctx context.Context, _ domain.RepoName) error {
152- close(started)176+ <-ctx.Done()
153- <-ctx.Done()177+ cancelled.Store(true)
154- cancelled.Store(true)178+ return ctx.Err()
155- return ctx.Err()179+ })
180+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
181+ m.Schedule(ctx, n)
182+ synctest.Sleep(app.MaintenanceDelay) // the run is blocked in Maintain
183+ assert.False(t, cancelled.Load())
184+ m.Close(ctx) // a hang is a bubble deadlock: reported, no timeout needed
185+ assert.True(t, cancelled.Load())
156 })186 })
157- m := app.NewMaintainer(ctx, git, time.Millisecond)
158- m.Schedule(ctx, n)
159- <-started
160- done := make(chan struct{})
161- go func() { m.Close(ctx); close(done) }()
162- select {
163- case <-done:
164- case <-time.After(5 * time.Second):
165- t.Fatal("Close did not return")
166- }
167- assert.True(t, cancelled.Load())
168 })187 })
169 }188 }
170 189
171 func TestMaintainerConcurrentSchedule(t *testing.T) {190 func TestMaintainerConcurrentSchedule(t *testing.T) {
172- ctx := context.Background()191+ synctest.Test(t, func(t *testing.T) {
173- git := mocks.NewGitRepos(t)192+ ctx := context.Background()
174- git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()193+ git := mocks.NewGitRepos(t)
175- m := app.NewMaintainer(ctx, git, time.Millisecond)194+ git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()
176- var wg sync.WaitGroup195+ m := app.NewMaintainer(ctx, git, app.MaintenanceDelay)
177- for i := range 8 {196+ var wg sync.WaitGroup
178- wg.Add(1)197+ for i := range 8 {
179- go func() {198+ wg.Add(1)
180- defer wg.Done()199+ go func() {
181- for range 50 {200+ defer wg.Done()
182- m.Schedule(ctx, name(t, string(rune('a'+i%3))))201+ for range 50 {
183- runtime.Gosched()202+ m.Schedule(ctx, name(t, string(rune('a'+i%3))))
184- }203+ runtime.Gosched()
185- }()204+ }
186- }205+ }()
187- wg.Wait()206+ }
188- m.Close(ctx)207+ wg.Wait()
208+ m.Close(ctx)
209+ })
189 }210 }
M internal/app/mergerequests_integration_test.go +5 -3
@@ -13,7 +13,6 @@ import (
1313 "strings"
1414 "sync"
1515 "testing"
16- "time"
1716
1817 "github.com/stretchr/testify/assert"
1918 "github.com/stretchr/testify/mock"
@@ -202,14 +201,17 @@ func (r *mrRig) open(branch string) domain.MergeRequest {
202201 return mr
203202 }
204203
205-// drain returns the events published so far.
204+// drain returns the events published so far. The use cases publish on the bus
205+// from the calling goroutine, and the bus delivers into the buffer of the
206+// subscriber before Publish returns: whatever a returned call published is
207+// already in the channel.
206208 func (r *mrRig) drain() []domain.Event {
207209 var out []domain.Event
208210 for {
209211 select {
210212 case ev := <-r.events:
211213 out = append(out, ev)
212- case <-time.After(20 * time.Millisecond):
214+ default:
213215 return out
214216 }
215217 }
@@ -13,7 +13,6 @@ import (
13 "strings"13 "strings"
14 "sync"14 "sync"
15 "testing"15 "testing"
16- "time"
17 16
18 "github.com/stretchr/testify/assert"17 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/mock"18 "github.com/stretchr/testify/mock"
@@ -202,14 +201,17 @@ func (r *mrRig) open(branch string) domain.MergeRequest {
202 return mr201 return mr
203 }202 }
204 203
205-// drain returns the events published so far.204+// drain returns the events published so far. The use cases publish on the bus
205+// from the calling goroutine, and the bus delivers into the buffer of the
206+// subscriber before Publish returns: whatever a returned call published is
207+// already in the channel.
206 func (r *mrRig) drain() []domain.Event {208 func (r *mrRig) drain() []domain.Event {
207 var out []domain.Event209 var out []domain.Event
208 for {210 for {
209 select {211 select {
210 case ev := <-r.events:212 case ev := <-r.events:
211 out = append(out, ev)213 out = append(out, ev)
212- case <-time.After(20 * time.Millisecond):214+ default:
213 return out215 return out
214 }216 }
215 }217 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close