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 (radv); it is lowered with each optimization until it
186// reaches the target of 0.
187const allocBaselineRenderer = 22
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 step := steadyFrame(t)
197 got := testing.AllocsPerRun(50, step)
198 t.Logf("allocs per steady Render+Released cycle: %v", got)
199 if got > allocBaselineRenderer {
200 t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
201 }
202}
203
204// BenchmarkRendererSteadyFrame profiles the TestAllocRendererSteadyFrame
205// cycle; it needs NEFERGUI_RENDER_NODE.
206func BenchmarkRendererSteadyFrame(b *testing.B) {
207 step := steadyFrame(b)
208 b.ReportAllocs()
209 for b.Loop() {
210 step()
211 }
212}
213
214// steadyFrame returns one warmed Render+Released cycle for a counter view.
215func steadyFrame(t testing.TB) func() {
216 t.Helper()
217 r, tg := gpuRenderer(t)
218 var out Output
219 n := 0
220 view := func(f *Frame, n *int) {
221 root := f.Root()
222 root.Heading("Counter")
223 root.TextInt("Value: ", int64(*n))
224 root.Button("Go", Key("go"))
225 }
226 r.Resize(160, 120, 1)
227 step := func() {
228 n++
229 r.Invalidate()
230 ok, err := r.Render(&out, &n, view)
231 for tries := 0; err == nil && !ok && r.Pending() && tries < 5000; tries++ {
232 time.Sleep(200 * time.Microsecond)
233 ok, err = r.Render(&out, &n, view)
234 }
235 if err != nil || !ok {
236 t.Fatalf("render: ok=%v err=%v pending=%v", ok, err, r.Pending())
237 }
238 slot := tg.Slots[out.Buffer]
239 if err = tg.Node.Signal(slot.ReleaseHandle, out.ReleasePoint); err != nil {
240 t.Fatal(err)
241 }
242 if err = r.Released(out.Buffer); err != nil {
243 t.Fatal(err)
244 }
245 }
246 for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
247 step()
248 }
249 return step
250}
251
252// A readable eventfd whose point is not signaled (a stale notification) must be
253// drained without spinning, and the live registration must still work.
254func TestRendererGPUStaleReleaseNotification(t *testing.T) {
255 r, tg := gpuRenderer(t)
256 var out Output
257 var m struct{}
258 view := func(f *Frame, _ *struct{}) { f.Root().Box(Inline("width:10px;height:10px;background:#fff")) }
259 r.Resize(64, 64, 1)
260 if ok, err := r.Render(&out, &m, view); !ok || err != nil {
261 t.Fatalf("ok=%v err=%v", ok, err)
262 }
263 one := [8]byte{1}
264 if _, err := unix.Write(out.ReleaseFD, one[:]); err != nil {
265 t.Fatal(err)
266 }
267 if !readable(t, out.ReleaseFD, time.Second) {
268 t.Fatal("test setup: eventfd not readable")
269 }
270 if err := r.Released(out.Buffer); err != nil {
271 t.Fatal(err)
272 }
273 if readable(t, out.ReleaseFD, 0) {
274 t.Fatal("stale notification left the eventfd readable: the caller would spin")
275 }
276 if tg.Slots[out.Buffer].Buffer.State == "available" {
277 t.Fatal("buffer reused before the compositor released it")
278 }
279 signalRelease(t, tg, &out)
280 if !readable(t, out.ReleaseFD, time.Second) {
281 t.Fatal("live registration lost after a stale notification")
282 }
283 if err := r.Released(out.Buffer); err != nil {
284 t.Fatal(err)
285 }
286 // Released for an unknown or idle buffer is a no-op that still drains.
287 if err := r.Released(999); err != nil {
288 t.Fatal(err)
289 }
290}