1package drm
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "github.com/bnema/neferwl/internal/adapters/syncfile"
10 "github.com/bnema/neferwl/internal/ports"
11 "golang.org/x/sys/unix"
12)
13
14// errProtectionPending asks the output owner to retry after the outstanding
15// commit ends. In particular, never queue a blocking disable behind a client
16// acquire fence: that fence need not signal in bounded time.
17var errProtectionPending = errors.New("output protection: commit pending")
18
19// detachProtectedPlanes detaches every plane owned by this output, and stray
20// planes currently on its CRTC (not planes belonging to another output). A
21// failed read is not evidence that a stray plane is detached.
22func (o *Output) detachProtectedPlanes(req *atomicReq) error {
23 var err error
24 owned := make(map[uint32]bool)
25 detach := func(p *plane) {
26 if p == nil {
27 return
28 }
29 // atomicReq.set silently skips zero property IDs. Partial cached
30 // metadata therefore cannot prove detachment of a previous desktop.
31 if p.id == 0 || p.prop("FB_ID") == 0 || p.prop("CRTC_ID") == 0 {
32 err = errors.Join(err, fmt.Errorf("protected plane %d: incomplete detachment properties", p.id))
33 return
34 }
35 req.set(p.id, p.prop("FB_ID"), 0)
36 req.set(p.id, p.prop("CRTC_ID"), 0)
37 }
38 detachOwned := func(p *plane) {
39 if p != nil {
40 owned[p.id] = true
41 detach(p)
42 }
43 }
44 detachOwned(o.primary)
45 if o.cursor != nil {
46 detachOwned(o.cursor.plane)
47 }
48 detachOwned(o.overlay)
49 for _, p := range o.stray {
50 // pickOverlay leaves the owned overlay in stray. Do not turn a
51 // redundant query failure into refusal of known-owned detachment.
52 if owned[p.id] {
53 continue
54 }
55 props, readErr := o.k.objProps(p.id, objPlane)
56 if readErr != nil {
57 err = errors.Join(err, fmt.Errorf("read protected plane %d: %w", p.id, readErr))
58 continue
59 }
60 crtc, ok := props["CRTC_ID"]
61 if !ok {
62 err = errors.Join(err, fmt.Errorf("protected plane %d: missing CRTC_ID", p.id))
63 continue
64 }
65 if crtc[1] == uint64(o.crtc) {
66 detach(p)
67 }
68 }
69 return err
70}
71
72// disableProtected is the atomic disable of a protected output. It is a full
73// disable: ACTIVE=0, MODE_ID=0, connector CRTC_ID=0, every plane detached, VRR
74// and HDR connector state off. amdgpu (amdgpu_dm, via
75// drm_atomic_helper_check_crtc_primary_plane) rejects with EINVAL a CRTC that
76// stays enabled (MODE_ID set) without its primary plane, even with ACTIVE=0,
77// so "inactive but every plane detached" is not a valid request. The other
78// valid shape, ACTIVE=0 with the primary still attached, would keep a desktop
79// framebuffer referenced by the CRTC until a later activation replaced it. A
80// full disable leaves nothing of the desktop attached and needs no blob; the
81// activation that follows is a full modeset either way. It is the same
82// request as Close's disable, which this hardware accepts.
83func (o *Output) disableProtected() error {
84 req := &atomicReq{}
85 req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
86 req.set(o.crtc, o.crtcProps["MODE_ID"], 0)
87 req.set(o.crtc, o.vrrProp, 0)
88 req.set(o.conn.id, o.connCrtc, 0)
89 o.hdrConnectorProps(req, false)
90 o.forceBypass(req)
91 if err := o.detachProtectedPlanes(req); err != nil {
92 return protectedCommitError{err}
93 }
94 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
95 return protectedCommitError{fmt.Errorf("disable protected output: %w", err)}
96 }
97 o.colorBypassed()
98 if o.modeBlob != 0 {
99 _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it
100 o.modeBlob = 0
101 }
102 o.off, o.vrrGame, o.overlayOn = true, false, 0
103 o.setVRR(false)
104 o.sendFormats()
105 o.shown, o.queued = 0, 0
106 if o.cursor != nil {
107 o.cursor.applied = cursorState{}
108 o.cursor.screen, o.cursor.flying = 0, false
109 }
110 return nil
111}
112
113// protectedCommitError marks a recoverable failure of the protected disable or
114// activation: a refused KMS commit, unverifiable plane state, a mode blob or a
115// clear fence that failed. The output owner keeps the output, stays protected
116// and retries with bounded backoff instead of stopping (see commitFailed). A
117// renderer error is not wrapped: it stays fatal.
118type protectedCommitError struct{ error }
119
120func (e protectedCommitError) Unwrap() error { return e.error }
121
122// retryableProtected reports a refused protected KMS commit worth retrying
123// later. Busy and lost-master errors keep their own handling.
124func retryableProtected(err error) bool {
125 var pe protectedCommitError
126 return errors.As(err, &pe) && !lostMaster(err) && !errors.Is(err, unix.EBUSY)
127}
128
129const (
130 protectRetryMin = 50 * time.Millisecond
131 protectRetryMax = 2 * time.Second
132)
133
134// deferProtection schedules the next protected attempt after a refusal.
135func (o *Output) deferProtection() {
136 o.protectBackoff = nextProtectBackoff(o.protectBackoff)
137 o.protectNotBefore = time.Now().Add(o.protectBackoff)
138}
139
140// protectBackoffActive reports a pending retry delay.
141func (o *Output) protectBackoffActive() bool {
142 return !o.protectNotBefore.IsZero() && time.Now().Before(o.protectNotBefore)
143}
144
145// protectSucceeded clears the retry state.
146func (o *Output) protectSucceeded() { o.protectBackoff, o.protectNotBefore = 0, time.Time{} }
147
148// nextProtectBackoff doubles the delay, bounded by protectRetryMax.
149func nextProtectBackoff(d time.Duration) time.Duration {
150 if d < protectRetryMin {
151 return protectRetryMin
152 }
153 return min(2*d, protectRetryMax)
154}
155
156// protectedModeset is an internal output-owner primitive, not session-lock
157// readiness or a protocol entry point. It latches protection even on failure.
158// First stop scanout with a blocking full disable, then clear an unscanned
159// target and wait for the clear to complete before activating that exact FB.
160func (o *Output) protectedModeset(ctx context.Context, r ports.Renderer) error {
161 o.protected = true
162 if o.frame.pendingCommit() {
163 return errProtectionPending
164 }
165 if err := ctx.Err(); err != nil {
166 return err
167 }
168 if err := o.disableProtected(); err != nil {
169 return err
170 }
171 // Do not dropRead here: an earlier render whose KMS commit failed may
172 // still read client buffers. KMS detachment is not a GPU completion fence.
173 if err := ctx.Err(); err != nil {
174 return err
175 }
176 r.UseTarget(o.back)
177 fence, err := r.Render(ports.Scene{Seq: 0, Background: "#000000"}, nil)
178 if err != nil {
179 if fence != nil {
180 fence.Close()
181 }
182 return fmt.Errorf("clear protected output: %w", err)
183 }
184 if fence != nil {
185 err := syncfile.Wait(ctx, fence)
186 fence.Close()
187 if err != nil {
188 err = fmt.Errorf("wait protected clear: %w", err)
189 if ctx.Err() != nil {
190 return err
191 }
192 return protectedCommitError{err}
193 }
194 }
195 if err := ctx.Err(); err != nil {
196 return err
197 }
198 if o.wantOff {
199 o.protectSucceeded()
200 o.signalReady()
201 return nil // the blocking disable is the proof; do not activate or flip
202 }
203 if err := o.modesetImage(o.fbs[o.back], true); err != nil {
204 return err
205 }
206 o.back = 1 - o.back
207 o.protectSucceeded()
208 return nil
209}