1package drm
2
3import (
4 "context"
5 "errors"
6 "os"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/stretchr/testify/mock"
14 "golang.org/x/sys/unix"
15)
16
17// runProtectedStartup runs o with the session already protected. k and r are
18// set up with permissive renderer/KMS expectations; inject holds the failure
19// under test.
20func runProtectedStartup(t *testing.T, o *Output, k *mockkms, renders func() (*os.File, error)) (<-chan ports.SecurityBackendEvent, <-chan error, context.CancelFunc) {
21 t.Helper()
22 o.cursor = nil
23 var state atomic.Uint64
24 state.Store(3)
25 securityGate(t, o, &state)
26 events := make(chan ports.SecurityBackendEvent, 32)
27 o.SecurityEvents = events
28 r := portsmocks.NewMockRenderer(t)
29 buf := func() ports.DMABuf {
30 f, w := protectionFence(t, false)
31 w.Close()
32 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
33 }
34 r.EXPECT().SetHDR(float64(0)).Return().Maybe()
35 r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
36 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
37 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
38 return renders()
39 }).Maybe()
40 r.EXPECT().Close().Return().Once()
41 k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Maybe()
42 k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
43 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
44 done := make(chan error, 1)
45 go func() {
46 done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, nil, nil, nil, nil, make(chan ports.OutputPresented, 8), nil, nil)
47 }()
48 return events, done, cancel
49}
50
51func waitProof(t *testing.T, events <-chan ports.SecurityBackendEvent, done <-chan error) {
52 t.Helper()
53 timeout := time.After(4 * time.Second)
54 for {
55 select {
56 case ev := <-events:
57 if _, ok := ev.(ports.SecurityOutputProof); ok {
58 return
59 }
60 case err := <-done:
61 t.Fatalf("output stopped while protected: %v", err)
62 case <-timeout:
63 t.Fatal("no proof after retry")
64 }
65 }
66}
67
68// A protected startup whose first KMS attempt is refused (EBUSY,
69// or a swallowed EINVAL refusal) keeps the output, backs off from that first
70// refusal, and proves only after the accepted retry.
71func TestProtectedStartupRefusalRetriesWithBackoffFromFirstRefusal(t *testing.T) {
72 for _, errno := range []error{unix.EBUSY, unix.EINVAL} {
73 t.Run(errno.Error(), func(t *testing.T) {
74 // Commits: test-only modeset, then the blocking disable (refused).
75 o, k, commits, mu := testOutputMu(t, nil, errno)
76 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
77 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
78 events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) { return nil, nil })
79 defer cancel()
80 waitProof(t, events, done)
81 cancel()
82 if err := <-done; err != nil {
83 t.Fatalf("output stopped: %v", err)
84 }
85 mu.Lock()
86 defer mu.Unlock()
87 var real []commitRec
88 for _, c := range *commits {
89 if c.flags&atomicTestOnly == 0 {
90 real = append(real, c)
91 }
92 }
93 if len(real) < 3 {
94 t.Fatalf("real commits %d", len(real))
95 }
96 if gap := real[1].at.Sub(real[0].at); gap < protectRetryMin {
97 t.Fatalf("retry after %v of the first refusal, want >= %v", gap, protectRetryMin)
98 }
99 })
100 }
101}
102
103// Lost master at protected startup keeps the output running, dark,
104// without proof; it follows the lost-master invalidate path.
105func TestProtectedStartupLostMasterStaysRunningWithoutProof(t *testing.T) {
106 for _, errno := range []error{unix.EACCES, unix.EPERM} {
107 t.Run(errno.Error(), func(t *testing.T) {
108 o, k, _, _ := testOutputMu(t, nil, errno)
109 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
110 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
111 events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) { return nil, nil })
112 defer cancel()
113 select {
114 case err := <-done:
115 t.Fatalf("output stopped on lost master: %v", err)
116 case ev := <-events:
117 if _, ok := ev.(ports.SecurityOutputProof); ok {
118 t.Fatal("proof after lost master")
119 }
120 case <-time.After(300 * time.Millisecond):
121 }
122 cancel()
123 if err := <-done; err != nil {
124 t.Fatalf("output stopped: %v", err)
125 }
126 if o.securityPrepared || !o.protected {
127 t.Fatalf("prepared=%v protected=%v", o.securityPrepared, o.protected)
128 }
129 })
130 }
131}
132
133// Startup failures of detachment, mode blob or clear fence keep
134// the output and recover through the bounded retry; proof only after success.
135func TestProtectedStartupRecoverableFailuresRetry(t *testing.T) {
136 for _, stage := range []string{"detach", "blob", "fence"} {
137 t.Run(stage, func(t *testing.T) {
138 o, k, _, _ := testOutputMu(t)
139 var failures, blobs, objs, renders atomic.Int32
140 switch stage {
141 case "detach":
142 o.stray = []*plane{{id: 53, props: planeProps}}
143 k.EXPECT().objProps(uint32(53), uint32(objPlane)).RunAndReturn(func(uint32, uint32) (map[string][2]uint64, error) {
144 // 1: test modeset request (error ignored); 2: first detach.
145 if objs.Add(1) == 2 {
146 failures.Add(1)
147 return nil, unix.EIO
148 }
149 return map[string][2]uint64{"CRTC_ID": {pCrtcID, 0}}, nil
150 }).Maybe()
151 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
152 case "blob":
153 k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
154 if blobs.Add(1) == 2 { // 1: test modeset; 2: activation
155 failures.Add(1)
156 return 0, unix.ENOMEM
157 }
158 return 99, nil
159 }).Maybe()
160 case "fence":
161 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Maybe()
162 }
163 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
164 events, done, cancel := runProtectedStartup(t, o, k, func() (*os.File, error) {
165 // Renders 1-2 initialise the images; 3 is the first protected clear.
166 if stage != "fence" || renders.Add(1) != 3 {
167 return nil, nil
168 }
169 failures.Add(1)
170 f, w := protectionFence(t, false)
171 w.Close() // HUP without POLLIN is an error, not completion
172 return f, nil
173 })
174 defer cancel()
175 waitProof(t, events, done)
176 if failures.Load() == 0 {
177 t.Fatal("failure was not injected before proof")
178 }
179 cancel()
180 if err := <-done; err != nil {
181 t.Fatalf("output stopped: %v", err)
182 }
183 })
184 }
185}
186
187// Recoverable preparation failures are retryable protected errors
188// handled by commitFailed with backoff; a renderer error stays fatal.
189func TestProtectedPreparationFailuresRetryableRenderFatal(t *testing.T) {
190 boom := errors.New("render broke")
191 for _, stage := range []string{"stray-read", "stray-missing", "blob", "fence", "render"} {
192 t.Run(stage, func(t *testing.T) {
193 o, k, _ := testOutput(t)
194 r := portsmocks.NewMockRenderer(t)
195 switch stage {
196 case "stray-read", "stray-missing":
197 o.stray = []*plane{{id: 53, props: planeProps}}
198 var err error
199 props := map[string][2]uint64{}
200 if stage == "stray-read" {
201 err = unix.EIO
202 }
203 k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(props, err).Once()
204 case "blob":
205 k.EXPECT().createBlob(mock.Anything).Return(0, unix.ENOMEM).Once()
206 r.EXPECT().UseTarget(0).Return().Once()
207 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
208 case "fence":
209 f, w := protectionFence(t, false)
210 w.Close()
211 r.EXPECT().UseTarget(0).Return().Once()
212 r.EXPECT().Render(mock.Anything, mock.Anything).Return(f, nil).Once()
213 case "render":
214 r.EXPECT().UseTarget(0).Return().Once()
215 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, boom).Once()
216 }
217 o.protected = true
218 err := o.protectedModeset(context.Background(), r)
219 if err == nil {
220 t.Fatal("failure reported success")
221 }
222 enabled := true
223 handled := o.commitFailed(err, &enabled)
224 if stage == "render" {
225 if retryableProtected(err) || handled {
226 t.Fatalf("renderer error must stay fatal: retryable=%v handled=%v", retryableProtected(err), handled)
227 }
228 return
229 }
230 if !retryableProtected(err) || !handled || !enabled || !o.protectBackoffActive() || o.securityPrepared {
231 t.Fatalf("not retried with backoff: %v", err)
232 }
233 })
234 }
235}
236
237// A protected EBUSY/lost-master/detach error from showImages is a
238// protected KMS error: it never triggers the HDR->SDR fallback or hdrFailed.
239func TestProtectedShowImagesHDRKeepsHDRForKMSErrors(t *testing.T) {
240 for _, errno := range []error{unix.EBUSY, unix.EACCES, unix.EPERM, unix.EINVAL, nil} {
241 name := "detach"
242 if errno != nil {
243 name = errno.Error()
244 }
245 t.Run(name, func(t *testing.T) {
246 var errs []error
247 if errno != nil {
248 errs = []error{nil, errno} // HDR test commit, then the disable
249 }
250 o, k, _ := testOutput(t, errs...)
251 o.cursor = nil
252 o.protected = true
253 o.hdr.cap = hdrCapability{Capable: true}
254 o.hdr.settings = HDRSettings{Enabled: true, SDRBrightness: 203}
255 o.hdr.props = connectorHDRProps{Metadata: 100, Colorspace: 101, MaxBPC: 102, BT2020Value: 7, HasDefault: true, MaxBPCValue: 8}
256 o.primary.formats = []ports.DMABufFormat{{Format: fourccXR30, Modifier: 0}}
257 if errno == nil {
258 o.primary.props = map[string]uint32{"CRTC_ID": pCrtcID} // incomplete: detach fails
259 }
260 r := portsmocks.NewMockRenderer(t)
261 buf := func() ports.DMABuf {
262 f, w := protectionFence(t, false)
263 w.Close()
264 return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
265 }
266 r.EXPECT().SetHDR(float64(203)).Return().Once()
267 r.EXPECT().UseTarget(mock.Anything).Return().Maybe()
268 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
269 r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
270 k.EXPECT().addFB(mock.Anything, uint32(fourccXR30)).Return(70, nil).Maybe()
271 k.EXPECT().createBlob(mock.Anything).Return(321, nil).Maybe()
272 k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
273 err := o.showImages(r, imagesDriver, nil)
274 if err == nil {
275 t.Fatal("protected KMS failure reported success")
276 }
277 enabled := true
278 lost := errno != nil && lostMaster(errno)
279 if !o.commitFailed(err, &enabled) {
280 t.Fatalf("protected failure not handled: %v", err)
281 }
282 if o.hdr.failed || !o.hdr.on {
283 t.Fatalf("HDR fell back: failed=%v on=%v", o.hdr.failed, o.hdr.on)
284 }
285 if lost == enabled {
286 t.Fatalf("lost master=%v enabled=%v", lost, enabled)
287 }
288 if !o.protected || o.securityPrepared {
289 t.Fatal("protection dropped or certified")
290 }
291 })
292 }
293}