detached 59d5a0b

hlcache_test.go RawHistoryFolder

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, "&lt;i&gt;two&lt;/i&gt;")
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}

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