1package drm
2
3import (
4 "context"
5 "errors"
6 "os"
7 "testing"
8 "time"
9
10 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11 "github.com/bnema/neferwl/internal/ports"
12 "github.com/stretchr/testify/mock"
13 "golang.org/x/sys/unix"
14)
15
16func protectionFence(t *testing.T, signalled bool) (*os.File, *os.File) {
17 t.Helper()
18 f, w, err := os.Pipe()
19 if err != nil {
20 t.Fatal(err)
21 }
22 t.Cleanup(func() { f.Close(); w.Close() })
23 if signalled {
24 if _, err := w.Write([]byte{1}); err != nil {
25 t.Fatal(err)
26 }
27 }
28 return f, w
29}
30
31func checkProtectedDisable(t *testing.T, c commitRec, planes ...uint32) {
32 t.Helper()
33 if c.flags != atomicAllowModes || c.user != 0 {
34 t.Fatalf("not blocking modeset: flags=%#x user=%d", c.flags, c.user)
35 }
36 if active, ok := c.req.value(tCrtc, pActive); !ok || active != 0 {
37 t.Fatalf("CRTC still active: %d %v", active, ok)
38 }
39 // Full disable: no CRTC enabled without its primary plane (amdgpu rule).
40 if v, ok := c.req.value(tCrtc, pMode); !ok || v != 0 {
41 t.Fatalf("CRTC still enabled: MODE_ID %d %v", v, ok)
42 }
43 if v, ok := c.req.value(tConn, pConnCrtc); !ok || v != 0 {
44 t.Fatalf("connector still routed: CRTC_ID %d %v", v, ok)
45 }
46 for _, p := range planes {
47 for _, prop := range []uint32{pFB, pCrtcID} {
48 if v, ok := c.req.value(p, prop); !ok || v != 0 {
49 t.Fatalf("plane %d prop %d still attached: %d %v", p, prop, v, ok)
50 }
51 }
52 }
53}
54
55// Adapted from the parent's draft: detach, clear, wait, then activate only
56// the cleared target, with no nonblocking commit or desktop cursor test.
57func TestProtectedModesetClearsBeforeActivation(t *testing.T) {
58 o, k, commits := testOutput(t)
59 o.back = 1 // exercise the other target, not just startup's back=0
60 o.overlay = &plane{id: tOverlay, props: planeProps}
61 o.stray = []*plane{o.overlay, {id: 53, props: planeProps}, {id: 54, props: planeProps}}
62 k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
63 k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 99}}, nil)
64 r := portsmocks.NewMockRenderer(t)
65 r.EXPECT().UseTarget(1).Return().Once()
66 f, _ := protectionFence(t, true)
67 // Blob creation is part of activation and must occur after fence close.
68 k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
69 if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) {
70 t.Fatalf("activation before clear fence close: %v", err)
71 }
72 return 99, nil
73 }).Once()
74 r.EXPECT().Render(ports.Scene{Seq: 0, Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
75 if len(*commits) != 1 {
76 t.Fatalf("clear before disable: %d commits", len(*commits))
77 }
78 checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53)
79 if _, ok := (*commits)[0].req.value(54, pFB); ok {
80 t.Fatal("detached another output's plane")
81 }
82 return f, nil
83 }).Once()
84 if err := o.protectedModeset(context.Background(), r); err != nil {
85 t.Fatal(err)
86 }
87 if len(*commits) != 2 {
88 t.Fatalf("commits %d", len(*commits))
89 }
90 last := (*commits)[1]
91 if last.flags != atomicAllowModes || last.user != 0 {
92 t.Fatal("activation was not blocking")
93 }
94 if fb, _ := last.req.value(tPrimary, pFB); fb != 71 {
95 t.Fatalf("activated fb %d, want cleared 71", fb)
96 }
97 if active, _ := last.req.value(tCrtc, pActive); active != 1 {
98 t.Fatal("not activated")
99 }
100 if o.back != 0 || !o.protected || o.off {
101 t.Fatalf("back=%d protected=%v off=%v", o.back, o.protected, o.off)
102 }
103 if _, err := f.Stat(); !errors.Is(err, os.ErrClosed) {
104 t.Fatalf("fence not closed: %v", err)
105 }
106}
107
108func TestProtectedModesetFailureNeverActivates(t *testing.T) {
109 for _, stage := range []string{"disable", "render", "fence", "blob", "activate"} {
110 t.Run(stage, func(t *testing.T) {
111 boom := errors.New("refused")
112 var failures []error
113 if stage == "disable" {
114 failures = []error{unix.EACCES}
115 }
116 if stage == "activate" {
117 failures = []error{nil, unix.EPERM}
118 }
119 o, k, commits := testOutput(t, failures...)
120 r := portsmocks.NewMockRenderer(t)
121 var fence *os.File
122 if stage != "disable" {
123 r.EXPECT().UseTarget(0).Return().Once()
124 f, w := protectionFence(t, stage != "fence")
125 fence = f
126 if stage == "fence" {
127 w.Close() // HUP without POLLIN is not fence completion.
128 }
129 var renderErr error
130 if stage == "render" {
131 renderErr = boom
132 }
133 r.EXPECT().Render(mock.Anything, mock.Anything).Return(fence, renderErr).Once()
134 }
135 if stage == "activate" {
136 k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
137 k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once()
138 }
139 if stage == "blob" {
140 k.EXPECT().createBlob(mock.Anything).Return(0, boom).Once()
141 }
142 if err := o.protectedModeset(context.Background(), r); err == nil {
143 t.Fatal("failure reported success")
144 }
145 if !o.protected || o.back != 0 {
146 t.Fatal("failure dropped protection or flipped target")
147 }
148 if stage != "activate" && len(*commits) != 1 {
149 t.Fatalf("activated after %s failure", stage)
150 }
151 if stage != "disable" && !o.off {
152 t.Fatal("disabled output considered active")
153 }
154 if fence != nil {
155 if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
156 t.Fatalf("fence not closed: %v", err)
157 }
158 }
159 })
160 }
161}
162
163func TestProtectedResumeWithoutClearStaysInactive(t *testing.T) {
164 o, _, commits := testOutput(t)
165 o.protected = true
166 if err := o.modeset(); err != nil {
167 t.Fatal(err)
168 }
169 checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
170 if !o.off {
171 t.Fatal("resume exposed stale target")
172 }
173}
174
175func TestProtectedCloseNeverRestoresSavedDesktop(t *testing.T) {
176 o, k, commits := testOutput(t)
177 o.protected = true
178 o.saved = modeCrtc{crtcID: tCrtc, fbID: 123, modeValid: 1, mode: o.mode}
179 o.overlay = &plane{id: tOverlay, props: planeProps}
180 o.stray = []*plane{o.overlay, {id: 53, props: planeProps}, {id: 54, props: planeProps}}
181 k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once()
182 k.EXPECT().objProps(uint32(54), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, 99}}, nil).Once()
183 k.EXPECT().rmFB(mock.Anything).Return(nil)
184 o.Close()
185 if len(*commits) != 1 {
186 t.Fatalf("commits %d", len(*commits))
187 }
188 checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor, tOverlay, 53)
189 if _, ok := (*commits)[0].req.value(54, pFB); ok {
190 t.Fatal("close detached another output's plane")
191 }
192}
193
194func TestProtectedModesetPendingDoesNotWaitForClientFence(t *testing.T) {
195 o, _, commits := testOutput(t)
196 f, _ := protectionFence(t, false)
197 o.frame.begin(pendingFrame{fences: []*os.File{f}}, time.Now())
198 r := portsmocks.NewMockRenderer(t)
199 for _, call := range []func() error{
200 func() error { return o.protectedModeset(context.Background(), r) },
201 o.modeset,
202 } {
203 if err := call(); !errors.Is(err, errProtectionPending) {
204 t.Fatalf("pending result: %v", err)
205 }
206 }
207 if !o.protected || len(*commits) != 0 || !o.frame.pendingCommit() {
208 t.Fatal("pending protection touched KMS or dropped pending state")
209 }
210 if _, err := f.Stat(); err != nil {
211 t.Fatalf("client fence ownership dropped: %v", err)
212 }
213}
214
215func TestProtectedModesetCanceled(t *testing.T) {
216 for _, before := range []bool{true, false} {
217 t.Run(map[bool]string{true: "before-disable", false: "during-wait"}[before], func(t *testing.T) {
218 o, _, commits := testOutput(t)
219 r := portsmocks.NewMockRenderer(t)
220 ctx, cancel := context.WithCancel(context.Background())
221 defer cancel()
222 var fence *os.File
223 if before {
224 cancel()
225 } else {
226 fence, _ = protectionFence(t, false)
227 r.EXPECT().UseTarget(0).Return().Once()
228 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
229 cancel()
230 return fence, nil
231 }).Once()
232 }
233 if err := o.protectedModeset(ctx, r); !errors.Is(err, context.Canceled) {
234 t.Fatalf("cancel result: %v", err)
235 }
236 want := 1
237 if before {
238 want = 0
239 }
240 if len(*commits) != want || !o.protected || o.back != 0 {
241 t.Fatal("cancel activated or dropped latch")
242 }
243 if fence != nil {
244 if !o.off {
245 t.Fatal("canceled clear left output marked active")
246 }
247 if _, err := fence.Stat(); !errors.Is(err, os.ErrClosed) {
248 t.Fatalf("cancel did not close fence: %v", err)
249 }
250 }
251 })
252 }
253}
254
255func TestProtectedModesetPreservesPowerOff(t *testing.T) {
256 o, _, commits := testOutput(t)
257 o.wantOff = true
258 r := portsmocks.NewMockRenderer(t)
259 r.EXPECT().UseTarget(0).Return().Once()
260 f, _ := protectionFence(t, true)
261 r.EXPECT().Render(ports.Scene{Seq: 0, Background: "#000000"}, map[ports.WindowID]ports.SurfaceContent(nil)).Return(f, nil).Once()
262 if err := o.protectedModeset(context.Background(), r); err != nil {
263 t.Fatal(err)
264 }
265 if len(*commits) != 1 || !o.wantOff || !o.off || !o.protected || o.back != 0 {
266 t.Fatal("off proof activated output, flipped target or changed power intent")
267 }
268 checkProtectedDisable(t, (*commits)[0], tPrimary, tCursor)
269}
270
271func TestProtectedModesetUnreadableStrayFailsClosed(t *testing.T) {
272 for _, missing := range []bool{false, true} {
273 t.Run(map[bool]string{false: "read-error", true: "missing-property"}[missing], func(t *testing.T) {
274 o, k, commits := testOutput(t)
275 o.stray = []*plane{{id: tOverlay, props: planeProps}}
276 var err error
277 if !missing {
278 err = unix.EACCES
279 }
280 k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, err).Once()
281 if err := o.protectedModeset(context.Background(), portsmocks.NewMockRenderer(t)); err == nil {
282 t.Fatal("unverified stray plane reported success")
283 }
284 if !o.protected || len(*commits) != 0 {
285 t.Fatal("unverified detachment touched KMS or dropped latch")
286 }
287 })
288 }
289}