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}