main 33a243c

cache_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package actionscache
4
5import (
6	"context"
7	"errors"
8	"io"
9	"os"
10	"strings"
11	"sync"
12	"sync/atomic"
13	"testing"
14	"time"
15
16	"github.com/bnema/zerowrap"
17)
18
19func newCache(t *testing.T, max int64) (*Cache, *time.Time) {
20	t.Helper()
21	now := time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)
22	c, err := New(Config{Dir: t.TempDir(), Max: max, Now: func() time.Time { return now }, Log: zerowrap.Logger{}})
23	if err != nil {
24		t.Fatal(err)
25	}
26	return c, &now
27}
28
29func keyOf(t *testing.T, n int) string {
30	t.Helper()
31	k, ok := Key("https://github.com", "o/r"+strings.Repeat("x", n), strings.Repeat("a", 40))
32	if !ok {
33		t.Fatal("key")
34	}
35	return k
36}
37
38func body(s string) Fetch {
39	return func(_ context.Context, w io.Writer) error { _, err := io.WriteString(w, s); return err }
40}
41
42func read(t *testing.T, c *Cache, key string) string {
43	t.Helper()
44	f, err := c.Open(key)
45	if err != nil {
46		t.Fatal(err)
47	}
48	defer func() { _ = f.Close() }()
49	b, _ := io.ReadAll(f)
50	return string(b)
51}
52
53func TestHitMiss(t *testing.T) {
54	c, _ := newCache(t, 1<<20)
55	k := keyOf(t, 0)
56	var fetches int
57	f := func(ctx context.Context, w io.Writer) error { fetches++; return body("archive")(ctx, w) }
58	if hit, _, err := c.Ensure(context.Background(), k, f); err != nil || hit {
59		t.Fatalf("first: hit=%v err=%v", hit, err)
60	}
61	if hit, verify, err := c.Ensure(context.Background(), k, f); err != nil || !hit || verify <= 0 {
62		t.Fatalf("second: hit=%v err=%v", hit, err)
63	}
64	if fetches != 1 || read(t, c, k) != "archive" {
65		t.Fatalf("fetches=%d", fetches)
66	}
67	// A new process finds the entry.
68	c2, err := New(Config{Dir: c.dir, Max: 1 << 20})
69	if err != nil {
70		t.Fatal(err)
71	}
72	if hit, _, _ := c2.Ensure(context.Background(), k, f); !hit || fetches != 1 {
73		t.Fatalf("restart: hit=%v fetches=%d", hit, fetches)
74	}
75}
76
77func TestFailedFetchLeavesNothing(t *testing.T) {
78	c, _ := newCache(t, 1<<20)
79	k := keyOf(t, 0)
80	boom := errors.New("429")
81	_, _, err := c.Ensure(context.Background(), k, func(_ context.Context, w io.Writer) error { _, _ = io.WriteString(w, "part"); return boom })
82	if !errors.Is(err, boom) {
83		t.Fatal(err)
84	}
85	if _, err := c.Open(k); !errors.Is(err, ErrNotFound) {
86		t.Fatal("a failed download must not be an entry")
87	}
88	des, _ := os.ReadDir(c.dir)
89	if len(des) != 0 {
90		t.Fatalf("leftovers: %v", des)
91	}
92}
93
94func TestConcurrentSingleFlight(t *testing.T) {
95	c, _ := newCache(t, 1<<20)
96	k := keyOf(t, 0)
97	var fetches atomic.Int32
98	release := make(chan struct{})
99	f := func(ctx context.Context, w io.Writer) error {
100		fetches.Add(1)
101		<-release
102		return body("once")(ctx, w)
103	}
104	var wg sync.WaitGroup
105	var hits atomic.Int32
106	for range 8 {
107		wg.Add(1)
108		go func() {
109			defer wg.Done()
110			hit, _, err := c.Ensure(context.Background(), k, f)
111			if err != nil {
112				t.Error(err)
113			}
114			if hit {
115				hits.Add(1)
116			}
117		}()
118	}
119	time.Sleep(50 * time.Millisecond)
120	close(release)
121	wg.Wait()
122	if fetches.Load() != 1 {
123		t.Fatalf("%d downloads for one key", fetches.Load())
124	}
125	if hits.Load() != 7 {
126		t.Errorf("%d hits, want 7 (all but the downloader)", hits.Load())
127	}
128}
129
130func TestCorruptEntryIsDroppedAndRefetched(t *testing.T) {
131	c, _ := newCache(t, 1<<20)
132	k := keyOf(t, 0)
133	if _, _, err := c.Ensure(context.Background(), k, body("good bytes")); err != nil {
134		t.Fatal(err)
135	}
136	// Same size, other content: only the hash can tell.
137	if err := os.WriteFile(c.archivePath(k), []byte("evil bytes"), 0o600); err != nil {
138		t.Fatal(err)
139	}
140	if _, err := c.Open(k); !errors.Is(err, ErrNotFound) {
141		t.Fatalf("a corrupt entry was served: %v", err)
142	}
143	if c.Size() != 0 {
144		t.Errorf("size %d after the drop", c.Size())
145	}
146	hit, _, err := c.Ensure(context.Background(), k, body("good bytes"))
147	if err != nil || hit {
148		t.Fatalf("hit=%v err=%v", hit, err)
149	}
150	if read(t, c, k) != "good bytes" {
151		t.Fatal("not refetched")
152	}
153	// Truncation is caught too.
154	if err := os.WriteFile(c.archivePath(k), []byte("good"), 0o600); err != nil {
155		t.Fatal(err)
156	}
157	if _, err := c.Open(k); !errors.Is(err, ErrNotFound) {
158		t.Fatal("a truncated entry was served")
159	}
160}
161
162func TestLRUEviction(t *testing.T) {
163	c, now := newCache(t, 25)
164	ctx := context.Background()
165	step := func() { *now = now.Add(time.Hour + PinFor) } // past every pin
166	a, b, d := keyOf(t, 0), keyOf(t, 1), keyOf(t, 2)
167	for _, k := range []string{a, b} {
168		if _, _, err := c.Ensure(ctx, k, body(strings.Repeat("x", 10))); err != nil {
169			t.Fatal(err)
170		}
171		step()
172	}
173	// Use a again: b is now the least recently used.
174	if hit, _, _ := c.Ensure(ctx, a, body("unused")); !hit {
175		t.Fatal("a should hit")
176	}
177	step()
178	if _, _, err := c.Ensure(ctx, d, body(strings.Repeat("y", 10))); err != nil {
179		t.Fatal(err)
180	}
181	if _, err := c.Open(b); !errors.Is(err, ErrNotFound) {
182		t.Error("the least recently used entry survived")
183	}
184	for _, k := range []string{a, d} {
185		if _, err := c.Open(k); err != nil {
186			t.Errorf("%s evicted: %v", k[:6], err)
187		}
188	}
189	if c.Size() > 25 {
190		t.Errorf("size %d over the cap", c.Size())
191	}
192}
193
194// A pinned entry is not evicted, even to make room: the newcomer is the one
195// that is not cached, so the cap holds.
196func TestPinnedEntryIsNotEvicted(t *testing.T) {
197	c, _ := newCache(t, 15)
198	ctx := context.Background()
199	a, b := keyOf(t, 0), keyOf(t, 1)
200	if _, _, err := c.Ensure(ctx, a, body(strings.Repeat("x", 10))); err != nil {
201		t.Fatal(err)
202	}
203	if _, _, err := c.Ensure(ctx, b, body(strings.Repeat("y", 10))); !errors.Is(err, ErrTooLarge) {
204		t.Fatalf("got %v", err)
205	}
206	if _, err := c.Open(a); err != nil {
207		t.Fatalf("a pinned entry was evicted: %v", err)
208	}
209	if c.Size() > 15 {
210		t.Fatalf("size %d", c.Size())
211	}
212}
213
214// diskUse is the bytes of every file in the cache directory, temporary files
215// included.
216func diskUse(t *testing.T, c *Cache) int64 {
217	t.Helper()
218	var n int64
219	des, _ := os.ReadDir(c.dir)
220	for _, de := range des {
221		if fi, err := de.Info(); err == nil {
222			n += fi.Size()
223		}
224	}
225	return n
226}
227
228// Many downloads in flight at once, each below the cap, are charged as they
229// are written: the directory never holds more than the cap, temporary files
230// included, and the accounting returns to the bytes of the entries.
231func TestConcurrentDownloadsStayUnderTheCap(t *testing.T) {
232	const max = 100
233	c, _ := newCache(t, max)
234	var over atomic.Int32
235	var wg sync.WaitGroup
236	stop := make(chan struct{})
237	go func() {
238		for {
239			select {
240			case <-stop:
241				return
242			default:
243				// The charge covers the entries and the downloads in progress. (The
244				// directory cannot be sampled atomically: a listing taken while an
245				// entry is evicted and a download grows would count both.)
246				c.mu.Lock()
247				n := c.total + c.pending
248				c.mu.Unlock()
249				if n > max {
250					over.Add(1)
251				}
252			}
253		}
254	}()
255	for i := range 16 {
256		wg.Add(1)
257		go func() {
258			defer wg.Done()
259			k := keyOf(t, i)
260			_, _, _ = c.Ensure(context.Background(), k, func(_ context.Context, w io.Writer) error {
261				for range 6 { // 6 x 10 bytes, in pieces
262					if _, err := w.Write([]byte(strings.Repeat("z", 10))); err != nil {
263						return err
264					}
265					time.Sleep(time.Millisecond)
266				}
267				return nil
268			})
269		}()
270	}
271	wg.Wait()
272	close(stop)
273	if over.Load() != 0 {
274		t.Fatal("entries and downloads in flight went over the cap")
275	}
276	var onDisk int64
277	des, _ := os.ReadDir(c.dir)
278	for _, de := range des {
279		if strings.HasPrefix(de.Name(), tmpPrefix) {
280			t.Errorf("temporary file left: %s", de.Name())
281		}
282		if strings.HasSuffix(de.Name(), archiveExt) {
283			fi, _ := de.Info()
284			onDisk += fi.Size()
285		}
286	}
287	if onDisk > max {
288		t.Fatalf("%d bytes on disk", onDisk)
289	}
290	c.mu.Lock()
291	pending, total := c.pending, c.total
292	c.mu.Unlock()
293	if pending != 0 || total > max {
294		t.Fatalf("pending=%d total=%d", pending, total)
295	}
296	var sum int64
297	for _, e := range c.entries {
298		sum += e.size
299	}
300	if sum != total {
301		t.Fatalf("entries hold %d, accounted %d", sum, total)
302	}
303}
304
305// The bytes of a download in progress are counted, and a failed or refused one
306// leaves neither a file nor a charge.
307func TestTemporaryFilesAreCountedAndCleaned(t *testing.T) {
308	c, _ := newCache(t, 50)
309	seen := int64(-1)
310	_, _, err := c.Ensure(context.Background(), keyOf(t, 0), func(_ context.Context, w io.Writer) error {
311		_, _ = io.WriteString(w, strings.Repeat("a", 30))
312		c.mu.Lock()
313		seen = c.pending
314		c.mu.Unlock()
315		return errors.New("connection reset")
316	})
317	if err == nil || seen != 30 {
318		t.Fatalf("err=%v pending during=%d", err, seen)
319	}
320	// Refused midway for size: the part already written is released too.
321	_, _, err = c.Ensure(context.Background(), keyOf(t, 1), body(strings.Repeat("b", 60)))
322	if !errors.Is(err, ErrTooLarge) {
323		t.Fatalf("got %v", err)
324	}
325	if c.pending != 0 || c.Size() != 0 || diskUse(t, c) != 0 {
326		t.Fatalf("pending=%d size=%d disk=%d", c.pending, c.Size(), diskUse(t, c))
327	}
328	// The size is what was written, whatever the server announced.
329	if _, _, err := c.Ensure(context.Background(), keyOf(t, 2), body(strings.Repeat("c", 50))); err != nil {
330		t.Fatal(err)
331	}
332	if c.Size() != 50 {
333		t.Fatalf("size %d", c.Size())
334	}
335}
336
337func TestTooLarge(t *testing.T) {
338	c, _ := newCache(t, 10)
339	_, _, err := c.Ensure(context.Background(), keyOf(t, 0), body(strings.Repeat("x", 11)))
340	if !errors.Is(err, ErrTooLarge) {
341		t.Fatalf("got %v", err)
342	}
343	if c.Size() != 0 || diskUse(t, c) != 0 {
344		t.Fatalf("an oversize archive left %d bytes on disk", diskUse(t, c))
345	}
346}
347
348func TestStartDropsHalfWrittenFiles(t *testing.T) {
349	c, _ := newCache(t, 100)
350	k := keyOf(t, 0)
351	_ = os.WriteFile(c.archivePath(k), []byte("no sum"), 0o600)
352	_ = os.WriteFile(c.dir+"/tmp-123", []byte("x"), 0o600)
353	_ = os.WriteFile(c.sumPath(keyOf(t, 1)), []byte("sum without archive"), 0o600)
354	if _, err := New(Config{Dir: c.dir, Max: 100}); err != nil {
355		t.Fatal(err)
356	}
357	if des, _ := os.ReadDir(c.dir); len(des) != 0 {
358		t.Fatalf("leftovers: %v", des)
359	}
360}
361
362// The key is made of the forge, the repository and the commit, never a ref: a
363// tag or a branch name is not a sha and is refused, and two commits or two
364// forges never share an entry.
365func TestKeyNeverUsesARef(t *testing.T) {
366	sha := strings.Repeat("a", 40)
367	for _, bad := range []string{"", "v4", "main", "refs/tags/v4", strings.Repeat("g", 40), strings.Repeat("a", 39), strings.Repeat("a", 41)} {
368		if _, ok := Key("https://github.com", "o/r", bad); ok {
369			t.Errorf("%q made a key", bad)
370		}
371	}
372	k1, _ := Key("https://github.com", "o/r", sha)
373	k2, _ := Key("https://github.com", "o/r", strings.Repeat("b", 40))
374	k3, _ := Key("https://ermite.example", "o/r", sha)
375	k4, _ := Key("https://github.com", "O/R", strings.ToUpper(sha))
376	if k1 == k2 || k1 == k3 {
377		t.Error("collision")
378	}
379	if k1 != k4 {
380		t.Error("the case of the repository or the sha must not split an entry")
381	}
382	if !ValidKey(k1) {
383		t.Error("shape")
384	}
385}

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 4

Tags 11

↑↓ moveenter openesc close