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 if err := o.detachProtectedPlanes(req); err != nil {
91 return protectedCommitError{err}
92 }
93 if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
94 return protectedCommitError{fmt.Errorf("disable protected output: %w", err)}
95 }
96 if o.modeBlob != 0 {
97 _ = o.k.destroyBlob(o.modeBlob) // MODE_ID is 0 now: nothing references it
98 o.modeBlob = 0
99 }
100 o.off, o.vrrOn, o.vrrGame, o.overlayOn = true, false, false, 0
101 o.sendFormats()
102 o.shown, o.queued = 0, 0
103 if o.cursor != nil {
104 o.cursor.applied = cursorState{}
105 o.cursor.screen, o.cursor.flying = 0, false
106 }
107 return nil
108}
109
110// protectedCommitError marks a recoverable failure of the protected disable or
111// activation: a refused KMS commit, unverifiable plane state, a mode blob or a
112// clear fence that failed. The output owner keeps the output, stays protected
113// and retries with bounded backoff instead of stopping (see commitFailed). A
114// renderer error is not wrapped: it stays fatal.
115type protectedCommitError struct{ error }
116
117func (e protectedCommitError) Unwrap() error { return e.error }
118
119// retryableProtected reports a refused protected KMS commit worth retrying
120// later. Busy and lost-master errors keep their own handling.
121func retryableProtected(err error) bool {
122 var pe protectedCommitError
123 return errors.As(err, &pe) && !lostMaster(err) && !errors.Is(err, unix.EBUSY)
124}
125
126const (
127 protectRetryMin = 50 * time.Millisecond
128 protectRetryMax = 2 * time.Second
129)
130
131// deferProtection schedules the next protected attempt after a refusal.
132func (o *Output) deferProtection() {
133 o.protectBackoff = nextProtectBackoff(o.protectBackoff)
134 o.protectNotBefore = time.Now().Add(o.protectBackoff)
135}
136
137// protectBackoffActive reports a pending retry delay.
138func (o *Output) protectBackoffActive() bool {
139 return !o.protectNotBefore.IsZero() && time.Now().Before(o.protectNotBefore)
140}
141
142// protectSucceeded clears the retry state.
143func (o *Output) protectSucceeded() { o.protectBackoff, o.protectNotBefore = 0, time.Time{} }
144
145// nextProtectBackoff doubles the delay, bounded by protectRetryMax.
146func nextProtectBackoff(d time.Duration) time.Duration {
147 if d < protectRetryMin {
148 return protectRetryMin
149 }
150 return min(2*d, protectRetryMax)
151}
152
153// protectedModeset is an internal output-owner primitive, not session-lock
154// readiness or a protocol entry point. It latches protection even on failure.
155// First stop scanout with a blocking full disable, then clear an unscanned
156// target and wait for the clear to complete before activating that exact FB.
157func (o *Output) protectedModeset(ctx context.Context, r ports.Renderer) error {
158 o.protected = true
159 if o.frame.pendingCommit() {
160 return errProtectionPending
161 }
162 if err := ctx.Err(); err != nil {
163 return err
164 }
165 if err := o.disableProtected(); err != nil {
166 return err
167 }
168 // Do not dropRead here: an earlier render whose KMS commit failed may
169 // still read client buffers. KMS detachment is not a GPU completion fence.
170 if err := ctx.Err(); err != nil {
171 return err
172 }
173 r.UseTarget(o.back)
174 fence, err := r.Render(ports.Scene{Seq: 0, Background: "#000000"}, nil)
175 if err != nil {
176 if fence != nil {
177 fence.Close()
178 }
179 return fmt.Errorf("clear protected output: %w", err)
180 }
181 if fence != nil {
182 err := syncfile.Wait(ctx, fence)
183 fence.Close()
184 if err != nil {
185 err = fmt.Errorf("wait protected clear: %w", err)
186 if ctx.Err() != nil {
187 return err
188 }
189 return protectedCommitError{err}
190 }
191 }
192 if err := ctx.Err(); err != nil {
193 return err
194 }
195 if o.wantOff {
196 o.protectSucceeded()
197 o.signalReady()
198 return nil // the blocking disable is the proof; do not activate or flip
199 }
200 if err := o.modesetImage(o.fbs[o.back], true); err != nil {
201 return err
202 }
203 o.back = 1 - o.back
204 o.protectSucceeded()
205 return nil
206}