1package drm
2
3import (
4 "runtime"
5 "testing"
6
7 "github.com/bnema/neferwl/internal/ports"
8 "github.com/stretchr/testify/mock"
9 "golang.org/x/sys/unix"
10)
11
12// mallocsPerRun is testing.AllocsPerRun without the integer truncation.
13func mallocsPerRun(runs int, f func()) float64 {
14 f()
15 var before, after runtime.MemStats
16 runtime.GC()
17 runtime.ReadMemStats(&before)
18 for range runs {
19 f()
20 }
21 runtime.ReadMemStats(&after)
22 return float64(after.Mallocs-before.Mallocs) / float64(runs)
23}
24
25func scanoutAllocsOutput(t *testing.T) (*Output, ports.SurfaceContent) {
26 o, _, _ := testOutput(t)
27 o.cursor.image = true
28 o.cursor.Move(12, 34)
29 o.clientFBs[9] = &clientFB{fbID: 80}
30 return o, ports.SurfaceContent{ID: 1, Width: 200, Height: 100, DMABuf: &ports.DMABuf{ID: 9}}
31}
32
33// A steady-state direct-scanout commit builds its request, and the flat
34// ioctl arrays, in buffers it reuses.
35//
36// The real kmsDevice on an invalid fd runs the whole request path up to the
37// failing ioctl: exactly zero allocations. testify's own bookkeeping per
38// mocked call allocates, so the success path (lifecycle, cursor, VRR) is
39// measured on the generated mockkms against the same mocked call made
40// directly: the commit may add nothing to it.
41func TestCommitFrameAllocations(t *testing.T) {
42 t.Run("request and ioctl arrays", func(t *testing.T) {
43 o, c := scanoutAllocsOutput(t)
44 o.k = kmsDevice{fd: -1}
45 rect := fullPlaneRect(200, 100)
46 commit := func() { _, _ = o.commitScanoutRect(80, c, pendingFrame{}, rect, colorBypass) }
47 commit() // grow the buffers
48 if len(o.frameReq.objs) != 3 || len(o.frameReq.counts) != 3 || len(o.frameReq.flatProps) == 0 {
49 t.Fatalf("frame request %+v", o.frameReq)
50 }
51 if n := testing.AllocsPerRun(100, commit); n != 0 {
52 t.Fatalf("direct scanout commit: %.1f allocs, want 0", n)
53 }
54 })
55 t.Run("success path", func(t *testing.T) {
56 if raceEnabled {
57 // The race runtime moves testify's count by a few
58 // allocations per call, as much as the margin below.
59 // perf-check runs it without; the subtest above stays exact.
60 t.Skip("testify allocates unevenly under the race detector")
61 }
62 o, c := scanoutAllocsOutput(t)
63 k := newMockkms(t)
64 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).Return(nil)
65 o.k = k // no recorder: it copies every request
66 rect := fullPlaneRect(200, 100)
67 commit := func() {
68 if _, err := o.commitScanoutRect(80, c, pendingFrame{}, rect, colorBypass); err != nil {
69 t.Fatal(err)
70 }
71 }
72 commit()
73 // The mock boxes flags and userData (a fresh serial per commit):
74 // the reference call passes them as variables too.
75 serial, flags := uint64(1<<20), uint32(atomicNonblock|flipEventFlag)
76 ref := mallocsPerRun(1000, func() {
77 serial += 1 << userKindBits
78 mockCallOuter(k, &o.frameReq, flags, serial|userFrame)
79 })
80 got := mallocsPerRun(1000, commit)
81 t.Logf("commit %.2f allocs, the mocked call alone %.2f", got, ref)
82 // A heap profile puts the whole difference inside testify (its
83 // call-site lookup allocates per stack frame, and the stacks
84 // differ by a frame or so); the first subtest proves the commit
85 // itself allocates zero. A leaked request would add one per
86 // slice and grow far past this margin (before: 9 more).
87 if got-ref > 3 {
88 t.Fatalf("direct scanout commit: %.2f allocs, %.2f of them the mock's", got, ref)
89 }
90 })
91}
92
93// mockCallOuter and mockCallInner stand where commitScanoutRect and
94// commitWithRect stand: testify's call-site lookup allocates per stack
95// frame, so the reference call must be as deep as the real one.
96//
97//go:noinline
98func mockCallOuter(k *mockkms, req *atomicReq, flags uint32, user uint64) {
99 mockCallInner(k, req, flags, user)
100}
101
102//go:noinline
103func mockCallInner(k *mockkms, req *atomicReq, flags uint32, user uint64) {
104 _ = k.commit(req, flags, user)
105}
106
107// A retry resets the frame request and builds it again: the second commit
108// carries the retried frame, not the first one's leftovers, and the caller
109// never reads the request after the recursive call.
110func TestCommitFrameRetryRebuildsRequest(t *testing.T) {
111 o, _, commits := testOutput(t, unix.EINVAL)
112 o.shown, o.vrrOn = 1, true
113 o.clientFBs[9] = &clientFB{fbID: 80}
114 c := ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 9}, Async: true}
115 // The async attempt is refused, then retried synchronously with the
116 // full plane state in the same request object.
117 ok, err := o.commitScanout(80, c, pendingFrame{})
118 if !ok || err != nil || len(*commits) != 2 {
119 t.Fatalf("ok=%v err=%v commits=%d", ok, err, len(*commits))
120 }
121 first, second := (*commits)[0], (*commits)[1]
122 if len(first.req.objs) != 1 || first.req.objs[0] != tPrimary || len(first.req.props[0]) != 1 {
123 t.Fatalf("async request %+v", first.req)
124 }
125 if v, ok := second.req.value(tPrimary, pFB); !ok || v != 80 {
126 t.Fatalf("retry primary fb %d %v", v, ok)
127 }
128 if _, ok := second.req.value(tPrimary, 17); !ok {
129 t.Fatal("retry lost the plane geometry")
130 }
131 if _, ok := second.req.value(tCrtc, pVRR); !ok {
132 t.Fatal("retry lost VRR")
133 }
134 // The retry's request shows no stale entry twice.
135 for i, ps := range second.req.props {
136 seen := map[uint32]bool{}
137 for _, p := range ps {
138 if seen[p.prop] {
139 t.Fatalf("object %d: property %d twice", second.req.objs[i], p.prop)
140 }
141 seen[p.prop] = true
142 }
143 }
144}
145
146// The cursor-refused retry rebuilds the request with the cursor off while
147// the TEST_ONLY probe uses its own request.
148func TestCommitFrameCursorRetryKeepsProbeApart(t *testing.T) {
149 var frameSeen []*atomicReq
150 o, _, _ := testOutput(t)
151 o.cursor.image = true
152 o.cursor.Move(1, 2)
153 // First real commit refused with EINVAL, the cursor probe refused too,
154 // then the retry without the cursor passes.
155 k2 := newMockkms(t)
156 n := 0
157 k2.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, _ uint64) error {
158 n++
159 frameSeen = append(frameSeen, r)
160 if n <= 2 {
161 return unix.EINVAL
162 }
163 return nil
164 })
165 o.k = k2
166 if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
167 t.Fatal(err)
168 }
169 if n != 3 || !o.cursor.off {
170 t.Fatalf("commits %d cursorOff %v", n, o.cursor.off)
171 }
172 if frameSeen[0] != &o.frameReq || frameSeen[1] == &o.frameReq || frameSeen[2] != &o.frameReq {
173 t.Fatal("probe must not reuse the frame request, retry must")
174 }
175 if _, ok := o.frameReq.value(tCursor, pFB); !ok {
176 t.Fatal("cursor plane not turned off in the retry")
177 }
178 if v, _ := o.frameReq.value(tCursor, pFB); v != 0 {
179 t.Fatalf("retry still shows the cursor: fb %d", v)
180 }
181}
182
183// kmsDevice.commit fills the request's own scratch: once grown it allocates
184// nothing. The bad fd makes the ioctl fail before reaching a kernel.
185func TestKMSDeviceCommitAllocations(t *testing.T) {
186 k := kmsDevice{fd: -1}
187 req := &atomicReq{}
188 build := func() {
189 req.reset()
190 for i := range 3 {
191 obj := uint32(10 + i)
192 for p := range 8 {
193 req.set(obj, uint32(p+1), uint64(p))
194 }
195 }
196 }
197 build()
198 if err := k.commit(req, atomicTestOnly, 0); err == nil {
199 t.Fatal("ioctl on fd -1 succeeded")
200 }
201 if len(req.counts) != 3 || len(req.flatProps) != 24 || len(req.flatVals) != 24 || req.counts[1] != 8 {
202 t.Fatalf("scratch counts %v props %d vals %d", req.counts, len(req.flatProps), len(req.flatVals))
203 }
204 if n := testing.AllocsPerRun(100, func() {
205 build()
206 _ = k.commit(req, atomicTestOnly, 0)
207 }); n != 0 {
208 t.Fatalf("build and commit: %.1f allocs", n)
209 }
210}
211
212func TestAtomicReqResetKeepsCapacityAndDropsContent(t *testing.T) {
213 r := &atomicReq{}
214 r.set(1, 1, 5)
215 r.set(1, 2, 6)
216 r.set(2, 1, 7)
217 r.reset()
218 if len(r.objs) != 0 || len(r.props) != 0 {
219 t.Fatalf("not empty: %+v", r)
220 }
221 r.set(2, 9, 1)
222 if len(r.objs) != 1 || len(r.props) != 1 || len(r.props[0]) != 1 || r.props[0][0] != (propValue{9, 1}) {
223 t.Fatalf("stale content after reset: %+v", r)
224 }
225 if _, ok := r.value(1, 1); ok {
226 t.Fatal("stale object")
227 }
228}
229
230// With a plane colour pipeline active, the steady-state frame decision and
231// commit allocate nothing: the colour state is compared, not rebuilt, and a
232// cached verdict spares the TEST_ONLY.
233func TestColorPipelineAllocations(t *testing.T) {
234 t.Run("scanout decision", func(t *testing.T) {
235 o, _, _ := colorOutput(t, false, nil)
236 o.cursor.image = true
237 o.cursor.Move(12, 34)
238 o.clientFBs[9] = &clientFB{fbID: 80}
239 s, surfaces := hdrScanoutScene()
240 d := o.decideFrame(s, surfaces, false) // first use: TEST_ONLY, then cached
241 if d.fb != 80 || d.color != colorSDRToPQ {
242 t.Fatalf("decision %+v reason %q", d, o.reason)
243 }
244 if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
245 t.Fatalf("decision with the pipeline: %.1f allocs, want 0", n)
246 }
247 })
248 t.Run("overlay decision", func(t *testing.T) {
249 o, k, _ := colorOutput(t, true, nil)
250 o.primary.pipeline = nil
251 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(uint32(88), nil).Maybe()
252 s, surfaces := overlayScene()
253 if d := o.decideFrame(s, surfaces, false); d.overlay.fb != 88 || d.overlay.color.mode != colorSDRToPQ {
254 t.Fatalf("decision %+v reason %q", d, o.overlayReason)
255 }
256 if n := testing.AllocsPerRun(100, func() { o.decideFrame(s, surfaces, false) }); n != 0 {
257 t.Fatalf("overlay decision with the pipeline: %.1f allocs, want 0", n)
258 }
259 })
260 // The real kmsDevice on an invalid fd runs the request path up to the
261 // failing ioctl (see TestCommitFrameAllocations).
262 for _, tc := range []struct {
263 name string
264 known bool // the plane applies the pipeline already
265 }{{"commit writing the pipeline", false}, {"steady-state commit", true}} {
266 t.Run(tc.name, func(t *testing.T) {
267 o, _, _ := colorOutput(t, false, nil)
268 c := sdrContent(o)
269 o.cursor.image = true
270 o.cursor.Move(12, 34)
271 o.k = kmsDevice{fd: -1}
272 rect := fullPlaneRect(200, 100)
273 commit := func() {
274 o.primary.colorKnown, o.primary.colorApplied = tc.known, colorSDRToPQ
275 _, _ = o.commitScanoutRect(80, c, pendingFrame{}, rect, colorSDRToPQ)
276 }
277 commit() // grow the buffers
278 if _, ok := o.frameReq.value(tPrimary, pColorPipe); ok == tc.known {
279 t.Fatalf("COLOR_PIPELINE in the request: %v, known %v", ok, tc.known)
280 }
281 if n := testing.AllocsPerRun(100, commit); n != 0 {
282 t.Fatalf("%s: %.1f allocs, want 0", tc.name, n)
283 }
284 })
285 }
286}