v0.5.0 5fd5066

renderer_gpu_test.go RawHistoryFolder

1//go:build linux
2
3package ui
4
5import (
6	"cmp"
7	"os"
8	"slices"
9	"testing"
10	"time"
11
12	"github.com/bnema/nefergui/internal/presentation/session"
13	"github.com/bnema/nefergui/internal/presentation/vkdevice"
14	"github.com/bnema/nefergui/internal/text"
15	"golang.org/x/sys/unix"
16)
17
18// gpuRenderer builds a real Renderer on the render node named by
19// NEFERGUI_RENDER_NODE (skips without it) with readback enabled.
20func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
21	t.Helper()
22	path := os.Getenv("NEFERGUI_RENDER_NODE")
23	if path == "" {
24		t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
25	}
26	var stat unix.Stat_t
27	if err := unix.Stat(path, &stat); err != nil {
28		t.Fatal(err)
29	}
30	dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev)))
31	if err != nil {
32		t.Fatal(err)
33	}
34	mods, err := dev.ExportableModifiers()
35	dev.Close()
36	if err != nil {
37		t.Fatal(err)
38	}
39	var formats []Format
40	for m := range mods {
41		formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
42	}
43	slices.SortFunc(formats, func(a, b Format) int { return cmp.Compare(a.Modifier, b.Modifier) })
44	vf := make([]vkdevice.Format, len(formats))
45	for i, f := range formats {
46		vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
47	}
48	tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf})
49	if err != nil {
50		t.Fatal(err)
51	}
52	tg.EnableReadback()
53	r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
54	if err != nil {
55		_ = tg.Close()
56		t.Fatal(err)
57	}
58	t.Cleanup(func() { _ = r.Close() })
59	return r, tg
60}
61
62func readable(t testing.TB, fd int, timeout time.Duration) bool {
63	t.Helper()
64	fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
65	n, err := unix.Poll(fds, int(timeout.Milliseconds()))
66	if err != nil {
67		t.Fatal(err)
68	}
69	return n > 0
70}
71
72// signalRelease plays the compositor: it signals the buffer's release point.
73func signalRelease(t testing.TB, tg *session.Target, out *Output) {
74	t.Helper()
75	slot := tg.Slots[out.Buffer]
76	if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
77		t.Fatal(err)
78	}
79}
80
81func TestRendererGPUPixelAndRelease(t *testing.T) {
82	r, tg := gpuRenderer(t)
83	var out Output
84	var m struct{}
85	view := func(f *Frame, _ *struct{}) {
86		f.Root().Box(Inline("width:50px;height:50px;background:#336699"))
87	}
88	r.Resize(100, 80, 1)
89	ok, err := r.Render(&out, &m, view)
90	if err != nil || !ok {
91		t.Fatalf("render: ok=%v err=%v", ok, err)
92	}
93	if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 ||
94		out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 ||
95		out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint {
96		t.Fatalf("output: %+v", out)
97	}
98	img, err := tg.Readback(out.Buffer)
99	if err != nil {
100		t.Fatal(err)
101	}
102	near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 }
103	px := img.NRGBAAt(10, 10)
104	if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) {
105		t.Fatalf("pixel inside box = %+v", px)
106	}
107	if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 {
108		t.Fatalf("pixel outside box = %+v", px)
109	}
110	// Nothing is released until the compositor signals the point.
111	if readable(t, out.ReleaseFD, 20*time.Millisecond) {
112		t.Fatal("release eventfd readable before the point signaled")
113	}
114	signalRelease(t, tg, &out)
115	if !readable(t, out.ReleaseFD, time.Second) {
116		t.Fatal("release eventfd not readable after the release point signaled")
117	}
118	if err = r.Released(out.Buffer); err != nil {
119		t.Fatal(err)
120	}
121	if readable(t, out.ReleaseFD, 0) {
122		t.Fatal("release eventfd still readable after Released")
123	}
124	// The same buffer comes back with stable descriptors and no re-import.
125	first := out.Buffer
126	r.Invalidate()
127	seen := map[uint64]bool{first: true}
128	for i := 0; i < 3 && len(seen) < 3; i++ {
129		r.Invalidate()
130		ok, err = r.Render(&out, &m, view)
131		if err != nil || !ok {
132			t.Fatalf("render %d: ok=%v err=%v", i, ok, err)
133		}
134		seen[out.Buffer] = true
135		if !out.NewBuffer && out.NewTimelines {
136			t.Fatal("timelines announced for a known buffer")
137		}
138	}
139	if len(seen) != 3 {
140		t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen)
141	}
142	// All three are compositor owned: no buffer is free, Render must not draw.
143	r.Invalidate()
144	if ok, err = r.Render(&out, &m, view); ok || err != nil {
145		t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err)
146	}
147}
148
149func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
150	r, tg := gpuRenderer(t)
151	var out Output
152	var m struct{}
153	view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
154	r.Resize(64, 64, 1)
155	if ok, err := r.Render(&out, &m, view); !ok || err != nil {
156		t.Fatalf("ok=%v err=%v", ok, err)
157	}
158	old := out.Buffer
159	signalRelease(t, tg, &out)
160	if err := r.Released(old); err != nil {
161		t.Fatal(err)
162	}
163	// Retirement waits for the old frame's GPU work; a late one is reported by
164	// a later Render, so let it finish to keep the test deterministic.
165	if err := tg.Slots[old].Frame.Wait(); err != nil {
166		t.Fatal(err)
167	}
168	r.Resize(96, 64, 1)
169	if ok, err := r.Render(&out, &m, view); !ok || err != nil {
170		t.Fatalf("after resize: ok=%v err=%v", ok, err)
171	}
172	if out.Width != 96 || !out.NewBuffer || out.Buffer == old {
173		t.Fatalf("resize output: %+v", out)
174	}
175	found := false
176	for _, rt := range out.Retired {
177		found = found || rt.Buffer == old
178	}
179	if !found {
180		t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired)
181	}
182}
183
184// allocBaselineRenderer is the measured allocation count of one steady
185// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the
186// target is 0, the remainder is frame build, layout and prepare.
187const allocBaselineRenderer = 244
188
189// TestAllocRendererSteadyFrame measures Render with a view whose number
190// changes on every frame and an unchanged structure. It runs on the real GPU
191// path because testify mocks would allocate.
192func TestAllocRendererSteadyFrame(t *testing.T) {
193	if raceEnabled {
194		t.Skip("race instrumentation changes allocation counts")
195	}
196	r, tg := gpuRenderer(t)
197	var out Output
198	n := 0
199	view := func(f *Frame, n *int) {
200		root := f.Root()
201		root.Heading("Counter")
202		root.TextInt("Value: ", int64(*n))
203		root.Button("Go", Key("go"))
204	}
205	r.Resize(160, 120, 1)
206	step := func() {
207		n++
208		r.Invalidate()
209		ok, err := r.Render(&out, &n, view)
210		for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
211			time.Sleep(200 * time.Microsecond)
212			ok, err = r.Render(&out, &n, view)
213		}
214		if err != nil || !ok {
215			t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
216		}
217		slot := tg.Slots[out.Buffer]
218		if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
219			t.Fatal(err)
220		}
221		if err = r.Released(out.Buffer); err != nil {
222			t.Fatal(err)
223		}
224	}
225	for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
226		step()
227	}
228	got := testing.AllocsPerRun(50, step)
229	t.Logf("allocs per steady Render+Released cycle: %v", got)
230	if got > allocBaselineRenderer {
231		t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
232	}
233}
234
235// A readable eventfd whose point is not signaled (a stale notification) must be
236// drained without spinning, and the live registration must still work.
237func TestRendererGPUStaleReleaseNotification(t *testing.T) {
238	r, tg := gpuRenderer(t)
239	var out Output
240	var m struct{}
241	view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
242	r.Resize(64, 64, 1)
243	if ok, err := r.Render(&out, &m, view); !ok || err != nil {
244		t.Fatalf("ok=%v err=%v", ok, err)
245	}
246	one := [8]byte{1}
247	if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
248		t.Fatal(err)
249	}
250	if !readable(t, out.ReleaseFD, time.Second) {
251		t.Fatal("test setup: eventfd not readable")
252	}
253	if err := r.Released(out.Buffer); err != nil {
254		t.Fatal(err)
255	}
256	if readable(t, out.ReleaseFD, 0) {
257		t.Fatal("stale notification left the eventfd readable: the caller would spin")
258	}
259	if tg.Slots[out.Buffer].Buffer.State == "available" {
260		t.Fatal("buffer reused before the compositor released it")
261	}
262	signalRelease(t, tg, &out)
263	if !readable(t, out.ReleaseFD, time.Second) {
264		t.Fatal("live registration lost after a stale notification")
265	}
266	if err := r.Released(out.Buffer); err != nil {
267		t.Fatal(err)
268	}
269	// Released for an unknown or idle buffer is a no-op that still drains.
270	if err := r.Released(999); err != nil {
271		t.Fatal(err)
272	}
273}

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 1

Tags 12

↑↓ moveenter openesc close