detached bd83a2d

test(drm): cover cursor fallback state, crop rows and file ownership

Check that cursor plane files are closed by production code, close the
card on real-device test failures, use distinct pixels in the crop test,
and cover the dumb-to-Vulkan cursor swap: in-flight state, queued and
hidden images, caller slice mutation and fallback failures. Move HDR
cache-key checks to hdrcursor.
commit
bd83a2d08e35718f00d91b43b91247069475ab63
author
brice · · 2026-10-22 13:16 UTC
parent
a5f1806
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
1/1
4 files · +218 -48
M internal/adapters/drm/cursor_images_test.go +181 -23
@@ -36,18 +36,26 @@ func dumbFree(k *mockkms) {
3636 k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()
3737 }
3838
39-func rendererBufs() [2]ports.DMABuf {
39+func rendererBufs(t *testing.T) [2]ports.DMABuf {
4040 var b [2]ports.DMABuf
4141 for i := range b {
42- f, w, _ := os.Pipe()
43- w.Close()
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+ }
4450 b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
4551 }
4652 return b
4753 }
4854
49-func rendererOK(k *mockkms, r *portsmocks.MockRenderer) {
50- r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()
55+func 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) })
5159 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
5260 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
5361 }
@@ -108,7 +116,7 @@ func TestCursorSetupFallsBackToRenderer(t *testing.T) {
108116 t.Run(name, func(t *testing.T) {
109117 k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
110118 fail(k)
111- rendererOK(k, r)
119+ rendererOK(t, k, r)
112120 c := newCursor(&plane{}, tSide)
113121 dumbErr, err := c.setup(k, r)
114122 if dumbErr == nil || err != nil {
@@ -139,13 +147,26 @@ func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
139147 // A renderer image whose framebuffer fails is released.
140148 func TestRendererCursorReleasesOnFailure(t *testing.T) {
141149 k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
142- r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()
150+ bufs := rendererBufs(t)
151+ r.EXPECT().CursorBuffers(tSide).Return(bufs, nil).Once()
143152 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
144153 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
145154 k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
146155 if _, err := newRendererCursor(k, r, tSide); err == nil {
147156 t.Fatal("framebuffer failure not reported")
148157 }
158+ assertPlanesClosed(t, bufs)
159+}
160+
161+func 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+ }
149170 }
150171
151172 // cursorOutput is a test output whose cursor plane has size tSide.
@@ -168,7 +189,7 @@ func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
168189 t.Fatal(err)
169190 }
170191 dumbFree(k)
171- rendererOK(k, r)
192+ rendererOK(t, k, r)
172193 var written []int
173194 r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
174195 written = append(written, i)
@@ -199,7 +220,7 @@ func TestCursorBothRefusedGoesOff(t *testing.T) {
199220 t.Fatal(err)
200221 }
201222 dumbFree(k)
202- rendererOK(k, r)
223+ rendererOK(t, k, r)
203224 if !o.testCursor() {
204225 t.Fatal("no change reported")
205226 }
@@ -217,24 +238,30 @@ func TestCursorBothRefusedGoesOff(t *testing.T) {
217238
218239 // Dumb images the plane takes stay, with no second test.
219240 func TestCursorDumbAcceptedStays(t *testing.T) {
220- o, k, r, commits := cursorOutput(t)
221- dumbOK(k, tSide*4)
222- if _, err := o.cursor.setup(k, r); err != nil {
223- t.Fatal(err)
224- }
225- if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {
226- t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))
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+ })
227257 }
228- // An error other than a refusal changes nothing.
229- dumbFree(k)
230- o.cursor.free()
231258 }
232259
233260 // Renderer images refused while no dumb ones exist: off, no retry.
234261 func TestCursorRendererRefusedGoesOff(t *testing.T) {
235262 o, k, r, commits := cursorOutput(t, unix.EINVAL)
236263 k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
237- rendererOK(k, r)
264+ rendererOK(t, k, r)
238265 if _, err := o.cursor.setup(k, r); err != nil {
239266 t.Fatal(err)
240267 }
@@ -278,13 +305,29 @@ func TestDumbCursorWritePitchCropClear(t *testing.T) {
278305 t.Fatal(err)
279306 }
280307 defer func() { dumbFree(k); d.close() }()
281- big := bytes.Repeat([]byte{0xff}, 40*40*4)
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+ }
282314 if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
283315 t.Fatal(err)
284316 }
285317 last := (tSide - 1) * pitch
286- if m := mem[1]; m[last+(tSide-1)*4] != 0xff || m[last+tSide*4] != 0 || m[pitch-1] != 0 {
287- t.Fatal("cropped image misplaced at the 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+ }
288331 }
289332 if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
290333 t.Fatal(err)
@@ -331,3 +374,118 @@ func TestDumbCursorWriteHDR(t *testing.T) {
331374 t.Fatalf("cache %d", d.cache.Len())
332375 }
333376 }
377+
378+func 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+
459+func 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+}
@@ -36,18 +36,26 @@ func dumbFree(k *mockkms) {
36 k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()36 k.EXPECT().unmapDumb(mock.Anything).Return(nil).Twice()
37 }37 }
38 38
39-func rendererBufs() [2]ports.DMABuf {39+func rendererBufs(t *testing.T) [2]ports.DMABuf {
40 var b [2]ports.DMABuf40 var b [2]ports.DMABuf
41 for i := range b {41 for i := range b {
42- f, w, _ := os.Pipe()42+ f, w, err := os.Pipe()
43- w.Close()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+ }
44 b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}50 b[i] = ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
45 }51 }
46 return b52 return b
47 }53 }
48 54
49-func rendererOK(k *mockkms, r *portsmocks.MockRenderer) {55+func rendererOK(t *testing.T, k *mockkms, r *portsmocks.MockRenderer) {
50- r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()56+ b := rendererBufs(t)
57+ r.EXPECT().CursorBuffers(tSide).Return(b, nil).Once()
58+ t.Cleanup(func() { assertPlanesClosed(t, b) })
51 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()59 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
52 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()60 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(81, nil).Once()
53 }61 }
@@ -108,7 +116,7 @@ func TestCursorSetupFallsBackToRenderer(t *testing.T) {
108 t.Run(name, func(t *testing.T) {116 t.Run(name, func(t *testing.T) {
109 k, r := newMockkms(t), portsmocks.NewMockRenderer(t)117 k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
110 fail(k)118 fail(k)
111- rendererOK(k, r)119+ rendererOK(t, k, r)
112 c := newCursor(&plane{}, tSide)120 c := newCursor(&plane{}, tSide)
113 dumbErr, err := c.setup(k, r)121 dumbErr, err := c.setup(k, r)
114 if dumbErr == nil || err != nil {122 if dumbErr == nil || err != nil {
@@ -139,13 +147,26 @@ func TestCursorSetupFailsWithoutAllocators(t *testing.T) {
139 // A renderer image whose framebuffer fails is released.147 // A renderer image whose framebuffer fails is released.
140 func TestRendererCursorReleasesOnFailure(t *testing.T) {148 func TestRendererCursorReleasesOnFailure(t *testing.T) {
141 k, r := newMockkms(t), portsmocks.NewMockRenderer(t)149 k, r := newMockkms(t), portsmocks.NewMockRenderer(t)
142- r.EXPECT().CursorBuffers(tSide).Return(rendererBufs(), nil).Once()150+ bufs := rendererBufs(t)
151+ r.EXPECT().CursorBuffers(tSide).Return(bufs, nil).Once()
143 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()152 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(80, nil).Once()
144 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()153 k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(0, unix.EINVAL).Once()
145 k.EXPECT().rmFB(uint32(80)).Return(nil).Once()154 k.EXPECT().rmFB(uint32(80)).Return(nil).Once()
146 if _, err := newRendererCursor(k, r, tSide); err == nil {155 if _, err := newRendererCursor(k, r, tSide); err == nil {
147 t.Fatal("framebuffer failure not reported")156 t.Fatal("framebuffer failure not reported")
148 }157 }
158+ assertPlanesClosed(t, bufs)
159+}
160+
161+func 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+ }
149 }170 }
150 171
151 // cursorOutput is a test output whose cursor plane has size tSide.172 // cursorOutput is a test output whose cursor plane has size tSide.
@@ -168,7 +189,7 @@ func TestCursorDumbRefusedUsesRendererImages(t *testing.T) {
168 t.Fatal(err)189 t.Fatal(err)
169 }190 }
170 dumbFree(k)191 dumbFree(k)
171- rendererOK(k, r)192+ rendererOK(t, k, r)
172 var written []int193 var written []int
173 r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {194 r.EXPECT().WriteCursor(mock.Anything, px, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
174 written = append(written, i)195 written = append(written, i)
@@ -199,7 +220,7 @@ func TestCursorBothRefusedGoesOff(t *testing.T) {
199 t.Fatal(err)220 t.Fatal(err)
200 }221 }
201 dumbFree(k)222 dumbFree(k)
202- rendererOK(k, r)223+ rendererOK(t, k, r)
203 if !o.testCursor() {224 if !o.testCursor() {
204 t.Fatal("no change reported")225 t.Fatal("no change reported")
205 }226 }
@@ -217,24 +238,30 @@ func TestCursorBothRefusedGoesOff(t *testing.T) {
217 238
218 // Dumb images the plane takes stay, with no second test.239 // Dumb images the plane takes stay, with no second test.
219 func TestCursorDumbAcceptedStays(t *testing.T) {240 func TestCursorDumbAcceptedStays(t *testing.T) {
220- o, k, r, commits := cursorOutput(t)241+ for _, tc := range []struct {
221- dumbOK(k, tSide*4)242+ name string
222- if _, err := o.cursor.setup(k, r); err != nil {243+ err error
223- t.Fatal(err)244+ }{{"accepted", nil}, {"non-refusal", unix.EIO}} {
224- }245+ t.Run(tc.name, func(t *testing.T) {
225- if o.testCursor() || o.cursor.off || o.cursor.alloc != allocDumb || len(*commits) != 1 {246+ o, k, r, commits := cursorOutput(t, tc.err)
226- t.Fatalf("off %v alloc %q commits %d", o.cursor.off, o.cursor.alloc, len(*commits))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+ })
227 }257 }
228- // An error other than a refusal changes nothing.
229- dumbFree(k)
230- o.cursor.free()
231 }258 }
232 259
233 // Renderer images refused while no dumb ones exist: off, no retry.260 // Renderer images refused while no dumb ones exist: off, no retry.
234 func TestCursorRendererRefusedGoesOff(t *testing.T) {261 func TestCursorRendererRefusedGoesOff(t *testing.T) {
235 o, k, r, commits := cursorOutput(t, unix.EINVAL)262 o, k, r, commits := cursorOutput(t, unix.EINVAL)
236 k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()263 k.EXPECT().createDumb(tSide, tSide).Return(dumbBuffer{}, unix.ENOSYS).Once()
237- rendererOK(k, r)264+ rendererOK(t, k, r)
238 if _, err := o.cursor.setup(k, r); err != nil {265 if _, err := o.cursor.setup(k, r); err != nil {
239 t.Fatal(err)266 t.Fatal(err)
240 }267 }
@@ -278,13 +305,29 @@ func TestDumbCursorWritePitchCropClear(t *testing.T) {
278 t.Fatal(err)305 t.Fatal(err)
279 }306 }
280 defer func() { dumbFree(k); d.close() }()307 defer func() { dumbFree(k); d.close() }()
281- big := bytes.Repeat([]byte{0xff}, 40*40*4)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+ }
282 if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide314 if err := d.write(1, big, 40, 40, 0); err != nil { // cropped to tSide
283 t.Fatal(err)315 t.Fatal(err)
284 }316 }
285 last := (tSide - 1) * pitch317 last := (tSide - 1) * pitch
286- if m := mem[1]; m[last+(tSide-1)*4] != 0xff || m[last+tSide*4] != 0 || m[pitch-1] != 0 {318+ for _, y := range []int{0, 1, 7, tSide - 1} {
287- t.Fatal("cropped image misplaced at the pitch")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+ }
288 }331 }
289 if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {332 if err := d.write(1, []byte{1, 2, 3, 4}, 1, 1, 0); err != nil {
290 t.Fatal(err)333 t.Fatal(err)
@@ -331,3 +374,118 @@ func TestDumbCursorWriteHDR(t *testing.T) {
331 t.Fatalf("cache %d", d.cache.Len())374 t.Fatalf("cache %d", d.cache.Len())
332 }375 }
333 }376 }
377+
378+func 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+
459+func 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+}
M internal/adapters/drm/dumb_device_test.go +4 -2
@@ -23,8 +23,10 @@ func TestDumbBuffersOnRealDevice(t *testing.T) {
2323 t.Logf("%s: %v", path, err)
2424 continue
2525 }
26- done := dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
27- f.Close()
26+ done := func() bool {
27+ defer f.Close()
28+ return dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
29+ }()
2830 if done {
2931 return
3032 }
@@ -23,8 +23,10 @@ func TestDumbBuffersOnRealDevice(t *testing.T) {
23 t.Logf("%s: %v", path, err)23 t.Logf("%s: %v", path, err)
24 continue24 continue
25 }25 }
26- done := dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})26+ done := func() bool {
27- f.Close()27+ defer f.Close()
28+ return dumbDeviceRoundTrip(t, path, kmsDevice{fd: int(f.Fd())})
29+ }()
28 if done {30 if done {
29 return31 return
30 }32 }
A internal/adapters/hdrcursor/convert_test.go +29 -0
@@ -0,0 +1,29 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestCacheKeyGeometry(t *testing.T) {
9+ px := []byte{10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255}
10+ for _, tc := range []struct {
11+ name string
12+ w, h, side int
13+ }{{"dimensions", 1, 4, 4}, {"max-side", 2, 2, 1}} {
14+ t.Run(tc.name, func(t *testing.T) {
15+ var c Cache
16+ c.Convert(px, 2, 2, 4, 203)
17+ before := c.Key()
18+ got := bytes.Clone(c.Convert(px, tc.w, tc.h, tc.side, 203))
19+ if c.Key() == before {
20+ t.Fatal("geometry missing from cache key")
21+ }
22+ var fresh Cache
23+ want := fresh.Convert(px, tc.w, tc.h, tc.side, 203)
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("stale conversion: %v want %v", got, want)
26+ }
27+ })
28+ }
29+}
@@ -0,0 +1,29 @@
1+package hdrcursor
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestCacheKeyGeometry(t *testing.T) {
9+ px := []byte{10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255}
10+ for _, tc := range []struct {
11+ name string
12+ w, h, side int
13+ }{{"dimensions", 1, 4, 4}, {"max-side", 2, 2, 1}} {
14+ t.Run(tc.name, func(t *testing.T) {
15+ var c Cache
16+ c.Convert(px, 2, 2, 4, 203)
17+ before := c.Key()
18+ got := bytes.Clone(c.Convert(px, tc.w, tc.h, tc.side, 203))
19+ if c.Key() == before {
20+ t.Fatal("geometry missing from cache key")
21+ }
22+ var fresh Cache
23+ want := fresh.Convert(px, tc.w, tc.h, tc.side, 203)
24+ if !bytes.Equal(got, want) {
25+ t.Fatalf("stale conversion: %v want %v", got, want)
26+ }
27+ })
28+ }
29+}
M internal/adapters/vulkan/cursor_test.go +4 -23
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
4747 }
4848 }
4949
50-func TestHDRCursorCachesConversion(t *testing.T) {
50+func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
5151 r, err := New(32, 16)
5252 if err != nil {
5353 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,28 +65,6 @@ func TestHDRCursorCachesConversion(t *testing.T) {
6565 if err := r.WriteCursor(0, px, 1, 1); err != nil {
6666 t.Fatal(err)
6767 }
68- if r.cursorCache.Len() != 4 {
69- t.Fatalf("cache size %d", r.cursorCache.Len())
70- }
71- if err := r.WriteCursor(1, px, 1, 1); err != nil {
72- t.Fatal(err)
73- }
74- if r.cursorCache.Len() != 4 {
75- t.Fatalf("cache grew on reselect: %d", r.cursorCache.Len())
76- }
77- first := r.cursorCache.Key()
78- for i := 0; i < 12; i++ {
79- color := byte(i * 18)
80- if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81- t.Fatal(err)
82- }
83- if r.cursorCache.Len() != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, r.cursorCache.Len())
85- }
86- }
87- if first == r.cursorCache.Key() {
88- t.Fatal("cursor conversion not replaced")
89- }
9068 if err := r.WriteCursor(1, px, 1, 1); err != nil {
9169 t.Fatal(err)
9270 }
@@ -97,6 +75,9 @@ func TestHDRCursorCachesConversion(t *testing.T) {
9775 }
9876 }
9977 r.SetHDR(0)
78+ if r.cursorCache.Len() != 0 {
79+ t.Fatal("SetHDR kept conversion")
80+ }
10081 if err := r.WriteCursor(0, px, 1, 1); err != nil {
10182 t.Fatal(err)
10283 }
@@ -47,7 +47,7 @@ func TestCursorBuffersWriteCursor(t *testing.T) {
47 }47 }
48 }48 }
49 49
50-func TestHDRCursorCachesConversion(t *testing.T) {50+func TestHDRCursorWritesBothImagesAndInvalidates(t *testing.T) {
51 r, err := New(32, 16)51 r, err := New(32, 16)
52 if err != nil {52 if err != nil {
53 t.Skipf("Vulkan unavailable: %v", err)53 t.Skipf("Vulkan unavailable: %v", err)
@@ -65,28 +65,6 @@ func TestHDRCursorCachesConversion(t *testing.T) {
65 if err := r.WriteCursor(0, px, 1, 1); err != nil {65 if err := r.WriteCursor(0, px, 1, 1); err != nil {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68- if r.cursorCache.Len() != 4 {
69- t.Fatalf("cache size %d", r.cursorCache.Len())
70- }
71- if err := r.WriteCursor(1, px, 1, 1); err != nil {
72- t.Fatal(err)
73- }
74- if r.cursorCache.Len() != 4 {
75- t.Fatalf("cache grew on reselect: %d", r.cursorCache.Len())
76- }
77- first := r.cursorCache.Key()
78- for i := 0; i < 12; i++ {
79- color := byte(i * 18)
80- if err := r.WriteCursor(1, []byte{color, color, color, 255}, 1, 1); err != nil {
81- t.Fatal(err)
82- }
83- if r.cursorCache.Len() != 4 {
84- t.Fatalf("cache grew after image %d: %d", i, r.cursorCache.Len())
85- }
86- }
87- if first == r.cursorCache.Key() {
88- t.Fatal("cursor conversion not replaced")
89- }
90 if err := r.WriteCursor(1, px, 1, 1); err != nil {68 if err := r.WriteCursor(1, px, 1, 1); err != nil {
91 t.Fatal(err)69 t.Fatal(err)
92 }70 }
@@ -97,6 +75,9 @@ func TestHDRCursorCachesConversion(t *testing.T) {
97 }75 }
98 }76 }
99 r.SetHDR(0)77 r.SetHDR(0)
78+ if r.cursorCache.Len() != 0 {
79+ t.Fatal("SetHDR kept conversion")
80+ }
100 if err := r.WriteCursor(0, px, 1, 1); err != nil {81 if err := r.WriteCursor(0, px, 1, 1); err != nil {
101 t.Fatal(err)82 t.Fatal(err)
102 }83 }

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