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