detached 28f84a4

test(ui): add GPU Renderer tests and TestAllocRendererSteadyFrame

Offscreen pixel readback, release eventfd, pool reuse, resize retirement
and the steady-frame allocation guard (measured 244, baseline 244).
commit
28f84a449354a2c4355ea17b3864670e0be115b9
author
brice · · 2026-10-01 16:32 UTC
parent
62a7058
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +229 -1
A internal/ui/renderer_gpu_test.go +229 -0
@@ -0,0 +1,229 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "os"
7+ "slices"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/nefergui/internal/presentation/session"
12+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
13+ "github.com/bnema/nefergui/internal/text"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// gpuRenderer builds a real Renderer on the render node named by
18+// NEFERGUI_RENDER_NODE (skips without it) with readback enabled.
19+func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
20+ t.Helper()
21+ path := os.Getenv("NEFERGUI_RENDER_NODE")
22+ if path == "" {
23+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
24+ }
25+ var stat unix.Stat_t
26+ if err := unix.Stat(path, &stat); err != nil {
27+ t.Fatal(err)
28+ }
29+ dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev)))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ mods, err := dev.ExportableModifiers()
34+ dev.Close()
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ var formats []Format
39+ for m := range mods {
40+ formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
41+ }
42+ slices.SortFunc(formats, func(a, b Format) int { return int(a.Modifier>>1) - int(b.Modifier>>1) })
43+ vf := make([]vkdevice.Format, len(formats))
44+ for i, f := range formats {
45+ vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
46+ }
47+ tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf})
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ tg.EnableReadback()
52+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
53+ if err != nil {
54+ _ = tg.Close()
55+ t.Fatal(err)
56+ }
57+ t.Cleanup(func() { _ = r.Close() })
58+ return r, tg
59+}
60+
61+func readable(t testing.TB, fd int, timeout time.Duration) bool {
62+ t.Helper()
63+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
64+ n, err := unix.Poll(fds, int(timeout.Milliseconds()))
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ return n > 0
69+}
70+
71+// signalRelease plays the compositor: it signals the buffer's release point.
72+func signalRelease(t testing.TB, tg *session.Target, out *Output) {
73+ t.Helper()
74+ slot := tg.Slots[out.Buffer]
75+ if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
76+ t.Fatal(err)
77+ }
78+}
79+
80+func TestRendererGPUPixelAndRelease(t *testing.T) {
81+ r, tg := gpuRenderer(t)
82+ var out Output
83+ var m struct{}
84+ view := func(f *Frame, _ *struct{}) {
85+ f.Root().Box(Inline("width:50px;height:50px;background:#336699"))
86+ }
87+ r.Resize(100, 80, 1)
88+ ok, err := r.Render(&out, &m, view)
89+ if err != nil || !ok {
90+ t.Fatalf("render: ok=%v err=%v", ok, err)
91+ }
92+ if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 ||
93+ out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 ||
94+ out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint {
95+ t.Fatalf("output: %+v", out)
96+ }
97+ img, err := tg.Readback(out.Buffer)
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 }
102+ px := img.NRGBAAt(10, 10)
103+ if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) {
104+ t.Fatalf("pixel inside box = %+v", px)
105+ }
106+ if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 {
107+ t.Fatalf("pixel outside box = %+v", px)
108+ }
109+ // Nothing is released until the compositor signals the point.
110+ if readable(t, out.ReleaseFD, 20*time.Millisecond) {
111+ t.Fatal("release eventfd readable before the point signaled")
112+ }
113+ signalRelease(t, tg, &out)
114+ if !readable(t, out.ReleaseFD, time.Second) {
115+ t.Fatal("release eventfd not readable after the release point signaled")
116+ }
117+ if err = r.Released(out.Buffer); err != nil {
118+ t.Fatal(err)
119+ }
120+ if readable(t, out.ReleaseFD, 0) {
121+ t.Fatal("release eventfd still readable after Released")
122+ }
123+ // The same buffer comes back with stable descriptors and no re-import.
124+ first := out.Buffer
125+ r.Invalidate()
126+ seen := map[uint64]bool{first: true}
127+ for i := 0; i < 3 && len(seen) < 3; i++ {
128+ r.Invalidate()
129+ ok, err = r.Render(&out, &m, view)
130+ if err != nil || !ok {
131+ t.Fatalf("render %d: ok=%v err=%v", i, ok, err)
132+ }
133+ seen[out.Buffer] = true
134+ if !out.NewBuffer && out.NewTimelines {
135+ t.Fatal("timelines announced for a known buffer")
136+ }
137+ }
138+ if len(seen) != 3 {
139+ t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen)
140+ }
141+ // All three are compositor owned: no buffer is free, Render must not draw.
142+ r.Invalidate()
143+ if ok, err = r.Render(&out, &m, view); ok || err != nil {
144+ t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err)
145+ }
146+}
147+
148+func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
149+ r, tg := gpuRenderer(t)
150+ var out Output
151+ var m struct{}
152+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
153+ r.Resize(64, 64, 1)
154+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
155+ t.Fatalf("ok=%v err=%v", ok, err)
156+ }
157+ old := out.Buffer
158+ signalRelease(t, tg, &out)
159+ if err := r.Released(old); err != nil {
160+ t.Fatal(err)
161+ }
162+ r.Resize(96, 64, 1)
163+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
164+ t.Fatalf("after resize: ok=%v err=%v", ok, err)
165+ }
166+ if out.Width != 96 || !out.NewBuffer || out.Buffer == old {
167+ t.Fatalf("resize output: %+v", out)
168+ }
169+ found := false
170+ for _, rt := range out.Retired {
171+ found = found || rt.Buffer == old
172+ }
173+ if !found {
174+ t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired)
175+ }
176+}
177+
178+// allocBaselineRenderer is the measured allocation count of one steady
179+// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the
180+// target is 0, the remainder is frame build, layout and prepare.
181+const allocBaselineRenderer = 244
182+
183+// TestAllocRendererSteadyFrame measures Render with a view whose number
184+// changes on every frame and an unchanged structure. It runs on the real GPU
185+// path because testify mocks would allocate.
186+func TestAllocRendererSteadyFrame(t *testing.T) {
187+ r, tg := gpuRenderer(t)
188+ var out Output
189+ n := 0
190+ view := func(f *Frame, n *int) {
191+ root := f.Root()
192+ root.Heading("Counter")
193+ root.TextInt("Value: ", int64(*n))
194+ root.Button("Go", Key("go"))
195+ }
196+ r.Resize(160, 120, 1)
197+ step := func() {
198+ n++
199+ r.Invalidate()
200+ // A false result can only mean the GPU still works on the previous
201+ // frame of every free buffer; the application retries shortly.
202+ ok := false
203+ for tries := 0; !ok && tries < 1000; tries++ {
204+ var err error
205+ if ok, err = r.Render(&out, &n, view); err != nil {
206+ t.Fatal(err)
207+ }
208+ if !ok {
209+ time.Sleep(time.Millisecond)
210+ }
211+ }
212+ if !ok {
213+ t.Fatal("render never found a ready buffer")
214+ }
215+ slot := tg.Slots[out.Buffer]
216+ _ = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint)
217+ if err := r.Released(out.Buffer); err != nil {
218+ t.Fatal(err)
219+ }
220+ }
221+ for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
222+ step()
223+ }
224+ got := testing.AllocsPerRun(50, step)
225+ t.Logf("allocs per steady Render+Released cycle: %v", got)
226+ if got > allocBaselineRenderer {
227+ t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
228+ }
229+}
@@ -0,0 +1,229 @@
1+//go:build linux
2+
3+package ui
4+
5+import (
6+ "os"
7+ "slices"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/nefergui/internal/presentation/session"
12+ "github.com/bnema/nefergui/internal/presentation/vkdevice"
13+ "github.com/bnema/nefergui/internal/text"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// gpuRenderer builds a real Renderer on the render node named by
18+// NEFERGUI_RENDER_NODE (skips without it) with readback enabled.
19+func gpuRenderer(t testing.TB) (*Renderer, *session.Target) {
20+ t.Helper()
21+ path := os.Getenv("NEFERGUI_RENDER_NODE")
22+ if path == "" {
23+ t.Skip("opt in with NEFERGUI_RENDER_NODE=/dev/dri/renderD...")
24+ }
25+ var stat unix.Stat_t
26+ if err := unix.Stat(path, &stat); err != nil {
27+ t.Fatal(err)
28+ }
29+ dev, err := vkdevice.Open(vkdevice.DeviceIdentity(uint64(stat.Rdev)))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ mods, err := dev.ExportableModifiers()
34+ dev.Close()
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ var formats []Format
39+ for m := range mods {
40+ formats = append(formats, Format{FourCC: vkdevice.XRGB8888, Modifier: m})
41+ }
42+ slices.SortFunc(formats, func(a, b Format) int { return int(a.Modifier>>1) - int(b.Modifier>>1) })
43+ vf := make([]vkdevice.Format, len(formats))
44+ for i, f := range formats {
45+ vf[i] = vkdevice.Format{FourCC: f.FourCC, Modifier: f.Modifier}
46+ }
47+ tg, err := session.NewTarget(session.TargetConfig{MainDevice: uint64(stat.Rdev), Formats: vf})
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ tg.EnableReadback()
52+ r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
53+ if err != nil {
54+ _ = tg.Close()
55+ t.Fatal(err)
56+ }
57+ t.Cleanup(func() { _ = r.Close() })
58+ return r, tg
59+}
60+
61+func readable(t testing.TB, fd int, timeout time.Duration) bool {
62+ t.Helper()
63+ fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
64+ n, err := unix.Poll(fds, int(timeout.Milliseconds()))
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ return n > 0
69+}
70+
71+// signalRelease plays the compositor: it signals the buffer's release point.
72+func signalRelease(t testing.TB, tg *session.Target, out *Output) {
73+ t.Helper()
74+ slot := tg.Slots[out.Buffer]
75+ if err := tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
76+ t.Fatal(err)
77+ }
78+}
79+
80+func TestRendererGPUPixelAndRelease(t *testing.T) {
81+ r, tg := gpuRenderer(t)
82+ var out Output
83+ var m struct{}
84+ view := func(f *Frame, _ *struct{}) {
85+ f.Root().Box(Inline("width:50px;height:50px;background:#336699"))
86+ }
87+ r.Resize(100, 80, 1)
88+ ok, err := r.Render(&out, &m, view)
89+ if err != nil || !ok {
90+ t.Fatalf("render: ok=%v err=%v", ok, err)
91+ }
92+ if !out.NewBuffer || !out.NewTimelines || out.Width != 100 || out.Height != 80 || out.PlaneCount != 1 ||
93+ out.Planes[0].FD < 0 || out.Planes[0].Stride < 400 || out.FourCC != vkdevice.XRGB8888 ||
94+ out.Acquire.FD < 0 || out.Release.FD < 0 || out.ReleaseFD < 0 || out.AcquirePoint == 0 || out.ReleasePoint <= out.AcquirePoint {
95+ t.Fatalf("output: %+v", out)
96+ }
97+ img, err := tg.Readback(out.Buffer)
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ near := func(a, b uint8) bool { return int(a)-int(b) <= 1 && int(b)-int(a) <= 1 }
102+ px := img.NRGBAAt(10, 10)
103+ if !near(px.R, 0x33) || !near(px.G, 0x66) || !near(px.B, 0x99) {
104+ t.Fatalf("pixel inside box = %+v", px)
105+ }
106+ if px = img.NRGBAAt(90, 70); px.R != 0 || px.G != 0 || px.B != 0 {
107+ t.Fatalf("pixel outside box = %+v", px)
108+ }
109+ // Nothing is released until the compositor signals the point.
110+ if readable(t, out.ReleaseFD, 20*time.Millisecond) {
111+ t.Fatal("release eventfd readable before the point signaled")
112+ }
113+ signalRelease(t, tg, &out)
114+ if !readable(t, out.ReleaseFD, time.Second) {
115+ t.Fatal("release eventfd not readable after the release point signaled")
116+ }
117+ if err = r.Released(out.Buffer); err != nil {
118+ t.Fatal(err)
119+ }
120+ if readable(t, out.ReleaseFD, 0) {
121+ t.Fatal("release eventfd still readable after Released")
122+ }
123+ // The same buffer comes back with stable descriptors and no re-import.
124+ first := out.Buffer
125+ r.Invalidate()
126+ seen := map[uint64]bool{first: true}
127+ for i := 0; i < 3 && len(seen) < 3; i++ {
128+ r.Invalidate()
129+ ok, err = r.Render(&out, &m, view)
130+ if err != nil || !ok {
131+ t.Fatalf("render %d: ok=%v err=%v", i, ok, err)
132+ }
133+ seen[out.Buffer] = true
134+ if !out.NewBuffer && out.NewTimelines {
135+ t.Fatal("timelines announced for a known buffer")
136+ }
137+ }
138+ if len(seen) != 3 {
139+ t.Fatalf("expected a pool of 3 distinct buffers, saw %v", seen)
140+ }
141+ // All three are compositor owned: no buffer is free, Render must not draw.
142+ r.Invalidate()
143+ if ok, err = r.Render(&out, &m, view); ok || err != nil {
144+ t.Fatalf("render with no free buffer: ok=%v err=%v", ok, err)
145+ }
146+}
147+
148+func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
149+ r, tg := gpuRenderer(t)
150+ var out Output
151+ var m struct{}
152+ view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
153+ r.Resize(64, 64, 1)
154+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
155+ t.Fatalf("ok=%v err=%v", ok, err)
156+ }
157+ old := out.Buffer
158+ signalRelease(t, tg, &out)
159+ if err := r.Released(old); err != nil {
160+ t.Fatal(err)
161+ }
162+ r.Resize(96, 64, 1)
163+ if ok, err := r.Render(&out, &m, view); !ok || err != nil {
164+ t.Fatalf("after resize: ok=%v err=%v", ok, err)
165+ }
166+ if out.Width != 96 || !out.NewBuffer || out.Buffer == old {
167+ t.Fatalf("resize output: %+v", out)
168+ }
169+ found := false
170+ for _, rt := range out.Retired {
171+ found = found || rt.Buffer == old
172+ }
173+ if !found {
174+ t.Fatalf("released old-generation buffer %d not retired: %+v", old, out.Retired)
175+ }
176+}
177+
178+// allocBaselineRenderer is the measured allocation count of one steady
179+// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the
180+// target is 0, the remainder is frame build, layout and prepare.
181+const allocBaselineRenderer = 244
182+
183+// TestAllocRendererSteadyFrame measures Render with a view whose number
184+// changes on every frame and an unchanged structure. It runs on the real GPU
185+// path because testify mocks would allocate.
186+func TestAllocRendererSteadyFrame(t *testing.T) {
187+ r, tg := gpuRenderer(t)
188+ var out Output
189+ n := 0
190+ view := func(f *Frame, n *int) {
191+ root := f.Root()
192+ root.Heading("Counter")
193+ root.TextInt("Value: ", int64(*n))
194+ root.Button("Go", Key("go"))
195+ }
196+ r.Resize(160, 120, 1)
197+ step := func() {
198+ n++
199+ r.Invalidate()
200+ // A false result can only mean the GPU still works on the previous
201+ // frame of every free buffer; the application retries shortly.
202+ ok := false
203+ for tries := 0; !ok && tries < 1000; tries++ {
204+ var err error
205+ if ok, err = r.Render(&out, &n, view); err != nil {
206+ t.Fatal(err)
207+ }
208+ if !ok {
209+ time.Sleep(time.Millisecond)
210+ }
211+ }
212+ if !ok {
213+ t.Fatal("render never found a ready buffer")
214+ }
215+ slot := tg.Slots[out.Buffer]
216+ _ = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint)
217+ if err := r.Released(out.Buffer); err != nil {
218+ t.Fatal(err)
219+ }
220+ }
221+ for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
222+ step()
223+ }
224+ got := testing.AllocsPerRun(50, step)
225+ t.Logf("allocs per steady Render+Released cycle: %v", got)
226+ if got > allocBaselineRenderer {
227+ t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
228+ }
229+}
M internal/ui/renderer_test.go +0 -1
@@ -15,7 +15,6 @@ import (
1515 type testModel struct {
1616 clicks int
1717 rects []Rect
18- inline string
1918 }
2019
2120 func testView(f *Frame, m *testModel) {
@@ -15,7 +15,6 @@ import (
15 type testModel struct {15 type testModel struct {
16 clicks int16 clicks int
17 rects []Rect17 rects []Rect
18- inline string
19 }18 }
20 19
21 func testView(f *Frame, m *testModel) {20 func testView(f *Frame, m *testModel) {

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