amdgpu_rules_test.go RawHistoryFolder

1package drm
2
3import (
4	"context"
5	"errors"
6	"sync"
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// Connector HDR property IDs and values of the amdgpu rule tests.
18const (
19	pHDRMeta, pColorspace, pMaxBPC = 20, 21, 22
20	colorspaceBT2020               = 9
21)
22
23// Blob lifetime is part of the model: attach feeds createBlob/destroyBlob
24// mocks into the live set.
25type amdgpuModel struct {
26	t    *testing.T
27	mu   sync.Mutex
28	live map[uint32]bool
29	next uint32
30	rule func(*atomicReq, uint32) error
31}
32
33// newAmdgpuModel starts from a lit desktop whose mode blob 99 is live.
34func newAmdgpuModel(t *testing.T) *amdgpuModel {
35	t.Helper()
36	m := &amdgpuModel{t: t, live: map[uint32]bool{99: true}, next: 100}
37	m.rule = amdgpuRuleWith(t, m.isLive)
38	return m
39}
40
41func (m *amdgpuModel) isLive(blob uint32) bool {
42	m.mu.Lock()
43	defer m.mu.Unlock()
44	return m.live[blob]
45}
46
47// attach makes k create ids from 100 up and destroy only live blobs. With
48// strict ids, each is expected to be destroyed exactly once and no other
49// destroy is allowed; without, any live blob may be destroyed.
50func (m *amdgpuModel) attach(k *mockkms, strict ...uint32) {
51	k.EXPECT().createBlob(mock.Anything).RunAndReturn(func([]byte) (uint32, error) {
52		m.mu.Lock()
53		defer m.mu.Unlock()
54		id := m.next
55		m.next++
56		m.live[id] = true
57		return id, nil
58	}).Maybe()
59	destroy := func(id uint32) error {
60		m.mu.Lock()
61		defer m.mu.Unlock()
62		if !m.live[id] {
63			m.t.Errorf("amdgpu: destroyBlob(%d) of an unknown or destroyed blob", id)
64			return unix.ENOENT
65		}
66		delete(m.live, id)
67		return nil
68	}
69	if len(strict) == 0 {
70		k.EXPECT().destroyBlob(mock.Anything).RunAndReturn(destroy).Maybe()
71	}
72	for _, id := range strict {
73		k.EXPECT().destroyBlob(id).RunAndReturn(destroy).Once()
74	}
75}
76
77// amdgpuRule returns a commit rule encoding the atomic_check constraints of
78// amdgpu_dm that real hardware applied to the protected requests (EINVAL on
79// kernel 7.2, RX/DP+HDMI). The rule tracks the committed state, starting from
80// a lit desktop: CRTC enabled and active, primary/cursor/overlay/stray
81// planes attached, connector routed.
82//
83//   - a CRTC with MODE_ID set (enabled) needs its primary plane attached,
84//     even with ACTIVE=0 (drm_atomic_helper_check_crtc_primary_plane);
85//   - MODE_ID, connector CRTC_ID and ACTIVE agree: an active or routed CRTC
86//     is enabled, an enabled one is routed;
87//   - a plane's FB_ID and CRTC_ID are both set or both clear, and only on an
88//     enabled CRTC;
89//   - the active primary plane covers the whole mode (no partial src/dst);
90//   - HDR_OUTPUT_METADATA needs the BT.2020 Colorspace;
91//   - MODE_ID must name a live blob, and a change of MODE_ID, ACTIVE or the
92//     connector CRTC_ID is a modeset that needs the ALLOW_MODESET flag.
93//
94// amdgpuRule has no blob model: any MODE_ID is live.
95func amdgpuRule(t *testing.T) func(*atomicReq, uint32) error {
96	t.Helper()
97	return amdgpuRuleWith(t, func(uint32) bool { return true })
98}
99
100func amdgpuRuleWith(t *testing.T, live func(uint32) bool) func(*atomicReq, uint32) error {
101	t.Helper()
102	state := map[[2]uint32]uint64{
103		{tCrtc, pMode}: 99, {tCrtc, pActive}: 1, {tConn, pConnCrtc}: tCrtc,
104	}
105	for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
106		state[[2]uint32{p, pFB}], state[[2]uint32{p, pCrtcID}] = 70, tCrtc
107	}
108	return func(req *atomicReq, flags uint32) error {
109		next := make(map[[2]uint32]uint64, len(state))
110		for k, v := range state {
111			next[k] = v
112		}
113		for i, obj := range req.objs {
114			for _, p := range req.props[i] {
115				next[[2]uint32{obj, p.prop}] = p.val
116			}
117		}
118		get := func(obj, prop uint32) uint64 { return next[[2]uint32{obj, prop}] }
119		if mode := get(tCrtc, pMode); mode != 0 && !live(uint32(mode)) {
120			t.Logf("amdgpu: MODE_ID %d names an unknown or destroyed blob", mode)
121			return unix.EINVAL
122		}
123		for _, k := range [...][2]uint32{{tCrtc, pMode}, {tCrtc, pActive}, {tConn, pConnCrtc}} {
124			if next[k] != state[k] && flags&atomicAllowModes == 0 {
125				t.Logf("amdgpu: modeset change of %v without ALLOW_MODESET", k)
126				return unix.EINVAL
127			}
128		}
129		enabled, active := get(tCrtc, pMode) != 0, get(tCrtc, pActive) == 1
130		if active && !enabled || (get(tConn, pConnCrtc) != 0) != enabled {
131			t.Logf("amdgpu: ACTIVE/MODE_ID/connector routing disagree: %v", next)
132			return unix.EINVAL
133		}
134		for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
135			fb, crtc := get(p, pFB), get(p, pCrtcID)
136			if (fb != 0) != (crtc != 0) || crtc != 0 && (!enabled || crtc != tCrtc) {
137				t.Logf("amdgpu: plane %d fb=%d crtc=%d enabled=%v", p, fb, crtc, enabled)
138				return unix.EINVAL
139			}
140		}
141		if enabled && get(tPrimary, pFB) == 0 {
142			t.Log("amdgpu: enabled CRTC without primary plane")
143			return unix.EINVAL
144		}
145		if active {
146			for _, c := range [...]struct {
147				prop uint32
148				want uint64
149			}{{10, 0}, {11, 0}, {12, 200 << 16}, {13, 100 << 16}, {14, 0}, {15, 0}, {16, 200}, {17, 100}} {
150				if get(tPrimary, c.prop) != c.want {
151					t.Logf("amdgpu: primary prop %d = %d, want full-screen %d", c.prop, get(tPrimary, c.prop), c.want)
152					return unix.EINVAL
153				}
154			}
155		}
156		if get(tConn, pHDRMeta) != 0 && get(tConn, pColorspace) != colorspaceBT2020 {
157			t.Log("amdgpu: HDR metadata without BT.2020 colorspace")
158			return unix.EINVAL
159		}
160		if flags&atomicTestOnly == 0 {
161			state = next
162		}
163		return nil
164	}
165}
166
167func enableTestHDR(o *Output) {
168	o.hdr.on, o.hdr.blob = true, 55
169	o.hdr.props = connectorHDRProps{Metadata: pHDRMeta, Colorspace: pColorspace, MaxBPC: pMaxBPC, BT2020Value: colorspaceBT2020, MaxBPCValue: 8, HasDefault: true}
170}
171
172func TestAmdgpuRuleRejectsWhatHardwareRejected(t *testing.T) {
173	for name, build := range map[string]func(*atomicReq){
174		// The shape that failed on hardware: inactive, still enabled, no planes.
175		"enabled CRTC without primary": func(r *atomicReq) {
176			r.set(tCrtc, pActive, 0)
177			for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
178				r.set(p, pFB, 0)
179				r.set(p, pCrtcID, 0)
180			}
181		},
182		"plane on disabled CRTC": func(r *atomicReq) {
183			r.set(tCrtc, pActive, 0)
184			r.set(tCrtc, pMode, 0)
185			r.set(tConn, pConnCrtc, 0)
186		},
187		"connector routed to disabled CRTC": func(r *atomicReq) {
188			r.set(tCrtc, pActive, 0)
189			r.set(tCrtc, pMode, 0)
190			for _, p := range []uint32{tPrimary, tCursor, tOverlay, 53} {
191				r.set(p, pFB, 0)
192				r.set(p, pCrtcID, 0)
193			}
194		},
195		"cursor with CRTC but no FB": func(r *atomicReq) { r.set(tCursor, pFB, 0) },
196		"partial primary":            func(r *atomicReq) { r.set(tPrimary, 16, 100) },
197		"unknown MODE_ID":            func(r *atomicReq) { r.set(tCrtc, pMode, 123) },
198		"HDR metadata without colorspace": func(r *atomicReq) {
199			r.set(tConn, pHDRMeta, 55)
200		},
201	} {
202		t.Run(name, func(t *testing.T) {
203			req := &atomicReq{}
204			build(req)
205			if err := amdgpuRule(t)(req, atomicAllowModes); err == nil {
206				t.Fatal("rule accepted a request amdgpu refuses")
207			}
208		})
209	}
210}
211
212// Every protected disable/activation request must pass the amdgpu rules, with
213// HDR on and off, and leave the state dark then black-only.
214func TestProtectedRequestsAcceptedByAmdgpuRules(t *testing.T) {
215	for _, hdr := range []bool{false, true} {
216		name := map[bool]string{false: "sdr", true: "hdr"}[hdr]
217		t.Run(name, func(t *testing.T) {
218			m := newAmdgpuModel(t)
219			o, k, commits, _ := testOutputRules(t, m.rule)
220			m.attach(k, 99)
221			if hdr {
222				enableTestHDR(o)
223			}
224			o.modeBlob = 99
225			o.overlay = &plane{id: tOverlay, props: planeProps}
226			o.stray = []*plane{o.overlay, {id: 53, props: planeProps}}
227			k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
228			r := portsmocks.NewMockRenderer(t)
229			r.EXPECT().UseTarget(0).Return().Once()
230			r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
231			if err := o.protectedModeset(context.Background(), r); err != nil {
232				t.Fatalf("amdgpu refused the protected transaction: %v", err)
233			}
234			if len(*commits) != 2 {
235				t.Fatalf("commits %d", len(*commits))
236			}
237			act := (*commits)[1]
238			if fb, _ := act.req.value(tPrimary, pFB); fb != 71 && fb != 70 {
239				t.Fatalf("activated fb %d", fb)
240			}
241			if crtc, ok := act.req.value(tCursor, pCrtcID); !ok || crtc != 0 {
242				t.Fatalf("cursor not explicitly detached: %d %v", crtc, ok)
243			}
244			if fb, ok := act.req.value(tCursor, pFB); !ok || fb != 0 {
245				t.Fatalf("cursor fb not explicitly detached: %d %v", fb, ok)
246			}
247			if o.off || !o.protected {
248				t.Fatalf("off=%v protected=%v", o.off, o.protected)
249			}
250			// The desktop mode blob is destroyed once by the disable; the
251			// activation's blob is the only live one and o.modeBlob owns it.
252			if o.modeBlob != 100 || !m.isLive(100) || m.isLive(99) {
253				t.Fatalf("modeBlob %d live100=%v live99=%v", o.modeBlob, m.isLive(100), m.isLive(99))
254			}
255		})
256	}
257}
258
259func TestAmdgpuRuleRejectsModesetWithoutAllowModes(t *testing.T) {
260	for name, build := range map[string]func(*atomicReq){
261		"MODE_ID":           func(r *atomicReq) { r.set(tCrtc, pMode, 99) },
262		"ACTIVE":            func(r *atomicReq) { r.set(tCrtc, pActive, 0) },
263		"connector CRTC_ID": func(r *atomicReq) { r.set(tConn, pConnCrtc, 0) },
264	} {
265		t.Run(name, func(t *testing.T) {
266			m := newAmdgpuModel(t)
267			m.live[98] = true
268			req := &atomicReq{}
269			build(req)
270			if name == "MODE_ID" {
271				req.set(tCrtc, pMode, 98)
272			}
273			if err := m.rule(req, 0); err == nil {
274				t.Fatal("rule accepted a modeset change without ALLOW_MODESET")
275			}
276		})
277	}
278}
279
280func TestAmdgpuRuleRejectsDestroyedModeBlob(t *testing.T) {
281	m := newAmdgpuModel(t)
282	req := &atomicReq{}
283	req.set(tCrtc, pMode, 99)
284	for _, c := range [...]struct{ prop, val uint32 }{{12, 200 << 16}, {13, 100 << 16}, {16, 200}, {17, 100}} {
285		req.set(tPrimary, c.prop, uint64(c.val))
286	}
287	if err := m.rule(req, atomicAllowModes); err != nil {
288		t.Fatalf("live blob rejected: %v", err)
289	}
290	m.mu.Lock()
291	delete(m.live, 99)
292	m.mu.Unlock()
293	if err := m.rule(req, atomicAllowModes); err == nil {
294		t.Fatal("rule accepted a destroyed MODE_ID blob")
295	}
296}
297
298func TestProtectedResumeAcceptedByAmdgpuRules(t *testing.T) {
299	m := newAmdgpuModel(t)
300	o, k, commits, _ := testOutputRules(t, m.rule)
301	m.attach(k)
302	o.modeBlob = 99
303	o.protected = true
304	o.overlay = &plane{id: tOverlay, props: planeProps}
305	o.stray = []*plane{o.overlay, {id: 53, props: planeProps}}
306	k.EXPECT().objProps(uint32(53), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil)
307	if err := o.modeset(); err != nil { // resume while protected: dark, not stale
308		t.Fatalf("amdgpu refused protected inactive modeset: %v", err)
309	}
310	if !o.off || len(*commits) != 1 {
311		t.Fatalf("off=%v commits=%d", o.off, len(*commits))
312	}
313	if v, ok := (*commits)[0].req.value(tCrtc, pMode); !ok || v != 0 {
314		t.Fatal("protected disable left the CRTC enabled")
315	}
316}
317
318// The legacy request is refused by the rule; the output stays registered and
319// protected, retries with bounded backoff and never reports proof.
320func TestProtectedRefusalKeepsOutputProtectedAndBacksOff(t *testing.T) {
321	o, k, commits, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
322		if v, ok := r.value(tCrtc, pActive); ok && v == 1 {
323			return unix.EINVAL
324		}
325		return nil
326	})
327	k.EXPECT().createBlob(mock.Anything).Return(100, nil)
328	k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
329	r := portsmocks.NewMockRenderer(t)
330	r.EXPECT().UseTarget(0).Return().Once()
331	r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
332	err := o.protectedModeset(context.Background(), r)
333	if err == nil || !retryableProtected(err) || !errorsIsEINVAL(err) {
334		t.Fatalf("refusal result %v", err)
335	}
336	if !o.protected || !o.off || o.back != 0 || len(*commits) != 2 {
337		t.Fatalf("protected=%v off=%v back=%d commits=%d", o.protected, o.off, o.back, len(*commits))
338	}
339	var enabled = true
340	if !o.commitFailed(err, &enabled) || !enabled || !o.protectBackoffActive() {
341		t.Fatal("refusal not retryable with backoff")
342	}
343	d := time.Duration(0)
344	for range 10 {
345		d = nextProtectBackoff(d)
346	}
347	if d != protectRetryMax || nextProtectBackoff(0) != protectRetryMin {
348		t.Fatalf("backoff not bounded: %v", d)
349	}
350}
351
352func errorsIsEINVAL(err error) bool { return err != nil && errors.Is(err, unix.EINVAL) }
353
354// Run: activation refused twice with EINVAL. The output keeps running, stays
355// dark, and only the later accepted black activation produces proof.
356func TestRunProtectedActivationRetriesWithoutStoppingOrEarlyProof(t *testing.T) {
357	var activations, refusals atomic.Int32
358	o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
359		if v, ok := r.value(tCrtc, pActive); ok && v == 1 && flags&atomicTestOnly == 0 {
360			activations.Add(1)
361			if refusals.Add(1) <= 2 {
362				return unix.EINVAL
363			}
364		}
365		return nil
366	})
367	o.cursor = nil
368	var state atomic.Uint64
369	state.Store(3)
370	securityGate(t, o, &state)
371	events := make(chan ports.SecurityBackendEvent, 32)
372	o.SecurityEvents = events
373	r := portsmocks.NewMockRenderer(t)
374	buf := func() ports.DMABuf {
375		f, w := protectionFence(t, false)
376		w.Close()
377		return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
378	}
379	r.EXPECT().SetHDR(float64(0)).Return().Maybe()
380	r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
381	r.EXPECT().UseTarget(mock.Anything).Return()
382	r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
383	r.EXPECT().Close().Return().Once()
384	k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
385	k.EXPECT().createBlob(mock.Anything).Return(99, nil)
386	k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
387	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
388	defer cancel()
389	done := make(chan error, 1)
390	go func() {
391		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)
392	}()
393	for {
394		select {
395		case ev := <-events:
396			if _, ok := ev.(ports.SecurityOutputProof); !ok {
397				continue
398			}
399			if n := activations.Load(); n < 3 {
400				t.Fatalf("proof after %d activation attempts; refused ones are no evidence", n)
401			}
402			cancel()
403			if err := <-done; err != nil {
404				t.Fatalf("output stopped: %v", err)
405			}
406			return
407		case err := <-done:
408			t.Fatalf("output stopped on refused protected commit: %v", err)
409		case <-ctx.Done():
410			t.Fatal("no proof after retries")
411		}
412	}
413}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close