1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18)
19
20// dataOf is colours of n lines, about 8n+64 bytes.
21func dataOf(n int) *hlData {
22 d := &hlData{lineEnd: make([]uint32, n), runs: make([]hlRun, n)}
23 for i := range n {
24 d.runs[i] = hlRun{end: 3, class: 1}
25 d.lineEnd[i] = uint32(i + 1)
26 }
27 return d
28}
29
30func withID(f domain.FileDiff, n int) domain.FileDiff {
31 f.OldID, f.NewID = fakeBlob(n, 0), fakeBlob(n, 1)
32 return f
33}
34
35// A file whose colours are in the cache is coloured with no lexing: with
36// every lexing slot taken, a miss is plain and a hit is not.
37func TestHLCacheHit(t *testing.T) {
38 fd := withID(hunkOf("a.go", " package a", "+var x = 1 // c"), 1)
39 c := newHLCache(1 << 20)
40
41 first := c.files(context.Background(), []domain.FileDiff{fd})
42 assert.Contains(t, rendered(t, first[0])[1], `class="kd"`)
43 assert.Len(t, c.byKey, 1)
44
45 for range cap(lexSlots) {
46 lexSlots <- struct{}{}
47 }
48 defer func() {
49 for range cap(lexSlots) {
50 <-lexSlots
51 }
52 }()
53 hit := c.files(context.Background(), []domain.FileDiff{fd})
54 assert.Equal(t, first, hit, "same page from the cache")
55
56 other := c.files(context.Background(), []domain.FileDiff{withID(fd, 2)})
57 assert.NotContains(t, rendered(t, other[0])[1], `class=`, "another blob: a miss, and no slot to lex")
58 assert.Len(t, c.byKey, 1, "a failed lexing is not kept")
59}
60
61// The cache holds colours, not text: the text of the page is the one of the
62// diff being rendered, escaped as ever.
63func TestHLCacheKeepsNoText(t *testing.T) {
64 c := newHLCache(1 << 20)
65 a := withID(hunkOf("a.go", "+x := \"<b>one</b>\""), 1)
66 b := withID(hunkOf("a.go", "+x := \"<i>two</i>\""), 1) // same key, other text: cannot happen, but must not leak
67 c.files(context.Background(), []domain.FileDiff{a})
68 got := c.files(context.Background(), []domain.FileDiff{b})
69 h := rendered(t, got[0])[0]
70 assert.Contains(t, h, "<i>two</i>")
71 assert.NotContains(t, h, "one")
72}
73
74// The budget is in bytes: the least recently used entries go first, a hit
75// makes an entry young, and an entry over a quarter of the budget is not kept.
76func TestHLCacheEvictsByBytes(t *testing.T) {
77 t.Parallel()
78 one := entrySize("k0", dataOf(100))
79 c := newHLCache(one*4 + one/2) // four entries fit, not five; one is under a quarter
80 ctx := context.Background()
81 add := func(k string) {
82 c.load(ctx, k, func() *hlData { return dataOf(100) })
83 }
84 add("k0")
85 add("k1")
86 add("k2")
87 add("k3")
88 assert.Len(t, c.byKey, 4)
89 c.load(ctx, "k0", func() *hlData { t.Fatal("k0 is cached"); return nil }) // k0 is the youngest now
90 add("k4") // pushes out k1, the oldest
91 assert.Len(t, c.byKey, 4)
92 assert.Contains(t, c.byKey, "k0")
93 assert.NotContains(t, c.byKey, "k1")
94 assert.LessOrEqual(t, c.size, c.budget)
95
96 // the sum of the sizes of the entries is what is counted
97 sum := 0
98 for _, el := range c.byKey {
99 sum += el.Value.(*hlEntry).size
100 }
101 assert.Equal(t, sum, c.size)
102
103 // over a quarter of the budget: served, not kept, nothing pushed out
104 big := 0
105 c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) })
106 c.load(ctx, "big", func() *hlData { big++; return dataOf(1000) })
107 assert.Equal(t, 2, big)
108 assert.NotContains(t, c.byKey, "big")
109 assert.Len(t, c.byKey, 4)
110
111 // many entries: the size never goes over the budget
112 for i := range 200 {
113 add(fmt.Sprintf("n%d", i))
114 require.LessOrEqual(t, c.size, c.budget)
115 }
116}
117
118// A nil cache and an empty key keep nothing; a build that gives nothing is
119// not kept and is tried again.
120func TestHLCacheNotKept(t *testing.T) {
121 t.Parallel()
122 ctx := context.Background()
123 n := 0
124 build := func() *hlData { n++; return nil }
125 var nilCache *hlCache
126 nilCache.load(ctx, "k", build)
127 newHLCache(1<<20).load(ctx, "", build)
128 c := newHLCache(1 << 20)
129 c.load(ctx, "k", build)
130 c.load(ctx, "k", build)
131 assert.Equal(t, 4, n)
132 assert.Empty(t, c.byKey)
133 assert.Empty(t, c.flight)
134}
135
136// N concurrent first requests for one key lex once: the others wait for the
137// first, and all get its data. Run with -race.
138func TestHLCacheDedup(t *testing.T) {
139 t.Parallel()
140 c := newHLCache(1 << 20)
141 const n = 32
142 var builds atomic.Int32
143 release := make(chan struct{})
144 var wg sync.WaitGroup
145 got := make([]*hlData, n)
146 for i := range n {
147 wg.Add(1)
148 go func() {
149 defer wg.Done()
150 got[i] = c.load(context.Background(), "k", func() *hlData {
151 builds.Add(1)
152 <-release // hold the build until every other caller has joined
153 return dataOf(10)
154 })
155 }()
156 }
157 require.Eventually(t, func() bool {
158 c.mu.Lock()
159 defer c.mu.Unlock()
160 f := c.flight["k"]
161 return f != nil && f.waiters == n-1
162 }, 5*time.Second, time.Millisecond)
163 close(release)
164 wg.Wait()
165
166 assert.EqualValues(t, 1, builds.Load())
167 for i := range n {
168 require.NotNil(t, got[i])
169 assert.Same(t, got[0], got[i], "one result for all")
170 }
171 assert.Empty(t, c.flight)
172 assert.Len(t, c.byKey, 1)
173}
174
175// The same through the diff: concurrent pages of one commit lex its file once.
176func TestHLCacheDedupFiles(t *testing.T) {
177 c := newHLCache(1 << 20)
178 var lines []string
179 for range 300 {
180 lines = append(lines, "+var v = 1 // 0123456789")
181 }
182 fd := withID(hunkOf("a.go", lines...), 7)
183 const n = 16
184 var wg sync.WaitGroup
185 pages := make([][]DiffFile, n)
186 for i := range n {
187 wg.Add(1)
188 go func() {
189 defer wg.Done()
190 pages[i] = c.files(context.Background(), []domain.FileDiff{fd})
191 }()
192 }
193 wg.Wait()
194 for i := range n {
195 assert.Equal(t, pages[0], pages[i])
196 assert.Contains(t, rendered(t, pages[i][0])[0], `class="kd"`)
197 }
198 assert.Len(t, c.byKey, 1)
199}
200
201// A waiter that gives up is freed at once and shows the file plain; the
202// build goes on and its result is kept for the next request.
203func TestHLCacheWaiterCancelled(t *testing.T) {
204 t.Parallel()
205 c := newHLCache(1 << 20)
206 release := make(chan struct{})
207 started := make(chan struct{})
208 done := make(chan struct{})
209 go func() {
210 defer close(done)
211 c.load(context.Background(), "k", func() *hlData { close(started); <-release; return dataOf(5) })
212 }()
213 <-started
214 ctx, cancel := context.WithCancel(context.Background())
215 cancel()
216 assert.Nil(t, c.load(ctx, "k", func() *hlData { t.Error("joins the build"); return nil }))
217 close(release)
218 <-done
219 assert.NotNil(t, c.load(context.Background(), "k", func() *hlData { t.Error("kept"); return nil }))
220}
221
222// A build that panics frees the callers that wait and is not kept.
223func TestHLCacheBuildPanics(t *testing.T) {
224 t.Parallel()
225 c := newHLCache(1 << 20)
226 started := make(chan struct{})
227 release := make(chan struct{})
228 res := make(chan *hlData, 1)
229 go func() {
230 defer func() { _ = recover() }()
231 c.load(context.Background(), "k", func() *hlData { close(started); <-release; panic("lexer bug") })
232 }()
233 <-started
234 go func() { res <- c.load(context.Background(), "k", func() *hlData { return dataOf(1) }) }()
235 require.Eventually(t, func() bool {
236 c.mu.Lock()
237 defer c.mu.Unlock()
238 return c.flight["k"] != nil && c.flight["k"].waiters == 1
239 }, 5*time.Second, time.Millisecond)
240 close(release)
241 select {
242 case d := <-res:
243 assert.Nil(t, d)
244 case <-time.After(5 * time.Second):
245 t.Fatal("the waiter is stuck")
246 }
247 assert.Empty(t, c.flight)
248 assert.Empty(t, c.byKey)
249}
250
251// The key tells apart what changes the colours: the blobs, the language, the
252// shape of the hunks.
253func TestDiffKey(t *testing.T) {
254 t.Parallel()
255 f := withID(hunkOf("a.go", " a", "+b"), 1)
256 k := diffKey("Go", f)
257 assert.NotEmpty(t, k)
258 assert.Equal(t, k, diffKey("Go", f))
259 assert.NotEqual(t, k, diffKey("C", f))
260 assert.NotEqual(t, k, diffKey("Go", withID(f, 2)))
261 assert.NotEqual(t, k, diffKey("Go", withID(hunkOf("a.go", " a", "+b", "+c"), 1)))
262 assert.Empty(t, diffKey("Go", hunkOf("a.go", " a")), "no blob ids: not kept")
263 assert.False(t, strings.Contains(k, "a.go"), "the path is not in the key: two paths with the same blobs share")
264}
265
266// The lines /context loads are cached under the blob of the file.
267func TestHLCacheContext(t *testing.T) {
268 c := newHLCache(1 << 20)
269 lines := []string{"package a", "/* open", "still open", "closed */", "var x = 1"}
270 blob := fakeBlob(3, 1).String()
271 ctx := context.Background()
272 first := c.highlightContext(ctx, blob, "a.go", lines, 1, 4)
273 require.Len(t, first, 3)
274 assert.Equal(t, []Seg{{Text: "still open", Class: "cm"}}, first[1])
275 assert.Len(t, c.byKey, 1)
276
277 for range cap(lexSlots) {
278 lexSlots <- struct{}{}
279 }
280 defer func() {
281 for range cap(lexSlots) {
282 <-lexSlots
283 }
284 }()
285 assert.Equal(t, first, c.highlightContext(ctx, blob, "a.go", lines, 1, 4), "from the cache")
286 assert.Equal(t, first[1:], c.highlightContext(ctx, blob, "a.go", lines, 2, 4), "other window, same colours")
287 assert.Nil(t, c.highlightContext(ctx, fakeBlob(4, 1).String(), "a.go", lines, 1, 4), "other blob: no slot, plain")
288}