main f2d166b

capture_test.go RawHistoryFolder

1package vulkan
2
3import (
4	"context"
5	"encoding/binary"
6	"errors"
7	"image"
8	"math"
9	"os"
10	"slices"
11	"strings"
12	"testing"
13	"unsafe"
14
15	"image/color"
16
17	"github.com/bnema/neferwl/internal/adapters/syncfile"
18	"github.com/bnema/neferwl/internal/ports"
19	vk "github.com/bnema/purego-vulkan/vulkan"
20	"github.com/stretchr/testify/mock"
21	"golang.org/x/sys/unix"
22)
23
24// requireCaptureSync skips only integration tests that need kernel sync-file
25// export. Software Vulkan devices can still exercise Pixels and rendering.
26func requireCaptureSync(t *testing.T, r *Renderer) {
27	t.Helper()
28	if !r.syncFD || !r.dd.HasGetSemaphoreFdKHR() {
29		t.Skip("Vulkan sync-file export unavailable")
30	}
31}
32
33// captureWait leases a capture of the last frame and waits for it.
34func captureWait(t *testing.T, r *Renderer) ports.CaptureFrame {
35	t.Helper()
36	requireCaptureSync(t, r)
37	cf, err := r.BeginCapture()
38	if err != nil {
39		t.Fatal(err)
40	}
41	if done := cf.Done(); done != nil {
42		if err := syncfile.Wait(context.Background(), done); err != nil {
43			t.Fatal(err)
44		}
45	}
46	return cf
47}
48
49func TestCaptureLastFrameRegion(t *testing.T) {
50	r, err := New(8, 8)
51	if err != nil {
52		t.Skipf("Vulkan unavailable: %v", err)
53	}
54	defer r.Close()
55	if _, err := r.BeginCapture(); err == nil {
56		t.Fatal("capture before any frame accepted")
57	}
58	if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
59		t.Fatal(err)
60	}
61	if r.captures != nil {
62		t.Fatal("slots allocated before the first capture")
63	}
64	cf := captureWait(t, r)
65	if len(r.captures) != captureSlots {
66		t.Fatalf("%d slots", len(r.captures))
67	}
68	dst := make([]byte, 24)
69	if err := cf.Read(image.Rect(2, 3, 4, 5), dst, 12); err != nil {
70		t.Fatal(err)
71	}
72	for _, at := range []int{0, 4, 12, 16} {
73		if got := dst[at : at+4]; got[0] != 0x56 || got[1] != 0x34 || got[2] != 0x12 || got[3] != 255 {
74			t.Fatalf("pixel %d: %x", at, got)
75		}
76	}
77	if err := cf.Read(image.Rect(-1, 0, 1, 1), dst, 12); err == nil {
78		t.Fatal("invalid region accepted")
79	}
80	if err := cf.Read(image.Rect(0, 0, 2, 2), dst[:10], 8); err == nil {
81		t.Fatal("short destination accepted")
82	}
83	// A stride near MaxInt must not overflow the row arithmetic.
84	if err := cf.Read(image.Rect(0, 0, 2, 2), dst, math.MaxInt-1); err == nil {
85		t.Fatal("overflowing stride accepted")
86	}
87	if err := cf.Read(image.Rect(0, 0, 2, 2), dst, math.MaxInt); err == nil {
88		t.Fatal("MaxInt stride accepted")
89	}
90	// A one-row read needs only w*4 bytes whatever the stride.
91	if err := cf.Read(image.Rect(0, 0, 2, 1), dst[:8], math.MaxInt); err != nil {
92		t.Fatal(err)
93	}
94	r.EndCapture(cf)
95	if err := cf.Read(image.Rect(2, 3, 4, 5), dst, 12); err == nil {
96		t.Fatal("read after EndCapture accepted")
97	}
98	r.EndCapture(cf) // idempotent
99}
100
101// Every slot leased: the next capture fails at once; returning one
102// frees it again once its copy finished.
103func TestCaptureSlotsBounded(t *testing.T) {
104	r, err := New(8, 8)
105	if err != nil {
106		t.Skipf("Vulkan unavailable: %v", err)
107	}
108	defer r.Close()
109	if err := render(r, ports.Scene{Background: "#ff0000"}, nil); err != nil {
110		t.Fatal(err)
111	}
112	leaseAll := func() (leased []ports.CaptureFrame) {
113		for range captureSlots {
114			leased = append(leased, captureWait(t, r))
115		}
116		return leased
117	}
118	leased := leaseAll()
119	if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
120		t.Fatalf("capture past the last slot: %v", err)
121	}
122	r.EndCapture(leased[0])
123	// The first copy finished (waited above): its fence polls signalled.
124	c := captureWait(t, r)
125	dst := make([]byte, 4)
126	if err := c.Read(image.Rect(0, 0, 1, 1), dst, 4); err != nil {
127		t.Fatal(err)
128	}
129	if dst[2] != 255 || dst[1] != 0 || dst[0] != 0 {
130		t.Fatalf("pixel %v", dst)
131	}
132	for _, f := range leased[1:] {
133		r.EndCapture(f)
134	}
135	r.EndCapture(c)
136	// Pixels never takes a leased slot: with all out it reports nil.
137	leased = leaseAll()
138	if img := r.Pixels(); img != nil {
139		t.Fatal("Pixels stole a leased slot")
140	}
141	for _, f := range leased {
142		r.EndCapture(f)
143	}
144	if img := r.Pixels(); img == nil || img.RGBAAt(0, 0) != (color.RGBA{255, 0, 0, 255}) {
145		t.Fatalf("Pixels after release: %v", img)
146	}
147	// Frames of a closed renderer are invalid, and Close survives leases
148	// still out.
149	d := captureWait(t, r)
150	r.Close()
151	if err := d.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
152		t.Fatal("read after Close accepted")
153	}
154	r.EndCapture(d)
155}
156
157// A returned slot whose copy has not finished is not reused: the poll
158// sees the fence unsignalled. Simulated by marking the slot pending with
159// its fence reset.
160func TestCaptureReturnedSlotWaitsForFence(t *testing.T) {
161	r, err := New(8, 8)
162	if err != nil {
163		t.Skipf("Vulkan unavailable: %v", err)
164	}
165	defer r.Close()
166	if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
167		t.Fatal(err)
168	}
169	a := captureWait(t, r)
170	b := captureWait(t, r)
171	r.EndCapture(a)
172	r.EndCapture(b)
173	for _, s := range r.captures {
174		// The copies finished (waited above); reset only signalled fences.
175		if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
176			t.Fatal(err)
177		}
178		if err := checked("vkResetFences", r.dd.ResetFences(r.device, 1, &s.fence)); err != nil {
179			t.Fatal(err)
180		}
181		s.pending = true
182	}
183	if _, err := r.BeginCapture(); !errors.Is(err, ports.ErrCaptureBusy) {
184		t.Fatalf("pending slots reused: %v", err)
185	}
186	// Signal them again with an empty submit, then the poll frees them.
187	for _, s := range r.captures {
188		if err := checked("vkQueueSubmit", r.dd.QueueSubmit(r.queue, 0, nil, s.fence)); err != nil {
189			t.Fatal(err)
190		}
191		if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
192			t.Fatal(err)
193		}
194	}
195	c := captureWait(t, r)
196	r.EndCapture(c)
197	if _, err := r.captureSize(); err != nil {
198		t.Fatal(err)
199	}
200	if r.captures[0].fence == 0 || r.captures[0].done == vk.Semaphore(0) {
201		t.Fatal("slot objects missing")
202	}
203}
204
205// A device without sync-file export refuses protocol capture before allocating
206// slots, but keeps synchronous debug screenshots functional (e.g. lavapipe).
207func TestCaptureWithoutSyncFileExport(t *testing.T) {
208	r, err := New(8, 8)
209	if err != nil {
210		t.Skipf("Vulkan unavailable: %v", err)
211	}
212	defer r.Close()
213	r.syncFD = false
214	if err := render(r, ports.Scene{Background: "#123456"}, nil); err != nil {
215		t.Fatal(err)
216	}
217	if _, err := r.BeginCapture(); err == nil || !strings.Contains(err.Error(), "sync file export") {
218		t.Fatalf("capture without sync-file export: %v", err)
219	}
220	if r.captures != nil {
221		t.Fatal("unsupported capture allocated slots")
222	}
223	pixels := r.Pixels()
224	if pixels == nil || pixels.RGBAAt(0, 0) != (color.RGBA{0x12, 0x34, 0x56, 255}) {
225		t.Fatalf("debug screenshot without sync-file export: %v", pixels)
226	}
227}
228
229// The capture size cap admits two 5K slots and rejects 8K.
230func TestCaptureSizeCap(t *testing.T) {
231	r := &Renderer{width: 5120, height: 2880}
232	if _, err := r.captureSize(); err != nil {
233		t.Fatalf("5K rejected: %v", err)
234	}
235	r.width, r.height = 7680, 4320
236	if _, err := r.captureSize(); err == nil {
237		t.Fatal("8K accepted")
238	}
239	r.width, r.height = 0, 4320
240	if _, err := r.captureSize(); err == nil {
241		t.Fatal("empty accepted")
242	}
243}
244
245// captureSDR follows the documented curve: exact below the knee, a
246// monotonic shoulder above it that never saturates, hue kept, and
247// negative components desaturated toward luminance.
248func TestCaptureCurve(t *testing.T) {
249	for _, x := range []float64{0, 0.25, 0.5, 0.9} {
250		if got := captureCurve(x); got != x {
251			t.Fatalf("curve(%v) = %v", x, got)
252		}
253	}
254	prev := captureCurve(0.9)
255	for _, x := range []float64{1, 1.5, 2, 4.93, 10, 100} {
256		got := captureCurve(x)
257		if got <= prev || got >= 1 {
258			t.Fatalf("curve(%v) = %v after %v", x, got, prev)
259		}
260		prev = got
261	}
262	// Diffuse white stays bright; 2x and 5x (1000 nits) white stay
263	// distinct from each other and from saturation.
264	white := captureSDR([3]float64{1, 1, 1})
265	twice := captureSDR([3]float64{2, 2, 2})
266	peak := captureSDR([3]float64{4.93, 4.93, 4.93})
267	if white[0] < 240 || white[0] != white[1] || white[1] != white[2] {
268		t.Fatalf("diffuse white %v", white)
269	}
270	if !(white[0] < twice[0] && twice[0] < peak[0] && peak[0] < 255) {
271		t.Fatalf("shoulder white %v 2x %v 5x %v", white, twice, peak)
272	}
273	// Bright red keeps its hue: green and blue stay zero.
274	if px := captureSDR([3]float64{4, 0, 0}); px[1] != 0 || px[2] != 0 || px[0] <= captureSDR([3]float64{0.9, 0, 0})[0] {
275		t.Fatalf("bright red %v", px)
276	}
277	// A BT.2020 red outside BT.709 (negative green and blue) is mixed
278	// toward its luminance until the most negative component (green)
279	// is zero; blue rises a little above zero, red stays dominant.
280	in := [3]float64{1.2, -0.1, -0.05}
281	px := captureSDR(in)
282	if px[1] != 0 || px[2] == 0 || px[2] > 60 || px[0] < 200 {
283		t.Fatalf("out-of-gamut red %v", px)
284	}
285	// The desaturation keeps luminance: the sRGB-decoded result has the
286	// input's Y (0.18) within rounding.
287	dec := func(b uint8) float64 {
288		v := float64(b) / 255
289		if v <= 0.04045 {
290			return v / 12.92
291		}
292		return math.Pow((v+0.055)/1.055, 2.4)
293	}
294	if y := 0.2126*dec(px[0]) + 0.7152*dec(px[1]) + 0.0722*dec(px[2]); math.Abs(y-0.18) > 0.01 {
295		t.Fatalf("luminance %.3f after gamut reduction of %v", y, px)
296	}
297	if nan := captureSDR([3]float64{math.NaN(), math.Inf(1), -1}); nan[0] != 0 || nan[1] < 250 {
298		t.Fatalf("NaN/Inf %v", nan)
299	}
300}
301
302// The lease object of a slot is reused, and a stale handle reports invalid.
303func TestCaptureLeaseReused(t *testing.T) {
304	r, err := New(8, 8)
305	if err != nil {
306		t.Skipf("Vulkan unavailable: %v", err)
307	}
308	defer r.Close()
309	if err := render(r, ports.Scene{Background: "#0000ff"}, nil); err != nil {
310		t.Fatal(err)
311	}
312	first := captureWait(t, r)
313	r.EndCapture(first)
314	second := captureWait(t, r)
315	if second != first {
316		t.Fatal("lease object not reused")
317	}
318	r.EndCapture(second)
319	dst := make([]byte, 4)
320	if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil {
321		t.Fatal("stale lease read accepted")
322	}
323}
324
325// pollSyncFile waits for a sync file without allocating (syncfile.Wait
326// builds its poll set per call).
327func pollSyncFile(f *os.File, fds []unix.PollFd) error {
328	fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN}
329	for {
330		n, err := unix.Poll(fds, 1000)
331		switch {
332		case errors.Is(err, unix.EINTR):
333		case err != nil:
334			return err
335		case n > 0 && fds[0].Revents&unix.POLLIN != 0:
336			return nil
337		case n > 0:
338			return errors.New("sync file poll error")
339		default:
340			return errors.New("sync file poll timeout")
341		}
342	}
343}
344
345// openFDs counts the process's open descriptors.
346func openFDs(t *testing.T) int {
347	t.Helper()
348	ents, err := os.ReadDir("/proc/self/fd")
349	if err != nil {
350		t.Skipf("no /proc/self/fd: %v", err)
351	}
352	return len(ents)
353}
354
355// The renderer capture cycle (lease, wait for the copy, read the rows,
356// return the lease) allocates nothing once the slots exist, on the SDR and
357// the HDR conversion path, and holds a constant number of descriptors: the
358// slot keeps its sync file for every lease. No mock stands in the measured
359// closure.
360func TestCaptureCycleAllocations(t *testing.T) {
361	for _, tc := range []struct {
362		name string
363		new  func(*testing.T) *Renderer
364	}{
365		{"sdr", func(t *testing.T) *Renderer {
366			r, err := New(16, 8)
367			if err != nil {
368				t.Skipf("Vulkan unavailable: %v", err)
369			}
370			t.Cleanup(r.Close)
371			return r
372		}},
373		{"hdr", hdrTestRenderer},
374	} {
375		t.Run(tc.name, func(t *testing.T) {
376			r := tc.new(t)
377			requireCaptureSync(t, r)
378			if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil {
379				t.Fatal(err)
380			}
381			dst := make([]byte, 16*8*4)
382			fds := make([]unix.PollFd, 1)
383			region := image.Rect(0, 0, 16, 8)
384			var cycleErr error
385			cycle := func() {
386				cf, err := r.BeginCapture()
387				if err != nil {
388					cycleErr = err
389					return
390				}
391				if done := cf.Done(); done != nil {
392					if err := pollSyncFile(done, fds); err != nil {
393						cycleErr = err
394					}
395				}
396				if err := cf.Read(region, dst, 16*4); err != nil {
397					cycleErr = err
398				}
399				r.EndCapture(cf)
400			}
401			cycle() // creates the slots, the capture pass and the sync files
402			cycle()
403			if cycleErr != nil {
404				t.Fatal(cycleErr)
405			}
406			fdsBefore := openFDs(t)
407			if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
408				t.Errorf("%.1f allocations per capture cycle, want 0", allocs)
409			}
410			if cycleErr != nil {
411				t.Fatal(cycleErr)
412			}
413			if after := openFDs(t); after != fdsBefore {
414				t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore)
415			}
416			if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 {
417				t.Error("capture read back black")
418			}
419		})
420	}
421}
422
423// adoptSync takes ownership of the descriptor it is given: the first one is
424// wrapped, a later one is moved onto the same descriptor number and its
425// source is closed, so nothing leaks and nothing is closed twice.
426func TestCaptureAdoptSync(t *testing.T) {
427	pipe := func() (r, w int) {
428		var p [2]int
429		if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
430			t.Fatal(err)
431		}
432		return p[0], p[1]
433	}
434	r1, w1 := pipe()
435	r2, w2 := pipe()
436	t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) })
437	var s captureSlot
438	if err := s.adoptSync(r1); err != nil {
439		t.Fatal(err)
440	}
441	if s.syncFD != r1 || s.file == nil {
442		t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1)
443	}
444	file := s.file
445	if err := s.adoptSync(r2); err != nil {
446		t.Fatal(err)
447	}
448	if s.file != file || s.syncFD != r1 {
449		t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD)
450	}
451	if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF {
452		t.Errorf("source descriptor still open after the move: %v", err)
453	}
454	if _, err := unix.Write(w2, []byte{7}); err != nil {
455		t.Fatal(err)
456	}
457	var b [1]byte
458	if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 {
459		t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err)
460	}
461	// A failed move still closes the source.
462	r3, w3 := pipe()
463	t.Cleanup(func() { _ = unix.Close(w3) })
464	bad := captureSlot{file: file, syncFD: -1}
465	if err := bad.adoptSync(r3); err == nil {
466		t.Error("move onto an invalid descriptor accepted")
467	}
468	if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF {
469		t.Errorf("source descriptor leaked by a failed move: %v", err)
470	}
471	if err := file.Close(); err != nil {
472		t.Errorf("slot file close: %v", err)
473	}
474}
475
476// A slot keeps one descriptor for its sync files: it is the same number and
477// the same file for every lease, closed once by Close, and a lease the
478// device finished at export (no sync file) hands out none.
479func TestCaptureSyncFileOwnership(t *testing.T) {
480	r, err := New(8, 8)
481	if err != nil {
482		t.Skipf("Vulkan unavailable: %v", err)
483	}
484	requireCaptureSync(t, r)
485	closed := false
486	defer func() {
487		if !closed {
488			r.Close()
489		}
490	}()
491	if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
492		t.Fatal(err)
493	}
494	before := openFDs(t)
495	cf := captureWait(t, r).(*captureFrame)
496	s := cf.slot
497	file, fd := cf.Done(), s.syncFD
498	if file == nil || s.file != file {
499		t.Fatalf("first lease: done %v, slot file %v", file, s.file)
500	}
501	r.EndCapture(cf)
502	if cf.Done() != nil {
503		t.Error("ended lease still exposes the sync file")
504	}
505	if _, err := file.Stat(); err != nil {
506		t.Errorf("EndCapture closed the slot's sync file: %v", err)
507	}
508	for range 4 {
509		next := captureWait(t, r).(*captureFrame)
510		if next.slot == s {
511			if next.Done() != file || s.syncFD != fd {
512				t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd)
513			}
514		}
515		r.EndCapture(next)
516	}
517	// Two slots, each holding one sync file.
518	if held := openFDs(t) - before; held > captureSlots {
519		t.Errorf("%d descriptors held by %d slots", held, captureSlots)
520	}
521	r.Close()
522	closed = true
523	if _, err := file.Stat(); err == nil {
524		t.Error("Close left the slot's sync file open")
525	}
526	if got := openFDs(t); got > before {
527		t.Errorf("%d descriptors open after Close, %d before the captures", got, before)
528	}
529}
530
531// A failed sync file export fails the capture without waiting: the slot
532// stays pending, its semaphore is kept until the fence polls signalled,
533// and only then replaced.
534func TestCaptureExportFailureKeepsSlotPending(t *testing.T) {
535	r, err := New(8, 8)
536	if err != nil {
537		t.Skipf("Vulkan unavailable: %v", err)
538	}
539	defer r.Close()
540	requireCaptureSync(t, r)
541	if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
542		t.Fatal(err)
543	}
544	m := newMockcaptureSync(t)
545	r.sync = m
546	dev := deviceSync{r}
547	// Slots are created on this first capture: their semaphores too.
548	m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Times(captureSlots)
549	m.EXPECT().exportSyncFD(mock.Anything).Return(-1, vk.ErrorOutOfHostMemory).Once()
550	if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
551		t.Fatalf("export failure: %v", err)
552	}
553	s := r.captures[0]
554	old := s.done
555	if !s.pending || s.leased || !s.replaceDone || s.done == 0 {
556		t.Fatalf("slot after export failure: leased=%v pending=%v replace=%v", s.leased, s.pending, s.replaceDone)
557	}
558	// While the copy runs the slot is skipped and its semaphore is not
559	// touched (no destroy expected yet).
560	m.EXPECT().fenceStatus(s.fence).Return(vk.Timeout).Once()
561	m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
562	second := captureWait(t, r) // the other slot
563	if second.(*captureFrame).slot == s {
564		t.Fatal("pending slot reused")
565	}
566	r.EndCapture(second)
567	// Once the fence polls signalled the semaphore is replaced (the old
568	// one destroyed only now) and the slot works again.
569	if err := checked("vkWaitForFences", r.dd.WaitForFences(r.device, 1, &s.fence, 1, math.MaxUint64)); err != nil {
570		t.Fatal(err)
571	}
572	m.EXPECT().fenceStatus(s.fence).RunAndReturn(dev.fenceStatus).Once()
573	m.EXPECT().resetFence(s.fence).RunAndReturn(dev.resetFence).Once()
574	m.EXPECT().destroySemaphore(old).Run(dev.destroySemaphore).Once()
575	boom := errors.New("replacement failed")
576	m.EXPECT().newSemaphore().Return(vk.Semaphore(0), boom).Once()
577	if _, err := r.BeginCapture(); !errors.Is(err, boom) {
578		t.Fatalf("semaphore replacement error: %v", err)
579	}
580	if s.pending || !s.replaceDone || s.done != 0 {
581		t.Fatal("failed replacement lost retry state")
582	}
583	// Retry after the fence was already reset. No second destruction or
584	// fence poll is needed, and no null semaphore may be submitted.
585	m.EXPECT().newSemaphore().RunAndReturn(dev.newSemaphore).Once()
586	m.EXPECT().exportSyncFD(mock.Anything).RunAndReturn(dev.exportSyncFD).Once()
587	third := captureWait(t, r)
588	if third.(*captureFrame).slot != s || s.replaceDone {
589		t.Fatal("recovered slot not reused")
590	}
591	r.EndCapture(third)
592	// Close destroys both semaphores.
593	m.EXPECT().destroySemaphore(mock.Anything).Run(dev.destroySemaphore).Times(captureSlots)
594}
595
596// A device error from the fence poll or reset propagates instead of
597// being taken as busy.
598func TestCapturePollErrorPropagates(t *testing.T) {
599	r, err := New(8, 8)
600	if err != nil {
601		t.Skipf("Vulkan unavailable: %v", err)
602	}
603	defer r.Close()
604	if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil {
605		t.Fatal(err)
606	}
607	a := captureWait(t, r)
608	r.EndCapture(a)
609	m := newMockcaptureSync(t)
610	r.sync = m
611	m.EXPECT().fenceStatus(mock.Anything).Return(vk.ErrorDeviceLost).Once()
612	if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
613		t.Fatalf("poll error: %v", err)
614	}
615	m.EXPECT().fenceStatus(mock.Anything).Return(vk.Success).Once()
616	m.EXPECT().resetFence(mock.Anything).Return(vk.ErrorOutOfDeviceMemory).Once()
617	if _, err := r.BeginCapture(); err == nil || errors.Is(err, ports.ErrCaptureBusy) {
618		t.Fatalf("reset error: %v", err)
619	}
620	// Close destroys the semaphores through the seam.
621	m.EXPECT().destroySemaphore(mock.Anything).Run(deviceSync{r}.destroySemaphore).Times(captureSlots)
622}
623
624// An fp16 client with NaN, +Inf and negative texels: the capture pass
625// maps NaN to black, bounds infinity to the shoulder's ceiling and lifts
626// the negative one to black, on the GPU as in captureSDR.
627func TestCaptureHDRNaNInf(t *testing.T) {
628	r := hdrTestRenderer(t)
629	format := ports.DMABufFormat{Format: fourcc('A', 'B', '4', 'H'), Modifier: 0}
630	if !slices.Contains(r.DMABuf().Formats, format) {
631		t.Skip("ABGR16161616F linear import unavailable")
632	}
633	// x<20: NaN; 20..40: +Inf; 40..: -2.0. Alpha 1.
634	texel := func(x int) uint16 {
635		switch {
636		case x < 20:
637			return 0x7e00
638		case x < 40:
639			return 0x7c00
640		}
641		return 0xc000
642	}
643	f := udmabuf16(t, 64, 16, texel)
644	buf := &ports.DMABuf{ID: 165, Width: 64, Height: 16, Format: format.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 512}}}
645	c := ports.SurfaceContent{ID: 1, Width: 64, Height: 16, Opaque: true, DMABuf: buf, Color: ports.SurfaceColor{TF: ports.ColorTFExtendedLinear, Primaries: ports.ColorPrimariesSRGB}}
646	scene := ports.Scene{Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 16}}}}
647	if err := render(r, scene, map[ports.WindowID]ports.SurfaceContent{1: c}); err != nil {
648		t.Fatal(err)
649	}
650	img := readPixels(t, r)
651	for _, tc := range []struct {
652		x    int
653		want [3]uint8
654	}{
655		{5, captureSDR([3]float64{math.NaN(), math.NaN(), math.NaN()})},
656		{25, captureSDR([3]float64{math.Inf(1), math.Inf(1), math.Inf(1)})},
657		{45, captureSDR([3]float64{-2, -2, -2})},
658	} {
659		got := img.RGBAAt(tc.x, 8)
660		if !near(got, color.RGBA{tc.want[0], tc.want[1], tc.want[2], 255}, 2) {
661			t.Errorf("x=%d got %v want %v", tc.x, got, tc.want)
662		}
663	}
664	if img.RGBAAt(5, 8) != (color.RGBA{0, 0, 0, 255}) || img.RGBAAt(45, 8) != (color.RGBA{0, 0, 0, 255}) {
665		t.Errorf("NaN %v / negative %v not black", img.RGBAAt(5, 8), img.RGBAAt(45, 8))
666	}
667	if inf := img.RGBAAt(25, 8); inf.R != 255 {
668		// The shoulder's ceiling rounds to 255 at infinity.
669		t.Errorf("+Inf %v", inf)
670	}
671}
672
673// udmabuf16 is a linear RGBA16F client buffer whose RGB texels are v(x).
674func udmabuf16(t *testing.T, w, h int, v func(x int) uint16) *os.File {
675	t.Helper()
676	pixels := make([]byte, w*h*8)
677	for y := range h {
678		for x := range w {
679			i := (y*w + x) * 8
680			for ch := range 3 {
681				binary.LittleEndian.PutUint16(pixels[i+ch*2:], v(x))
682			}
683			binary.LittleEndian.PutUint16(pixels[i+6:], 0x3c00)
684		}
685	}
686	mem, err := unix.MemfdCreate("fp16-client", unix.MFD_ALLOW_SEALING)
687	if err != nil {
688		t.Fatal(err)
689	}
690	defer unix.Close(mem)
691	if err := unix.Ftruncate(mem, int64(len(pixels))); err != nil {
692		t.Fatal(err)
693	}
694	if _, err := unix.Pwrite(mem, pixels, 0); err != nil {
695		t.Fatal(err)
696	}
697	if _, err := unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK); err != nil {
698		t.Fatal(err)
699	}
700	dev, err := os.OpenFile("/dev/udmabuf", os.O_RDWR, 0)
701	if err != nil {
702		t.Skipf("udmabuf unavailable: %v", err)
703	}
704	defer dev.Close()
705	arg := struct {
706		memfd, flags uint32
707		offset, size uint64
708	}{memfd: uint32(mem), flags: 1, size: uint64(len(pixels))}
709	fd, _, errno := unix.Syscall(unix.SYS_IOCTL, dev.Fd(), 0x40187542, uintptr(unsafe.Pointer(&arg)))
710	if errno != 0 {
711		t.Skipf("udmabuf create: %v", errno)
712	}
713	f := os.NewFile(fd, "fp16-client")
714	t.Cleanup(func() { f.Close() })
715	return f
716}
717
718// Toggling HDR while a capture is leased: the lease's mapping stays
719// readable, the next captures follow the new mode, and a solid colour
720// round-trips SDR → HDR → SDR within the capture curve's tolerance.
721func TestCaptureSurvivesHDRToggle(t *testing.T) {
722	r := hdrTestRenderer(t) // HDR, exported target
723	scene := ports.Scene{Seq: 1, Background: "#c08040"}
724	if err := render(r, scene, nil); err != nil {
725		t.Fatal(err)
726	}
727	hdrShot := readPixels(t, r).RGBAAt(3, 3)
728	leased := captureWait(t, r) // HDR capture held across the toggle
729	r.SetHDR(0)
730	if _, err := r.ExportTargets(0, nil, false); err != nil {
731		t.Fatal(err)
732	}
733	scene.Seq++
734	if err := render(r, scene, nil); err != nil {
735		t.Fatal(err)
736	}
737	sdrShot := readPixels(t, r).RGBAAt(3, 3)
738	dst := make([]byte, 4)
739	if err := leased.Read(image.Rect(3, 3, 4, 4), dst, 4); err != nil {
740		t.Fatal(err)
741	}
742	if got := (color.RGBA{dst[2], dst[1], dst[0], 255}); got != hdrShot {
743		t.Fatalf("leased HDR capture changed by the toggle: %v vs %v", got, hdrShot)
744	}
745	r.EndCapture(leased)
746	// The SDR frame is exact; the HDR capture of the same colour is
747	// within the curve's rounding (coverage of the transition, not a
748	// claim about the curve's look).
749	if sdrShot != (color.RGBA{0xc0, 0x80, 0x40, 255}) {
750		t.Fatalf("SDR %v", sdrShot)
751	}
752	if !near(hdrShot, sdrShot, 10) {
753		t.Fatalf("HDR capture %v vs SDR %v", hdrShot, sdrShot)
754	}
755	// Back to HDR: the capture pass and slots are reused.
756	r.SetHDR(203)
757	if _, err := r.ExportTargets(1, r.hdrMods, false); err != nil {
758		t.Skipf("no HDR target: %v", err)
759	}
760	scene.Seq++
761	if err := render(r, scene, nil); err != nil {
762		t.Fatal(err)
763	}
764	if again := readPixels(t, r).RGBAAt(3, 3); again != hdrShot {
765		t.Fatalf("HDR capture after the round trip %v, before %v", again, hdrShot)
766	}
767}

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 26

Tags 10

↑↓ moveenter openesc close