main f2d166b

cursor_images_test.go RawHistoryFolder

1package drm
2
3import (
4	"bytes"
5	"errors"
6	"os"
7	"testing"
8
9	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
10	"github.com/bnema/neferwl/internal/ports"
11	"github.com/stretchr/testify/mock"
12	"golang.org/x/sys/unix"
13)
14
15const tSide = 16
16
17// dumbOK expects two dumb buffers with handles 10 and 11, mappings (with
18// pitch bytes per row) returned in mem, and framebuffers 90 and 91.
19func dumbOK(k *mockkms, pitch int) (mem [2][]byte) {
20	for i := range mem {
21		mem[i] = make([]byte, pitch*tSide)
22		h := uint32(10 + i)
23		b := dumbBuffer{handle: h, pitch: uint32(pitch), size: uint64(len(mem[i]))}
24		k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
25		k.EXPECT().mapDumb(b).Return(mem[i], nil).Once()
26		k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90+uint32(i), nil).Once()
27	}
28	return mem
29}
30
31func dumbFree(k *mockkms) {
32	for i := range 2 {
33		k.EXPECT().rmFB(90 + uint32(i)).Return(nil).Once()
34		k.EXPECT().destroyDumb(10 + uint32(i)).Return(nil).Once()
35	}
36	k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()
37}
38
39func rendererBufs(t *testing.T) [2]ports.DMABuf {
40	var b [2]ports.DMABuf
41	for i := range b {
42		f, w, err := os.Pipe()
43		if err != nil {
44			t.Fatal(err)
45		}
46		t.Cleanup(func() { _ = f.Close(); _ = w.Close() })
47		if err := w.Close(); err != nil {
48			t.Fatal(err)
49		}
50		b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
51	}
52	return b
53}
54
55func rendererOK(t *testing.T, k *mockkms, r *portsmocks.MockRenderer) {
56	b := rendererBufs(t)
57	r.EXPECT().CursorBuffers(tSide).Return(b, nil).Once()
58	t.Cleanup(func() { assertPlanesClosed(t, b) })
59	k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
60	k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
61}
62
63// Dumb buffers come first: the renderer allocates nothing.
64func TestCursorSetupUsesDumbBuffers(t *testing.T) {
65	k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
66	dumbOK(k, tSide*4)
67	c := newCursor(&plane{}, tSide)
68	dumbErr, err := c.setup(k, r)
69	if dumbErr != nil || err != nil {
70		t.Fatalf("setup: %v %v", dumbErr, err)
71	}
72	if c.alloc != allocDumb || c.fbs != [2]uint32{90, 91} {
73		t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
74	}
75	dumbFree(k)
76	c.free()
77	if c.imgs != nil || c.fbs != [2]uint32{} {
78		t.Fatal("images kept after free")
79	}
80}
81
82// A dumb buffer that cannot be made (or mapped, or wrapped) falls back to
83// the renderer's images, and what was made is released.
84func TestCursorSetupFallsBackToRenderer(t *testing.T) {
85	for name, fail := range map[string]func(k *mockkms){
86		"create": func(k *mockkms) {
87			k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
88		},
89		"map": func(k *mockkms) {
90			b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
91			k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
92			k.EXPECT().mapDumb(b).Return(nil, unix.ENOMEM).Once()
93			k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
94		},
95		"framebuffer": func(k *mockkms) {
96			b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
97			mem := make([]byte, b.size)
98			k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
99			k.EXPECT().mapDumb(b).Return(mem, nil).Once()
100			k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
101			k.EXPECT().unmapDumb(mem).Return(nil).Once()
102			k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
103		},
104		"second buffer": func(k *mockkms) {
105			b := dumbBuffer{handle: 10, pitch: tSide * 4, size: tSide * tSide * 4}
106			mem := make([]byte, b.size)
107			k.EXPECT().createDumb(tSide, tSide).Return(b, nil).Once()
108			k.EXPECT().mapDumb(b).Return(mem, nil).Once()
109			k.EXPECT().addFBHandle(b, tSide, tSide, uint32(fourccARGB)).Return(90, nil).Once()
110			k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.EMFILE).Once()
111			k.EXPECT().rmFB(uint32(90)).Return(nil).Once()
112			k.EXPECT().unmapDumb(mem).Return(nil).Once()
113			k.EXPECT().destroyDumb(uint32(10)).Return(nil).Once()
114		},
115	} {
116		t.Run(name, func(t *testing.T) {
117			k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
118			fail(k)
119			rendererOK(t, k, r)
120			c := newCursor(&plane{}, tSide)
121			dumbErr, err := c.setup(k, r)
122			if dumbErr == nil || err != nil {
123				t.Fatalf("setup: %v %v", dumbErr, err)
124			}
125			if c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} {
126				t.Fatalf("alloc %q fbs %v", c.alloc, c.fbs)
127			}
128		})
129	}
130}
131
132// Without either allocator there are no images.
133func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
134	k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
135	k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
136	r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("no export")).Once()
137	c := newCursor(&plane{}, tSide)
138	dumbErr, err := c.setup(k, r)
139	if dumbErr == nil || err == nil || c.imgs != nil || c.fbs != [2]uint32{} {
140		t.Fatalf("setup: %v %v imgs %v fbs %v", dumbErr, err, c.imgs, c.fbs)
141	}
142	if _, err := c.useFallback(0); err != nil {
143		t.Fatal(err)
144	}
145}
146
147// A renderer image whose framebuffer fails is released.
148func TestRendererCursorReleasesOnFailure(t *testing.T) {
149	k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
150	bufs := rendererBufs(t)
151	r.EXPECT().CursorBuffers(tSide).Return(bufs, nil).Once()
152	k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
153	k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
154	k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
155	if _, err := newRendererCursor(k, r, tSide); err == nil {
156		t.Fatal("framebuffer failure not reported")
157	}
158	assertPlanesClosed(t, bufs)
159}
160
161func assertPlanesClosed(t *testing.T, bufs [2]ports.DMABuf) {
162	t.Helper()
163	for i, b := range bufs {
164		for j, p := range b.Planes {
165			if _, err := p.File.Stat(); !errors.Is(err, os.ErrClosed) {
166				t.Errorf("buffer %d plane %d: Stat = %v, want os.ErrClosed", i, j, err)
167			}
168		}
169	}
170}
171
172// cursorOutput is a test output whose cursor plane has size tSide.
173func cursorOutput(t *testing.T, errs ...error) (*Output, *mockkms, *portsmocks.MockRenderer, *[]commitRec) {
174	o, k, commits := testOutput(t, errs...)
175	o.cursor = newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, tSide)
176	return o, k, portsmocks.NewMockRenderer(t), commits
177}
178
179// The plane refuses the dumb images: the renderer's are tried (TEST_ONLY),
180// the image on the cursor is rewritten into them, and writes go there.
181func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
182	o, k, r, commits := cursorOutput(t, unix.EINVAL)
183	dumbOK(k, tSide*4)
184	if _, err := o.cursor.setup(k, r); err != nil {
185		t.Fatal(err)
186	}
187	px := []byte{1, 2, 3, 4}
188	if err := o.cursor.setImage(px, 1, 1, 2, 3, 0); err != nil {
189		t.Fatal(err)
190	}
191	dumbFree(k)
192	rendererOK(t, k, r)
193	var written []int
194	r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
195		written = append(written, i)
196		return nil
197	})
198	o.testCursor()
199	c := o.cursor
200	if c.off || c.alloc != allocVulkan || c.fbs != [2]uint32{80, 81} || !c.image || c.hotX != 2 || c.hotY != 3 {
201		t.Fatalf("off %v alloc %q fbs %v image %v hot %d,%d", c.off, c.alloc, c.fbs, c.image, c.hotX, c.hotY)
202	}
203	if len(written) != 1 || len(*commits) != 2 {
204		t.Fatalf("written %v, %d TEST_ONLY commits", written, len(*commits))
205	}
206	if v, _ := (*commits)[1].req.value(tCursor, pFB); v != 80 || (*commits)[1].flags != atomicTestOnly {
207		t.Fatalf("second test shows fb %d flags %#x", v, (*commits)[1].flags)
208	}
209	// Further changes are written by the renderer, not into the freed buffers.
210	if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil || len(written) != 2 {
211		t.Fatalf("write after fallback: %v %v", err, written)
212	}
213}
214
215// Both kinds refused: the software cursor takes over.
216func TestCursorBothRefusedGoesOff(t *testing.T) {
217	o, k, r, commits := cursorOutput(t, unix.EINVAL, unix.EINVAL)
218	dumbOK(k, tSide*4)
219	if _, err := o.cursor.setup(k, r); err != nil {
220		t.Fatal(err)
221	}
222	dumbFree(k)
223	rendererOK(t, k, r)
224	if !o.testCursor() {
225		t.Fatal("no change reported")
226	}
227	if !o.cursor.off || o.cursor.alloc != allocVulkan || len(*commits) != 2 {
228		t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
229	}
230	if o.cursor.desired().on {
231		t.Fatal("cursor shown while off")
232	}
233	// The renderer's images are released with the output.
234	k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
235	k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
236	o.cursor.free()
237}
238
239// Dumb images the plane takes stay, with no second test.
240func TestCursorDumbAcceptedStays(t *testing.T) {
241	for _, tc := range []struct {
242		name string
243		err  error
244	}{{"accepted", nil}, {"non-refusal", unix.EIO}} {
245		t.Run(tc.name, func(t *testing.T) {
246			o, k, r, commits := cursorOutput(t, tc.err)
247			dumbOK(k, tSide*4)
248			if _, err := o.cursor.setup(k, r); err != nil {
249				t.Fatal(err)
250			}
251			if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
252				t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
253			}
254			dumbFree(k)
255			o.cursor.free()
256		})
257	}
258}
259
260// Renderer images refused while no dumb ones exist: off, no retry.
261func TestCursorRendererRefusedGoesOff(t *testing.T) {
262	o, k, r, commits := cursorOutput(t, unix.EINVAL)
263	k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
264	rendererOK(t, k, r)
265	if _, err := o.cursor.setup(k, r); err != nil {
266		t.Fatal(err)
267	}
268	if !o.testCursor() || !o.cursor.off || len(*commits) != 1 {
269		t.Fatalf("off %v commits %d", o.cursor.off, len(*commits))
270	}
271}
272
273// Writes go to the images that own the cursor, and the output's HDR white
274// is passed along.
275func TestCursorWritesGoToCurrentImages(t *testing.T) {
276	o, _, _, _ := cursorOutput(t)
277	imgs := newMockcursorImages(t)
278	imgs.EXPECT().fb(0).Return(90).Once()
279	imgs.EXPECT().fb(1).Return(91).Once()
280	o.cursor.install(imgs, allocDumb)
281	px := []byte{1, 2, 3, 4}
282	imgs.EXPECT().write(0, px, 1, 1, float64(0)).Return(nil).Once()
283	if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err != nil {
284		t.Fatal(err)
285	}
286	o.hdr.on, o.hdr.settings.SDRBrightness = true, 203
287	if got := o.cursorNits(); got != 203 {
288		t.Fatalf("HDR white %v", got)
289	}
290	o.cursor.screen = 90
291	imgs.EXPECT().write(1, px, 1, 1, float64(203)).Return(errors.New("full")).Once()
292	if err := o.cursor.setImage(px, 1, 1, 0, 0, o.cursorNits()); err == nil {
293		t.Fatal("write error swallowed")
294	}
295}
296
297// Dumb pitch may exceed the row size: rows land at the pitch, the image is
298// cropped to the side and cleared around.
299func TestDumbCursorWritePitchCropClear(t *testing.T) {
300	k := newMockkms(t)
301	const pitch = tSide*4 + 32
302	mem := dumbOK(k, pitch)
303	d, err := newDumbCursor(k, tSide)
304	if err != nil {
305		t.Fatal(err)
306	}
307	defer func() { dumbFree(k); d.close() }()
308	big := make([]byte, 40*40*4)
309	for y := range 40 {
310		for x := range 40 {
311			copy(big[(y*40+x)*4:], []byte{byte(x), byte(y), byte(x + y), 255})
312		}
313	}
314	if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
315		t.Fatal(err)
316	}
317	last := (tSide - 1) * pitch
318	for _, y := range []int{0, 1, 7, tSide - 1} {
319		for _, x := range []int{0, 3, tSide - 1} {
320			got := mem[1][y*pitch+x*4 : y*pitch+x*4+4]
321			want := big[(y*40+x)*4 : (y*40+x)*4+4]
322			if !bytes.Equal(got, want) {
323				t.Fatalf("pixel %d,%d: %v want %v", x, y, got, want)
324			}
325		}
326	}
327	for y := range tSide {
328		if !bytes.Equal(mem[1][y*pitch+tSide*4:(y+1)*pitch], make([]byte, pitch-tSide*4)) {
329			t.Fatalf("row %d padding written", y)
330		}
331	}
332	if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
333		t.Fatal(err)
334	}
335	m := mem[1]
336	if !bytes.Equal(m[:4], []byte{1, 2, 3, 4}) || m[4] != 0 || m[pitch] != 0 || m[last+(tSide-1)*4] != 0 {
337		t.Fatal("image not cleared around the new pixels")
338	}
339	// A wide, short image keeps its rows apart.
340	row := []byte{9, 9, 9, 9, 8, 8, 8, 8}
341	if err := d.write(0, append(row, row...), 2, 2, 0); err != nil {
342		t.Fatal(err)
343	}
344	if m := mem[0]; !bytes.Equal(m[:8], row) || !bytes.Equal(m[pitch:pitch+8], row) || m[8] != 0 {
345		t.Fatal("rows of a narrow image")
346	}
347	if err := d.write(0, []byte{1}, 1, 1, 0); err == nil {
348		t.Fatal("short image accepted")
349	}
350	if err := d.write(2, nil, 0, 0, 0); err == nil {
351		t.Fatal("bad index accepted")
352	}
353}
354
355// HDR cursors convert exactly as the renderer did (see hdrcursor's vector).
356func TestDumbCursorWriteHDR(t *testing.T) {
357	k := newMockkms(t)
358	mem := dumbOK(k, tSide*4)
359	d, err := newDumbCursor(k, tSide)
360	if err != nil {
361		t.Fatal(err)
362	}
363	defer func() { dumbFree(k); d.close() }()
364	if err := d.write(0, []byte{255, 255, 255, 255}, 1, 1, 203); err != nil {
365		t.Fatal(err)
366	}
367	if m := mem[0]; m[0] != 148 || m[1] != 148 || m[2] != 148 || m[3] != 255 {
368		t.Fatalf("HDR white: %v", m[:4])
369	}
370	if err := d.write(1, []byte{255, 255, 255, 255}, 1, 1, 0); err != nil || mem[1][0] != 255 {
371		t.Fatalf("SDR white: %v %v", err, mem[1][:4])
372	}
373	if d.cache.Len() != 4 {
374		t.Fatalf("cache %d", d.cache.Len())
375	}
376}
377
378func TestCursorFallbackState(t *testing.T) {
379	for _, mode := range []string{"loaded", "queued", "hidden"} {
380		t.Run(mode, func(t *testing.T) {
381			o, k, r, commits := cursorOutput(t, unix.EINVAL)
382			dumbOK(k, tSide*4)
383			c := o.cursor
384			if _, err := c.setup(k, r); err != nil {
385				t.Fatal(err)
386			}
387			px := []byte{1, 2, 3, 4}
388			if err := c.setImage(px, 1, 1, 2, 3, 0); err != nil {
389				t.Fatal(err)
390			}
391			c.screen = 90
392			c.committed(cursorState{on: true, fb: 91, x: 8, y: 9})
393			want := []byte{1, 2, 3, 4}
394			if mode == "queued" {
395				px = []byte{5, 6, 7, 8}
396				want = bytes.Clone(px)
397				if err := c.setImage(px, 1, 1, 4, 5, 0); err != nil {
398					t.Fatal(err)
399				}
400				if c.later == nil {
401					t.Fatal("image not queued")
402				}
403			}
404			if mode == "hidden" {
405				if err := c.setImage(nil, 0, 0, 0, 0, 0); err != nil {
406					t.Fatal(err)
407				}
408			}
409			px[0] = 99 // Neither the loaded nor queued image may alias caller memory.
410			dumbFree(k)
411			rendererOK(t, k, r)
412			if mode != "hidden" {
413				r.EXPECT().WriteCursor(0, want, 1, 1).Return(nil).Once()
414			}
415			if !o.testCursor() {
416				t.Fatal("swap not reported")
417			}
418			if c.screen != 0 || c.flying || c.flight != (cursorState{}) || c.applied != (cursorState{}) || c.later != nil {
419				t.Fatalf("stale scanout state: screen %d flight %+v flying %v applied %+v", c.screen, c.flight, c.flying, c.applied)
420			}
421			c.landed()
422			if c.screen != 0 {
423				t.Fatal("old pending flight resurrected")
424			}
425			s := c.desired()
426			if mode == "hidden" {
427				if s.on || c.image {
428					t.Fatal("hidden cursor shown")
429				}
430			} else {
431				if !s.on || s.fb != 80 {
432					t.Fatalf("desired %+v", s)
433				}
434				if mode == "queued" && (c.hotX != 4 || c.hotY != 5) {
435					t.Fatal("queued hotspot lost")
436				}
437			}
438			req := &atomicReq{}
439			c.props(req, o.crtc, s)
440			if err := k.commit(req, 0, 0); err != nil {
441				t.Fatal(err)
442			}
443			wantFB := uint64(80)
444			if mode == "hidden" {
445				wantFB = 0
446			}
447			if fb, _ := (*commits)[2].req.value(tCursor, pFB); fb != wantFB {
448				t.Fatalf("next commit fb %d want %d", fb, wantFB)
449			}
450			c.committed(s)
451			c.landed()
452			if c.screen != uint32(wantFB) {
453				t.Fatalf("screen %d", c.screen)
454			}
455		})
456	}
457}
458
459func TestCursorFallbackFailureGoesOff(t *testing.T) {
460	for _, mode := range []string{"allocation", "rewrite"} {
461		t.Run(mode, func(t *testing.T) {
462			o, k, r, commits := cursorOutput(t, unix.EINVAL)
463			dumbOK(k, tSide*4)
464			c := o.cursor
465			if _, err := c.setup(k, r); err != nil {
466				t.Fatal(err)
467			}
468			px := []byte{1, 2, 3, 4}
469			if err := c.setImage(px, 1, 1, 0, 0, 0); err != nil {
470				t.Fatal(err)
471			}
472			if mode == "allocation" {
473				r.EXPECT().CursorBuffers(tSide).Return([2]ports.DMABuf{}, errors.New("allocation failed")).Once()
474			} else {
475				dumbFree(k)
476				rendererOK(t, k, r)
477				r.EXPECT().WriteCursor(0, px, 1, 1).Return(errors.New("rewrite failed")).Once()
478			}
479			if !o.testCursor() || !c.off || c.desired().on || len(*commits) != 1 {
480				t.Fatalf("off %v desired %+v commits %d", c.off, c.desired(), len(*commits))
481			}
482			if mode == "allocation" {
483				dumbFree(k)
484			} else {
485				k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
486				k.EXPECT().rmFB(uint32(81)).Return(nil).Once()
487			}
488			c.free()
489		})
490	}
491}

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 25

Tags 10

↑↓ moveenter openesc close