1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6 "context"
7 "errors"
8 "runtime"
9 "sync"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15
16 "bnema.dev/ermite/internal/app"
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports/mocks"
19)
20
21func 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}
31
32func 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)
43
44 for range 10 {
45 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")
55}
56
57func 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
70 }
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
80 })
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}
99
100func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) {
101 ctx := context.Background()
102 n := name(t, "tools")
103 git := mocks.NewGitRepos(t)
104 var runs atomic.Int32
105 git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error {
106 runs.Add(1)
107 return errors.New("boom")
108 })
109 m := app.NewMaintainer(ctx, git, 5*time.Millisecond)
110 defer m.Close(ctx)
111 m.Schedule(ctx, n)
112 waitFor(t, func() bool { return runs.Load() == 1 }, "run")
113 m.Schedule(ctx, n)
114 waitFor(t, func() bool { return runs.Load() == 2 }, "later runs still happen")
115}
116
117func TestMaintainerClose(t *testing.T) {
118 ctx := context.Background()
119 n := name(t, "tools")
120
121 t.Run("pending runs are dropped", func(t *testing.T) {
122 git := mocks.NewGitRepos(t) // no Maintain expected
123 m := app.NewMaintainer(ctx, git, time.Hour)
124 m.Schedule(ctx, n)
125 m.Close(ctx)
126 m.Schedule(ctx, n) // after Close: ignored
127 m.Close(ctx) // twice is fine
128 })
129
130 t.Run("a run in progress is cancelled and awaited", func(t *testing.T) {
131 git := mocks.NewGitRepos(t)
132 started := make(chan struct{})
133 var cancelled atomic.Bool
134 git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(ctx context.Context, _ domain.RepoName) error {
135 close(started)
136 <-ctx.Done()
137 cancelled.Store(true)
138 return ctx.Err()
139 })
140 m := app.NewMaintainer(ctx, git, time.Millisecond)
141 m.Schedule(ctx, n)
142 <-started
143 done := make(chan struct{})
144 go func() { m.Close(ctx); close(done) }()
145 select {
146 case <-done:
147 case <-time.After(5 * time.Second):
148 t.Fatal("Close did not return")
149 }
150 assert.True(t, cancelled.Load())
151 })
152}
153
154func TestMaintainerConcurrentSchedule(t *testing.T) {
155 ctx := context.Background()
156 git := mocks.NewGitRepos(t)
157 git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe()
158 m := app.NewMaintainer(ctx, git, time.Millisecond)
159 var wg sync.WaitGroup
160 for i := range 8 {
161 wg.Add(1)
162 go func() {
163 defer wg.Done()
164 for range 50 {
165 m.Schedule(ctx, name(t, string(rune('a'+i%3))))
166 runtime.Gosched()
167 }
168 }()
169 }
170 wg.Wait()
171 m.Close(ctx)
172}