detached 877c74b

Merge pull request 'perf(drm): flip only the overlay buffer when the composed image is unchanged' (#49) from perf/overlay-flip into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/49
commit
877c74b543187eaeb1a708b120dd2988d654b72b
author
bnema · · 2026-10-14 08:33 UTC
merge of
8cb4a4f + a6ad1b3 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
9 files · +666 -14
M internal/adapters/drm/frame_submit.go +50 -6
@@ -89,6 +89,22 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
8989 decision = o.composeFrame(scene, surfaces)
9090 }
9191 ov, composed := decision.overlay, decision.composed
92+ // A render that was not committed may still read client buffers: only a
93+ // new render's fence orders this flip after it, so the frame composes.
94+ if ov.fb != 0 && len(normal) == 0 && len(excluded) == 0 && o.readDone() {
95+ if front := o.fbs[1-o.back]; o.front.holds(front, composed, surfaces) {
96+ // Only the overlay window changed: the image on screen already
97+ // holds the rest.
98+ if o.testOverlay(front, ov) {
99+ err = o.commitOverlayOnly(front, scene, f, ov)
100+ presented = err == nil
101+ return false, err
102+ }
103+ // Refused: compose the window too (cached per buffer).
104+ ov.close()
105+ ov, composed = overlayWin{}, scene
106+ }
107+ }
92108 r.UseTarget(o.back)
93109 // Captures never show the capture indicator: their frame goes first.
94110 took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal)
@@ -203,17 +219,45 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
203219 done.Close()
204220 }
205221 ov.close()
222+ if err = o.finishOverlayCommit(err, ov.buf); err == nil {
223+ // The image now holds what was drawn last: composed, or the whole
224+ // scene after an overlay refusal.
225+ if ov.fb != 0 {
226+ o.front.hold(o.fbs[o.back], composed, surfaces)
227+ } else {
228+ o.front.hold(o.fbs[o.back], scene, surfaces)
229+ }
230+ o.back = 1 - o.back
231+ }
232+ return false, err
233+}
234+
235+// commitOverlayOnly commits front, the image on screen, again with ov on
236+// the overlay plane: no render, no GPU fence. It closes ov's fence.
237+func (o *Output) commitOverlayOnly(front uint32, scene ports.Scene, f pendingFrame, ov overlayWin) error {
238+ defer ov.close()
239+ if !o.sceneCurrent(scene) || o.protected {
240+ return errSecurityScene
241+ }
242+ f.queued, f.zeroCopy = ov.buf, ov.id
243+ game := o.vrrProp != 0 && fullscreenShown(&scene)
244+ return o.finishOverlayCommit(o.commitWith(front, nil, false, o.wantVRR(game), f, ov), ov.buf)
245+}
246+
247+// finishOverlayCommit applies the result of a frame commit with buf on the
248+// overlay (0: none): a refusal the overlay causes asks for a frame without
249+// it (errOverlayDropped); a commit marks buf queued.
250+func (o *Output) finishOverlayCommit(err error, buf uint64) error {
206251 if errors.Is(err, errColorRefused) {
207252 // Cached for the overlay's buffer format: the next frame composes it.
208253 o.setOverlayReason("color_refused")
209- return false, errOverlayDropped
254+ return errOverlayDropped
210255 }
211- if err != nil && o.overlayConflict(err, ov.buf) {
212- return false, errOverlayDropped
256+ if err != nil && o.overlayConflict(err, buf) {
257+ return errOverlayDropped
213258 }
214259 if err == nil {
215- o.queued = ov.buf
216- o.back = 1 - o.back
260+ o.queued = buf
217261 }
218- return false, err
262+ return err
219263 }
@@ -89,6 +89,22 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
89 decision = o.composeFrame(scene, surfaces)89 decision = o.composeFrame(scene, surfaces)
90 }90 }
91 ov, composed := decision.overlay, decision.composed91 ov, composed := decision.overlay, decision.composed
92+ // A render that was not committed may still read client buffers: only a
93+ // new render's fence orders this flip after it, so the frame composes.
94+ if ov.fb != 0 && len(normal) == 0 && len(excluded) == 0 && o.readDone() {
95+ if front := o.fbs[1-o.back]; o.front.holds(front, composed, surfaces) {
96+ // Only the overlay window changed: the image on screen already
97+ // holds the rest.
98+ if o.testOverlay(front, ov) {
99+ err = o.commitOverlayOnly(front, scene, f, ov)
100+ presented = err == nil
101+ return false, err
102+ }
103+ // Refused: compose the window too (cached per buffer).
104+ ov.close()
105+ ov, composed = overlayWin{}, scene
106+ }
107+ }
92 r.UseTarget(o.back)108 r.UseTarget(o.back)
93 // Captures never show the capture indicator: their frame goes first.109 // Captures never show the capture indicator: their frame goes first.
94 took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal)110 took, pdone, perr := pipeline.SubmitPlain(r, composed, surfaces, normal)
@@ -203,17 +219,45 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports.
203 done.Close()219 done.Close()
204 }220 }
205 ov.close()221 ov.close()
222+ if err = o.finishOverlayCommit(err, ov.buf); err == nil {
223+ // The image now holds what was drawn last: composed, or the whole
224+ // scene after an overlay refusal.
225+ if ov.fb != 0 {
226+ o.front.hold(o.fbs[o.back], composed, surfaces)
227+ } else {
228+ o.front.hold(o.fbs[o.back], scene, surfaces)
229+ }
230+ o.back = 1 - o.back
231+ }
232+ return false, err
233+}
234+
235+// commitOverlayOnly commits front, the image on screen, again with ov on
236+// the overlay plane: no render, no GPU fence. It closes ov's fence.
237+func (o *Output) commitOverlayOnly(front uint32, scene ports.Scene, f pendingFrame, ov overlayWin) error {
238+ defer ov.close()
239+ if !o.sceneCurrent(scene) || o.protected {
240+ return errSecurityScene
241+ }
242+ f.queued, f.zeroCopy = ov.buf, ov.id
243+ game := o.vrrProp != 0 && fullscreenShown(&scene)
244+ return o.finishOverlayCommit(o.commitWith(front, nil, false, o.wantVRR(game), f, ov), ov.buf)
245+}
246+
247+// finishOverlayCommit applies the result of a frame commit with buf on the
248+// overlay (0: none): a refusal the overlay causes asks for a frame without
249+// it (errOverlayDropped); a commit marks buf queued.
250+func (o *Output) finishOverlayCommit(err error, buf uint64) error {
206 if errors.Is(err, errColorRefused) {251 if errors.Is(err, errColorRefused) {
207 // Cached for the overlay's buffer format: the next frame composes it.252 // Cached for the overlay's buffer format: the next frame composes it.
208 o.setOverlayReason("color_refused")253 o.setOverlayReason("color_refused")
209- return false, errOverlayDropped254+ return errOverlayDropped
210 }255 }
211- if err != nil && o.overlayConflict(err, ov.buf) {256+ if err != nil && o.overlayConflict(err, buf) {
212- return false, errOverlayDropped257+ return errOverlayDropped
213 }258 }
214 if err == nil {259 if err == nil {
215- o.queued = ov.buf260+ o.queued = buf
216- o.back = 1 - o.back
217 }261 }
218- return false, err262+ return err
219 }263 }
A internal/adapters/drm/front.go +80 -0
@@ -0,0 +1,80 @@
1+package drm
2+
3+import (
4+ "slices"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// frontImage is what the output image last committed by a composed frame
10+// holds: the scene drawn into it and the content Seq of each window and
11+// layer it lists. When only the window on the overlay plane changes, the
12+// next frame commits that image again with the new overlay buffer: no GPU
13+// pass, no fence. Paths that draw into an output image outside
14+// submitFrame (security, protection, image setup, the async probe) forget
15+// it.
16+type frontImage struct {
17+ fb uint32 // 0: unknown
18+ scene ports.Scene
19+ seqs []uint64 // Seq of each window, then each layer, of scene
20+ // Backing arrays of scene's slices, reused across frames.
21+ windows []ports.SceneWindow
22+ separators []ports.Separator
23+ layers []ports.SceneLayer
24+ hints []ports.Rect
25+ indicators []ports.CaptureIndicator
26+}
27+
28+// forget drops the image's contents: the next frame composes.
29+func (f *frontImage) forget() { f.fb = 0 }
30+
31+// hold records that fb holds s drawn from surfaces. A capture scene is not
32+// recorded (SameAs never matches it).
33+func (f *frontImage) hold(fb uint32, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
34+ if s.Capture != nil || s.CaptureScene != nil {
35+ f.forget()
36+ return
37+ }
38+ f.windows = append(f.windows[:0], s.Windows...)
39+ f.separators = append(f.separators[:0], s.Separators...)
40+ f.layers = append(f.layers[:0], s.Layers...)
41+ f.hints = append(f.hints[:0], s.DropHints...)
42+ f.indicators = append(f.indicators[:0], s.CaptureIndicators...)
43+ f.scene = s
44+ f.scene.Windows, f.scene.Separators, f.scene.Layers = f.windows, f.separators, f.layers
45+ f.scene.DropHints, f.scene.CaptureIndicators = f.hints, f.indicators
46+ f.seqs = appendSeqs(f.seqs[:0], s, surfaces)
47+ f.fb = fb
48+}
49+
50+// holds reports whether fb holds exactly what drawing s from surfaces would.
51+func (f *frontImage) holds(fb uint32, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) bool {
52+ if f.fb == 0 || f.fb != fb || !s.SameAs(f.scene) {
53+ return false
54+ }
55+ i := 0
56+ for _, w := range s.Windows {
57+ if surfaces[w.ID].Seq != f.seqs[i] {
58+ return false
59+ }
60+ i++
61+ }
62+ for _, l := range s.Layers {
63+ if surfaces[l.ID].Seq != f.seqs[i] {
64+ return false
65+ }
66+ i++
67+ }
68+ return true
69+}
70+
71+func appendSeqs(seqs []uint64, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) []uint64 {
72+ seqs = slices.Grow(seqs, len(s.Windows)+len(s.Layers))
73+ for _, w := range s.Windows {
74+ seqs = append(seqs, surfaces[w.ID].Seq)
75+ }
76+ for _, l := range s.Layers {
77+ seqs = append(seqs, surfaces[l.ID].Seq)
78+ }
79+ return seqs
80+}
@@ -0,0 +1,80 @@
1+package drm
2+
3+import (
4+ "slices"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// frontImage is what the output image last committed by a composed frame
10+// holds: the scene drawn into it and the content Seq of each window and
11+// layer it lists. When only the window on the overlay plane changes, the
12+// next frame commits that image again with the new overlay buffer: no GPU
13+// pass, no fence. Paths that draw into an output image outside
14+// submitFrame (security, protection, image setup, the async probe) forget
15+// it.
16+type frontImage struct {
17+ fb uint32 // 0: unknown
18+ scene ports.Scene
19+ seqs []uint64 // Seq of each window, then each layer, of scene
20+ // Backing arrays of scene's slices, reused across frames.
21+ windows []ports.SceneWindow
22+ separators []ports.Separator
23+ layers []ports.SceneLayer
24+ hints []ports.Rect
25+ indicators []ports.CaptureIndicator
26+}
27+
28+// forget drops the image's contents: the next frame composes.
29+func (f *frontImage) forget() { f.fb = 0 }
30+
31+// hold records that fb holds s drawn from surfaces. A capture scene is not
32+// recorded (SameAs never matches it).
33+func (f *frontImage) hold(fb uint32, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) {
34+ if s.Capture != nil || s.CaptureScene != nil {
35+ f.forget()
36+ return
37+ }
38+ f.windows = append(f.windows[:0], s.Windows...)
39+ f.separators = append(f.separators[:0], s.Separators...)
40+ f.layers = append(f.layers[:0], s.Layers...)
41+ f.hints = append(f.hints[:0], s.DropHints...)
42+ f.indicators = append(f.indicators[:0], s.CaptureIndicators...)
43+ f.scene = s
44+ f.scene.Windows, f.scene.Separators, f.scene.Layers = f.windows, f.separators, f.layers
45+ f.scene.DropHints, f.scene.CaptureIndicators = f.hints, f.indicators
46+ f.seqs = appendSeqs(f.seqs[:0], s, surfaces)
47+ f.fb = fb
48+}
49+
50+// holds reports whether fb holds exactly what drawing s from surfaces would.
51+func (f *frontImage) holds(fb uint32, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) bool {
52+ if f.fb == 0 || f.fb != fb || !s.SameAs(f.scene) {
53+ return false
54+ }
55+ i := 0
56+ for _, w := range s.Windows {
57+ if surfaces[w.ID].Seq != f.seqs[i] {
58+ return false
59+ }
60+ i++
61+ }
62+ for _, l := range s.Layers {
63+ if surfaces[l.ID].Seq != f.seqs[i] {
64+ return false
65+ }
66+ i++
67+ }
68+ return true
69+}
70+
71+func appendSeqs(seqs []uint64, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) []uint64 {
72+ seqs = slices.Grow(seqs, len(s.Windows)+len(s.Layers))
73+ for _, w := range s.Windows {
74+ seqs = append(seqs, surfaces[w.ID].Seq)
75+ }
76+ for _, l := range s.Layers {
77+ seqs = append(seqs, surfaces[l.ID].Seq)
78+ }
79+ return seqs
80+}
A internal/adapters/drm/front_test.go +479 -0
@@ -0,0 +1,479 @@
1+package drm
2+
3+import (
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+// overlayFrames submits the overlay scene once (composed), then with the
18+// overlay window's next buffers: the frames after the first only flip the
19+// overlay buffer.
20+func overlayFrames(t *testing.T, o *Output, r *portsmocks.MockRenderer, s ports.Scene, c map[ports.WindowID]ports.SurfaceContent, frames int) {
21+ t.Helper()
22+ for i := range frames {
23+ win := c[2]
24+ buf := *win.DMABuf
25+ buf.ID = uint64(9 + i)
26+ win.DMABuf, win.Seq = &buf, uint64(1+i)
27+ c[2] = win
28+ if _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil); err != nil {
29+ t.Fatalf("frame %d: %v", i, err)
30+ }
31+ o.frame.endPending()
32+ }
33+}
34+
35+// When only the overlay window's buffer changes, the image on screen is
36+// committed again with the new buffer: no render, no probe once KMS took
37+// the same frame.
38+func TestSubmitFrameOverlayOnlySkipsRender(t *testing.T) {
39+ o, k, commits := overlayOutput(t)
40+ o.cursor, o.vrrProp = nil, 0
41+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) {
42+ return uint32(80 + b.ID), nil
43+ }).Times(4)
44+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
45+ s, c := overlayScene()
46+ r := portsmocks.NewMockRenderer(t)
47+ r.EXPECT().UseTarget(0).Return().Once()
48+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
49+ overlayFrames(t, o, r, s, c, 4)
50+ var probes, frames int
51+ for _, cm := range *commits {
52+ if cm.flags&atomicTestOnly != 0 {
53+ probes++
54+ continue
55+ }
56+ frames++
57+ if v, _ := cm.req.value(tPrimary, pFB); v != 70 {
58+ t.Fatalf("frame %d primary fb %d, want the composed image 70", frames, v)
59+ }
60+ if _, ok := cm.req.value(tPrimary, pFence); ok {
61+ t.Fatalf("frame %d waits on a render fence", frames)
62+ }
63+ }
64+ if frames != 4 {
65+ t.Fatalf("%d frame commits", frames)
66+ }
67+ // Each new overlay buffer is probed once; nothing else is.
68+ if probes != 4 {
69+ t.Fatalf("%d probes", probes)
70+ }
71+ if last := (*commits)[len(*commits)-1]; o.queued != 12 {
72+ t.Fatalf("queued %d", o.queued)
73+ } else if v, _ := last.req.value(tOverlay, pFB); v != 92 {
74+ t.Fatalf("overlay fb %d", v)
75+ }
76+ if o.back != 1 {
77+ t.Fatalf("back %d: the skipped frames drew nothing", o.back)
78+ }
79+}
80+
81+// An uncommitted render that may still read client buffers forces
82+// composition: the overlay-only flip carries no fence to order it after
83+// those reads, and its report would let wayland release them.
84+func TestSubmitFrameOverlayOnlyWaitsForHeldReads(t *testing.T) {
85+ o, k, _ := overlayOutput(t)
86+ o.cursor, o.vrrProp = nil, 0
87+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Maybe()
88+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
89+ s, c := overlayScene()
90+ r := portsmocks.NewMockRenderer(t)
91+ r.EXPECT().UseTarget(mock.Anything).Return().Twice()
92+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Twice()
93+ overlayFrames(t, o, r, s, c, 1)
94+ // An unsignalled fence of a render whose commit failed.
95+ fence, w, err := os.Pipe()
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ defer fence.Close()
100+ defer w.Close()
101+ o.holdRead(fence)
102+ defer o.dropRead()
103+ overlayFrames(t, o, r, s, c, 1)
104+}
105+
106+// The desktop case through Run: a video (a dmabuf window on the overlay)
107+// next to a static window. Once the overlay is up, each new video buffer is
108+// one frame commit of the same composed image with no render, and each flip
109+// reports the video shown zero-copy at its latest content.
110+func TestRunVideoOnOverlayDoesNotRender(t *testing.T) {
111+ o, k, commits, commitMu := testOutputMu(t)
112+ o.cursor, o.tearing, o.vrrProp = nil, false, 0
113+ props := map[string]uint32{"zpos": pZpos}
114+ for n, v := range planeProps {
115+ props[n] = v
116+ }
117+ o.primary.props = props
118+ o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}}
119+ o.primary.formats = nil
120+ o.pickOverlay()
121+ flips := make(chan flipEvent, 1)
122+ o.flipped = flips
123+ r := portsmocks.NewMockRenderer(t)
124+ pipeBuf := func() ports.DMABuf {
125+ f, w, err := os.Pipe()
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ w.Close()
130+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
131+ }
132+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
133+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
134+ r.EXPECT().UseTarget(mock.Anything).Return()
135+ var renders atomic.Int32
136+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
137+ renders.Add(1)
138+ return nil, nil
139+ })
140+ r.EXPECT().Close().Return().Once()
141+ var images atomic.Uint32
142+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) {
143+ if b.ID == 0 {
144+ return 70 + images.Add(1) - 1, nil // the output images
145+ }
146+ return uint32(100 + b.ID), nil
147+ })
148+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
149+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
150+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
151+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, nil).Maybe()
152+ frameCommits := func() []commitRec {
153+ commitMu.Lock()
154+ defer commitMu.Unlock()
155+ var out []commitRec
156+ for _, c := range *commits {
157+ if c.user&3 == userFrame {
158+ out = append(out, c)
159+ }
160+ }
161+ return out
162+ }
163+ scenes := make(chan ports.Scene, 1)
164+ contents := newContentFeed(t, 1)
165+ sink, presented := presentedSink(t, 64)
166+ ctx, cancel := context.WithCancel(context.Background())
167+ done := make(chan error, 1)
168+ go func() {
169+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents.in(), contents.ret, nil, sink, nil, nil)
170+ }()
171+ // Stop Run on every path, before the mocks check their calls.
172+ t.Cleanup(func() {
173+ cancel()
174+ select {
175+ case err := <-done:
176+ if err != nil {
177+ t.Error(err)
178+ }
179+ case <-time.After(2 * time.Second):
180+ t.Error("Run did not stop")
181+ }
182+ })
183+ static := ports.SurfaceContent{ID: 1, Seq: 1, Width: 100, Height: 100, Opaque: true, Solid: &ports.SolidColor{A: 1}}
184+ video := func(n int) ports.SurfaceContent {
185+ return ports.SurfaceContent{ID: 2, Seq: uint64(n), Width: 100, Height: 100, Opaque: true,
186+ DMABuf: &ports.DMABuf{ID: uint64(n%3 + 1), Width: 100, Height: 100, Format: fourccXRGB}}
187+ }
188+ contents.send(static, video(1))
189+ scenes <- ports.Scene{Seq: 1, Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{
190+ {ID: 1, Rect: ports.Rect{W: 100, H: 100}},
191+ {ID: 2, Rect: ports.Rect{X: 100, W: 100, H: 100}},
192+ }}
193+ // play delivers video frame n and flips every frame commit until a flip
194+ // shows it; it returns that commit and its report. (Seen is what the
195+ // output took in, which may run ahead of the flip.)
196+ flipped := 0
197+ vseq := uint32(0)
198+ play := func(n int) (commitRec, ports.OutputPresented) {
199+ t.Helper()
200+ contents.send(video(n))
201+ var c commitRec
202+ var got ports.OutputPresented
203+ waitFor(t, func() bool {
204+ if cs := frameCommits(); len(cs) > flipped {
205+ c = cs[len(cs)-1]
206+ flipped = len(cs)
207+ vseq++
208+ flips <- flipEvent{crtc: tCrtc, user: c.user, when: time.Duration(vseq) * time.Millisecond, seq: vseq}
209+ }
210+ for {
211+ select {
212+ case p := <-presented:
213+ if p.Flip != nil && p.Flip.Shows[2] == uint64(n) {
214+ got = p
215+ return true
216+ }
217+ default:
218+ return false
219+ }
220+ }
221+ })
222+ return c, got
223+ }
224+ // Warm up until the video is on the overlay over a composed image.
225+ n := 1
226+ for ; n < 10; n++ {
227+ c, _ := play(n)
228+ if v, _ := c.req.value(tOverlay, pFB); v != 0 {
229+ break
230+ }
231+ }
232+ base, commitsBefore := renders.Load(), len(frameCommits())
233+ const frames = 60
234+ for i := range frames {
235+ n++
236+ c, p := play(n)
237+ if v, _ := c.req.value(tOverlay, pFB); v != uint64(100+n%3+1) {
238+ t.Fatalf("video frame %d: overlay fb %d", i, v)
239+ }
240+ if _, ok := c.req.value(tPrimary, pFence); ok {
241+ t.Fatalf("video frame %d waits on a render", i)
242+ }
243+ // The flip reports the video shown zero-copy at its content.
244+ if p.Flip.ZeroCopy != 2 || p.Flip.Shows[2] != uint64(n) || p.Seen[1] != 1 {
245+ t.Fatalf("video frame %d: report %+v flip %+v", i, p, *p.Flip)
246+ }
247+ }
248+ if got := len(frameCommits()) - commitsBefore; got != frames {
249+ t.Fatalf("%d frame commits for %d video frames", got, frames)
250+ }
251+ if got := renders.Load() - base; got != 0 {
252+ t.Fatalf("%d renders for %d video frames, want 0", got, frames)
253+ }
254+ // Three buffers in rotation: each probed once, then remembered.
255+ commitMu.Lock()
256+ probes := 0
257+ for _, c := range *commits {
258+ if c.flags&atomicTestOnly != 0 {
259+ probes++
260+ }
261+ }
262+ commitMu.Unlock()
263+ if probes > 4 {
264+ t.Fatalf("%d overlay probes for %d video frames of 3 buffers", probes, frames)
265+ }
266+}
267+
268+// Refusals on the overlay-only path: a refused probe composes the whole
269+// scene at once; a refused commit drops the overlay and asks for a new frame,
270+// leaving the images and the queue as they were.
271+func TestSubmitFrameOverlayOnlyRefused(t *testing.T) {
272+ for _, tc := range []struct {
273+ name string
274+ refuse func(r *atomicReq, flags uint32) bool
275+ wantErr error
276+ renders int
277+ }{
278+ {"probe", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly != 0 }, nil, 1},
279+ {"commit", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly == 0 }, errOverlayDropped, 0},
280+ } {
281+ t.Run(tc.name, func(t *testing.T) {
282+ refusing := false
283+ o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
284+ if refusing && tc.refuse(r, flags) {
285+ return unix.EINVAL
286+ }
287+ return nil
288+ })
289+ props := map[string]uint32{"zpos": pZpos}
290+ for n, v := range planeProps {
291+ props[n] = v
292+ }
293+ o.primary.props, o.primary.formats = props, nil
294+ o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}}
295+ o.pickOverlay()
296+ o.cursor, o.vrrProp = nil, 0
297+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) {
298+ return uint32(80 + b.ID), nil
299+ })
300+ s, c := overlayScene()
301+ r := portsmocks.NewMockRenderer(t)
302+ r.EXPECT().UseTarget(mock.Anything).Return()
303+ var drawn []int
304+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
305+ drawn = append(drawn, len(s.Windows))
306+ return nil, nil
307+ })
308+ overlayFrames(t, o, r, s, c, 1) // composed, front image held
309+ back, queued := o.back, o.queued
310+ refusing = true
311+ win := c[2]
312+ buf := *win.DMABuf
313+ buf.ID, win.DMABuf, win.Seq = 20, &buf, 2
314+ c[2] = win
315+ _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil)
316+ if !errors.Is(err, tc.wantErr) && err != tc.wantErr {
317+ t.Fatalf("err %v, want %v", err, tc.wantErr)
318+ }
319+ if got := len(drawn) - 1; got != tc.renders {
320+ t.Fatalf("%d renders, want %d", got, tc.renders)
321+ }
322+ if tc.renders == 1 && drawn[1] != 2 {
323+ t.Fatalf("composed %d windows, want the whole scene", drawn[1])
324+ }
325+ if tc.wantErr != nil {
326+ if o.back != back || o.queued != queued || o.clientFBs[20].overlayFailed != "cursor_conflict" {
327+ t.Fatalf("back %d queued %d reason %q", o.back, o.queued, o.clientFBs[20].overlayFailed)
328+ }
329+ // The retry composes the window.
330+ refusing = false
331+ if _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil); err != nil {
332+ t.Fatal(err)
333+ }
334+ if drawn[len(drawn)-1] != 2 {
335+ t.Fatalf("retry composed %d windows", drawn[len(drawn)-1])
336+ }
337+ }
338+ })
339+ }
340+}
341+
342+// A commit result with the overlay on: refusals the overlay causes ask for a
343+// frame without it and leave the queue; a commit queues the buffer.
344+func TestFinishOverlayCommit(t *testing.T) {
345+ boom := errors.New("boom")
346+ for _, tc := range []struct {
347+ name string
348+ err error
349+ want error
350+ wantQueued uint64
351+ reason string
352+ }{
353+ {"committed", nil, nil, 9, ""},
354+ {"colour refused", errColorRefused, errOverlayDropped, 1, "color_refused"},
355+ {"overlay conflict", unix.EINVAL, errOverlayDropped, 1, "cursor_conflict"},
356+ {"other", boom, boom, 1, ""},
357+ } {
358+ t.Run(tc.name, func(t *testing.T) {
359+ o, _, _ := testOutput(t)
360+ o.queued = 1
361+ o.clientFBs[9] = &clientFB{}
362+ if err := o.finishOverlayCommit(tc.err, 9); err != tc.want {
363+ t.Fatalf("err %v, want %v", err, tc.want)
364+ }
365+ if o.queued != tc.wantQueued || o.overlayReason != tc.reason {
366+ t.Fatalf("queued %d reason %q", o.queued, o.overlayReason)
367+ }
368+ })
369+ }
370+}
371+
372+// A frame whose buffer KMS took before is not probed again.
373+func TestOverlayProbeCached(t *testing.T) {
374+ o, k, commits := overlayOutput(t)
375+ o.cursor.image = true
376+ o.cursor.Move(10, 10)
377+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
378+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
379+ s, c := overlayScene()
380+ ov, _ := o.overlayFrame(s, c)
381+ for range 3 {
382+ if !o.testOverlay(70, ov) {
383+ t.Fatal("refused")
384+ }
385+ }
386+ if len(*commits) != 1 {
387+ t.Fatalf("%d probes, want 1", len(*commits))
388+ }
389+ // Another place, primary image or cursor is probed.
390+ moved := ov
391+ moved.rect.X++
392+ o.testOverlay(70, moved)
393+ o.testOverlay(71, ov)
394+ o.cursor.Move(20, 10)
395+ o.testOverlay(70, ov)
396+ if len(*commits) != 4 {
397+ t.Fatalf("%d probes, want 4", len(*commits))
398+ }
399+ // The probe tested the cursor where the cache remembers it.
400+ if v, _ := (*commits)[3].req.value(tCursor, 14); v != 20-uint64(o.cursor.hotX) {
401+ t.Fatalf("probe cursor x %d", v)
402+ }
403+ // New colour state (HDR brightness) is probed again.
404+ o.colorMult++
405+ o.testOverlay(70, ov)
406+ if len(*commits) != 5 {
407+ t.Fatalf("%d probes after a colour change, want 5", len(*commits))
408+ }
409+ // A removed client framebuffer may give its id to another buffer.
410+ k.EXPECT().rmFB(uint32(88)).Return(nil).Once()
411+ o.dropClientFBs(time.Now(), true)
412+ o.testOverlay(70, ov)
413+ if len(*commits) != 6 {
414+ t.Fatal("probe cached across a framebuffer removal")
415+ }
416+ // A modeset (VT resume) forgets them.
417+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
418+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, nil).Maybe()
419+ if err := o.modeset(); err != nil {
420+ t.Fatal(err)
421+ }
422+ before := len(*commits)
423+ o.testOverlay(70, ov)
424+ if len(*commits) != before+1 {
425+ t.Fatal("probe cached across a modeset")
426+ }
427+}
428+
429+// Anything but the overlay window changing composes: another window's
430+// content, the scene, or an image drawn by another path.
431+func TestSubmitFrameOverlayOnlyNeedsSameImage(t *testing.T) {
432+ for name, edit := range map[string]func(o *Output, s *ports.Scene, c map[ports.WindowID]ports.SurfaceContent){
433+ "other window content": func(_ *Output, _ *ports.Scene, c map[ports.WindowID]ports.SurfaceContent) {
434+ w := c[1]
435+ w.Seq++
436+ c[1] = w
437+ },
438+ "scene": func(_ *Output, s *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) {
439+ s.Background = "#ffffff"
440+ },
441+ "image forgotten": func(o *Output, _ *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) {
442+ o.front.forget()
443+ },
444+ "layer content": func(_ *Output, _ *ports.Scene, c map[ports.WindowID]ports.SurfaceContent) {
445+ l := c[5]
446+ l.Seq++
447+ c[5] = l
448+ },
449+ // The scene's slices rewritten in place by their owner: what the
450+ // image holds is a copy.
451+ "scene slice rewritten": func(_ *Output, s *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) {
452+ s.Windows[0].Rect.W = 90
453+ },
454+ } {
455+ t.Run(name, func(t *testing.T) {
456+ o, k, _ := overlayOutput(t)
457+ o.cursor, o.vrrProp = nil, 0
458+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Maybe()
459+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
460+ s, c := overlayScene()
461+ s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerBottom, Rect: ports.Rect{W: 10, H: 10}}}
462+ c[1] = ports.SurfaceContent{ID: 1, Seq: 1, Width: 100, Height: 100, Opaque: true, Solid: &ports.SolidColor{A: 1}}
463+ c[5] = ports.SurfaceContent{ID: 5, Seq: 1, Width: 10, Height: 10, Opaque: true, Solid: &ports.SolidColor{A: 1}}
464+ r := portsmocks.NewMockRenderer(t)
465+ r.EXPECT().UseTarget(mock.Anything).Return().Twice()
466+ var drawn []int
467+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
468+ drawn = append(drawn, len(s.Windows))
469+ return nil, nil
470+ }).Twice()
471+ overlayFrames(t, o, r, s, c, 1)
472+ edit(o, &s, c)
473+ overlayFrames(t, o, r, s, c, 1)
474+ if len(drawn) != 2 || drawn[1] != 1 {
475+ t.Fatalf("rendered %v", drawn)
476+ }
477+ })
478+ }
479+}
@@ -0,0 +1,479 @@
1+package drm
2+
3+import (
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+// overlayFrames submits the overlay scene once (composed), then with the
18+// overlay window's next buffers: the frames after the first only flip the
19+// overlay buffer.
20+func overlayFrames(t *testing.T, o *Output, r *portsmocks.MockRenderer, s ports.Scene, c map[ports.WindowID]ports.SurfaceContent, frames int) {
21+ t.Helper()
22+ for i := range frames {
23+ win := c[2]
24+ buf := *win.DMABuf
25+ buf.ID = uint64(9 + i)
26+ win.DMABuf, win.Seq = &buf, uint64(1+i)
27+ c[2] = win
28+ if _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil); err != nil {
29+ t.Fatalf("frame %d: %v", i, err)
30+ }
31+ o.frame.endPending()
32+ }
33+}
34+
35+// When only the overlay window's buffer changes, the image on screen is
36+// committed again with the new buffer: no render, no probe once KMS took
37+// the same frame.
38+func TestSubmitFrameOverlayOnlySkipsRender(t *testing.T) {
39+ o, k, commits := overlayOutput(t)
40+ o.cursor, o.vrrProp = nil, 0
41+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) {
42+ return uint32(80 + b.ID), nil
43+ }).Times(4)
44+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
45+ s, c := overlayScene()
46+ r := portsmocks.NewMockRenderer(t)
47+ r.EXPECT().UseTarget(0).Return().Once()
48+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
49+ overlayFrames(t, o, r, s, c, 4)
50+ var probes, frames int
51+ for _, cm := range *commits {
52+ if cm.flags&atomicTestOnly != 0 {
53+ probes++
54+ continue
55+ }
56+ frames++
57+ if v, _ := cm.req.value(tPrimary, pFB); v != 70 {
58+ t.Fatalf("frame %d primary fb %d, want the composed image 70", frames, v)
59+ }
60+ if _, ok := cm.req.value(tPrimary, pFence); ok {
61+ t.Fatalf("frame %d waits on a render fence", frames)
62+ }
63+ }
64+ if frames != 4 {
65+ t.Fatalf("%d frame commits", frames)
66+ }
67+ // Each new overlay buffer is probed once; nothing else is.
68+ if probes != 4 {
69+ t.Fatalf("%d probes", probes)
70+ }
71+ if last := (*commits)[len(*commits)-1]; o.queued != 12 {
72+ t.Fatalf("queued %d", o.queued)
73+ } else if v, _ := last.req.value(tOverlay, pFB); v != 92 {
74+ t.Fatalf("overlay fb %d", v)
75+ }
76+ if o.back != 1 {
77+ t.Fatalf("back %d: the skipped frames drew nothing", o.back)
78+ }
79+}
80+
81+// An uncommitted render that may still read client buffers forces
82+// composition: the overlay-only flip carries no fence to order it after
83+// those reads, and its report would let wayland release them.
84+func TestSubmitFrameOverlayOnlyWaitsForHeldReads(t *testing.T) {
85+ o, k, _ := overlayOutput(t)
86+ o.cursor, o.vrrProp = nil, 0
87+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Maybe()
88+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
89+ s, c := overlayScene()
90+ r := portsmocks.NewMockRenderer(t)
91+ r.EXPECT().UseTarget(mock.Anything).Return().Twice()
92+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Twice()
93+ overlayFrames(t, o, r, s, c, 1)
94+ // An unsignalled fence of a render whose commit failed.
95+ fence, w, err := os.Pipe()
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ defer fence.Close()
100+ defer w.Close()
101+ o.holdRead(fence)
102+ defer o.dropRead()
103+ overlayFrames(t, o, r, s, c, 1)
104+}
105+
106+// The desktop case through Run: a video (a dmabuf window on the overlay)
107+// next to a static window. Once the overlay is up, each new video buffer is
108+// one frame commit of the same composed image with no render, and each flip
109+// reports the video shown zero-copy at its latest content.
110+func TestRunVideoOnOverlayDoesNotRender(t *testing.T) {
111+ o, k, commits, commitMu := testOutputMu(t)
112+ o.cursor, o.tearing, o.vrrProp = nil, false, 0
113+ props := map[string]uint32{"zpos": pZpos}
114+ for n, v := range planeProps {
115+ props[n] = v
116+ }
117+ o.primary.props = props
118+ o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}}
119+ o.primary.formats = nil
120+ o.pickOverlay()
121+ flips := make(chan flipEvent, 1)
122+ o.flipped = flips
123+ r := portsmocks.NewMockRenderer(t)
124+ pipeBuf := func() ports.DMABuf {
125+ f, w, err := os.Pipe()
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ w.Close()
130+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
131+ }
132+ r.EXPECT().SetHDR(float64(0)).Return().Maybe()
133+ r.EXPECT().ExportTargets(2, mock.Anything, false).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
134+ r.EXPECT().UseTarget(mock.Anything).Return()
135+ var renders atomic.Int32
136+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
137+ renders.Add(1)
138+ return nil, nil
139+ })
140+ r.EXPECT().Close().Return().Once()
141+ var images atomic.Uint32
142+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) {
143+ if b.ID == 0 {
144+ return 70 + images.Add(1) - 1, nil // the output images
145+ }
146+ return uint32(100 + b.ID), nil
147+ })
148+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
149+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
150+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
151+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, nil).Maybe()
152+ frameCommits := func() []commitRec {
153+ commitMu.Lock()
154+ defer commitMu.Unlock()
155+ var out []commitRec
156+ for _, c := range *commits {
157+ if c.user&3 == userFrame {
158+ out = append(out, c)
159+ }
160+ }
161+ return out
162+ }
163+ scenes := make(chan ports.Scene, 1)
164+ contents := newContentFeed(t, 1)
165+ sink, presented := presentedSink(t, 64)
166+ ctx, cancel := context.WithCancel(context.Background())
167+ done := make(chan error, 1)
168+ go func() {
169+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents.in(), contents.ret, nil, sink, nil, nil)
170+ }()
171+ // Stop Run on every path, before the mocks check their calls.
172+ t.Cleanup(func() {
173+ cancel()
174+ select {
175+ case err := <-done:
176+ if err != nil {
177+ t.Error(err)
178+ }
179+ case <-time.After(2 * time.Second):
180+ t.Error("Run did not stop")
181+ }
182+ })
183+ static := ports.SurfaceContent{ID: 1, Seq: 1, Width: 100, Height: 100, Opaque: true, Solid: &ports.SolidColor{A: 1}}
184+ video := func(n int) ports.SurfaceContent {
185+ return ports.SurfaceContent{ID: 2, Seq: uint64(n), Width: 100, Height: 100, Opaque: true,
186+ DMABuf: &ports.DMABuf{ID: uint64(n%3 + 1), Width: 100, Height: 100, Format: fourccXRGB}}
187+ }
188+ contents.send(static, video(1))
189+ scenes <- ports.Scene{Seq: 1, Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{
190+ {ID: 1, Rect: ports.Rect{W: 100, H: 100}},
191+ {ID: 2, Rect: ports.Rect{X: 100, W: 100, H: 100}},
192+ }}
193+ // play delivers video frame n and flips every frame commit until a flip
194+ // shows it; it returns that commit and its report. (Seen is what the
195+ // output took in, which may run ahead of the flip.)
196+ flipped := 0
197+ vseq := uint32(0)
198+ play := func(n int) (commitRec, ports.OutputPresented) {
199+ t.Helper()
200+ contents.send(video(n))
201+ var c commitRec
202+ var got ports.OutputPresented
203+ waitFor(t, func() bool {
204+ if cs := frameCommits(); len(cs) > flipped {
205+ c = cs[len(cs)-1]
206+ flipped = len(cs)
207+ vseq++
208+ flips <- flipEvent{crtc: tCrtc, user: c.user, when: time.Duration(vseq) * time.Millisecond, seq: vseq}
209+ }
210+ for {
211+ select {
212+ case p := <-presented:
213+ if p.Flip != nil && p.Flip.Shows[2] == uint64(n) {
214+ got = p
215+ return true
216+ }
217+ default:
218+ return false
219+ }
220+ }
221+ })
222+ return c, got
223+ }
224+ // Warm up until the video is on the overlay over a composed image.
225+ n := 1
226+ for ; n < 10; n++ {
227+ c, _ := play(n)
228+ if v, _ := c.req.value(tOverlay, pFB); v != 0 {
229+ break
230+ }
231+ }
232+ base, commitsBefore := renders.Load(), len(frameCommits())
233+ const frames = 60
234+ for i := range frames {
235+ n++
236+ c, p := play(n)
237+ if v, _ := c.req.value(tOverlay, pFB); v != uint64(100+n%3+1) {
238+ t.Fatalf("video frame %d: overlay fb %d", i, v)
239+ }
240+ if _, ok := c.req.value(tPrimary, pFence); ok {
241+ t.Fatalf("video frame %d waits on a render", i)
242+ }
243+ // The flip reports the video shown zero-copy at its content.
244+ if p.Flip.ZeroCopy != 2 || p.Flip.Shows[2] != uint64(n) || p.Seen[1] != 1 {
245+ t.Fatalf("video frame %d: report %+v flip %+v", i, p, *p.Flip)
246+ }
247+ }
248+ if got := len(frameCommits()) - commitsBefore; got != frames {
249+ t.Fatalf("%d frame commits for %d video frames", got, frames)
250+ }
251+ if got := renders.Load() - base; got != 0 {
252+ t.Fatalf("%d renders for %d video frames, want 0", got, frames)
253+ }
254+ // Three buffers in rotation: each probed once, then remembered.
255+ commitMu.Lock()
256+ probes := 0
257+ for _, c := range *commits {
258+ if c.flags&atomicTestOnly != 0 {
259+ probes++
260+ }
261+ }
262+ commitMu.Unlock()
263+ if probes > 4 {
264+ t.Fatalf("%d overlay probes for %d video frames of 3 buffers", probes, frames)
265+ }
266+}
267+
268+// Refusals on the overlay-only path: a refused probe composes the whole
269+// scene at once; a refused commit drops the overlay and asks for a new frame,
270+// leaving the images and the queue as they were.
271+func TestSubmitFrameOverlayOnlyRefused(t *testing.T) {
272+ for _, tc := range []struct {
273+ name string
274+ refuse func(r *atomicReq, flags uint32) bool
275+ wantErr error
276+ renders int
277+ }{
278+ {"probe", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly != 0 }, nil, 1},
279+ {"commit", func(_ *atomicReq, flags uint32) bool { return flags&atomicTestOnly == 0 }, errOverlayDropped, 0},
280+ } {
281+ t.Run(tc.name, func(t *testing.T) {
282+ refusing := false
283+ o, k, _, _ := testOutputRules(t, func(r *atomicReq, flags uint32) error {
284+ if refusing && tc.refuse(r, flags) {
285+ return unix.EINVAL
286+ }
287+ return nil
288+ })
289+ props := map[string]uint32{"zpos": pZpos}
290+ for n, v := range planeProps {
291+ props[n] = v
292+ }
293+ o.primary.props, o.primary.formats = props, nil
294+ o.stray = []*plane{{id: tOverlay, typ: planeOverlay, props: props, zposValue: 2, formats: []ports.DMABufFormat{{Format: fourccXRGB}}}}
295+ o.pickOverlay()
296+ o.cursor, o.vrrProp = nil, 0
297+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).RunAndReturn(func(b *ports.DMABuf, _ uint32) (uint32, error) {
298+ return uint32(80 + b.ID), nil
299+ })
300+ s, c := overlayScene()
301+ r := portsmocks.NewMockRenderer(t)
302+ r.EXPECT().UseTarget(mock.Anything).Return()
303+ var drawn []int
304+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
305+ drawn = append(drawn, len(s.Windows))
306+ return nil, nil
307+ })
308+ overlayFrames(t, o, r, s, c, 1) // composed, front image held
309+ back, queued := o.back, o.queued
310+ refusing = true
311+ win := c[2]
312+ buf := *win.DMABuf
313+ buf.ID, win.DMABuf, win.Seq = 20, &buf, 2
314+ c[2] = win
315+ _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil)
316+ if !errors.Is(err, tc.wantErr) && err != tc.wantErr {
317+ t.Fatalf("err %v, want %v", err, tc.wantErr)
318+ }
319+ if got := len(drawn) - 1; got != tc.renders {
320+ t.Fatalf("%d renders, want %d", got, tc.renders)
321+ }
322+ if tc.renders == 1 && drawn[1] != 2 {
323+ t.Fatalf("composed %d windows, want the whole scene", drawn[1])
324+ }
325+ if tc.wantErr != nil {
326+ if o.back != back || o.queued != queued || o.clientFBs[20].overlayFailed != "cursor_conflict" {
327+ t.Fatalf("back %d queued %d reason %q", o.back, o.queued, o.clientFBs[20].overlayFailed)
328+ }
329+ // The retry composes the window.
330+ refusing = false
331+ if _, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, nil); err != nil {
332+ t.Fatal(err)
333+ }
334+ if drawn[len(drawn)-1] != 2 {
335+ t.Fatalf("retry composed %d windows", drawn[len(drawn)-1])
336+ }
337+ }
338+ })
339+ }
340+}
341+
342+// A commit result with the overlay on: refusals the overlay causes ask for a
343+// frame without it and leave the queue; a commit queues the buffer.
344+func TestFinishOverlayCommit(t *testing.T) {
345+ boom := errors.New("boom")
346+ for _, tc := range []struct {
347+ name string
348+ err error
349+ want error
350+ wantQueued uint64
351+ reason string
352+ }{
353+ {"committed", nil, nil, 9, ""},
354+ {"colour refused", errColorRefused, errOverlayDropped, 1, "color_refused"},
355+ {"overlay conflict", unix.EINVAL, errOverlayDropped, 1, "cursor_conflict"},
356+ {"other", boom, boom, 1, ""},
357+ } {
358+ t.Run(tc.name, func(t *testing.T) {
359+ o, _, _ := testOutput(t)
360+ o.queued = 1
361+ o.clientFBs[9] = &clientFB{}
362+ if err := o.finishOverlayCommit(tc.err, 9); err != tc.want {
363+ t.Fatalf("err %v, want %v", err, tc.want)
364+ }
365+ if o.queued != tc.wantQueued || o.overlayReason != tc.reason {
366+ t.Fatalf("queued %d reason %q", o.queued, o.overlayReason)
367+ }
368+ })
369+ }
370+}
371+
372+// A frame whose buffer KMS took before is not probed again.
373+func TestOverlayProbeCached(t *testing.T) {
374+ o, k, commits := overlayOutput(t)
375+ o.cursor.image = true
376+ o.cursor.Move(10, 10)
377+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once()
378+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
379+ s, c := overlayScene()
380+ ov, _ := o.overlayFrame(s, c)
381+ for range 3 {
382+ if !o.testOverlay(70, ov) {
383+ t.Fatal("refused")
384+ }
385+ }
386+ if len(*commits) != 1 {
387+ t.Fatalf("%d probes, want 1", len(*commits))
388+ }
389+ // Another place, primary image or cursor is probed.
390+ moved := ov
391+ moved.rect.X++
392+ o.testOverlay(70, moved)
393+ o.testOverlay(71, ov)
394+ o.cursor.Move(20, 10)
395+ o.testOverlay(70, ov)
396+ if len(*commits) != 4 {
397+ t.Fatalf("%d probes, want 4", len(*commits))
398+ }
399+ // The probe tested the cursor where the cache remembers it.
400+ if v, _ := (*commits)[3].req.value(tCursor, 14); v != 20-uint64(o.cursor.hotX) {
401+ t.Fatalf("probe cursor x %d", v)
402+ }
403+ // New colour state (HDR brightness) is probed again.
404+ o.colorMult++
405+ o.testOverlay(70, ov)
406+ if len(*commits) != 5 {
407+ t.Fatalf("%d probes after a colour change, want 5", len(*commits))
408+ }
409+ // A removed client framebuffer may give its id to another buffer.
410+ k.EXPECT().rmFB(uint32(88)).Return(nil).Once()
411+ o.dropClientFBs(time.Now(), true)
412+ o.testOverlay(70, ov)
413+ if len(*commits) != 6 {
414+ t.Fatal("probe cached across a framebuffer removal")
415+ }
416+ // A modeset (VT resume) forgets them.
417+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
418+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, nil).Maybe()
419+ if err := o.modeset(); err != nil {
420+ t.Fatal(err)
421+ }
422+ before := len(*commits)
423+ o.testOverlay(70, ov)
424+ if len(*commits) != before+1 {
425+ t.Fatal("probe cached across a modeset")
426+ }
427+}
428+
429+// Anything but the overlay window changing composes: another window's
430+// content, the scene, or an image drawn by another path.
431+func TestSubmitFrameOverlayOnlyNeedsSameImage(t *testing.T) {
432+ for name, edit := range map[string]func(o *Output, s *ports.Scene, c map[ports.WindowID]ports.SurfaceContent){
433+ "other window content": func(_ *Output, _ *ports.Scene, c map[ports.WindowID]ports.SurfaceContent) {
434+ w := c[1]
435+ w.Seq++
436+ c[1] = w
437+ },
438+ "scene": func(_ *Output, s *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) {
439+ s.Background = "#ffffff"
440+ },
441+ "image forgotten": func(o *Output, _ *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) {
442+ o.front.forget()
443+ },
444+ "layer content": func(_ *Output, _ *ports.Scene, c map[ports.WindowID]ports.SurfaceContent) {
445+ l := c[5]
446+ l.Seq++
447+ c[5] = l
448+ },
449+ // The scene's slices rewritten in place by their owner: what the
450+ // image holds is a copy.
451+ "scene slice rewritten": func(_ *Output, s *ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) {
452+ s.Windows[0].Rect.W = 90
453+ },
454+ } {
455+ t.Run(name, func(t *testing.T) {
456+ o, k, _ := overlayOutput(t)
457+ o.cursor, o.vrrProp = nil, 0
458+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Maybe()
459+ o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}}
460+ s, c := overlayScene()
461+ s.Layers = []ports.SceneLayer{{ID: 5, Layer: ports.LayerBottom, Rect: ports.Rect{W: 10, H: 10}}}
462+ c[1] = ports.SurfaceContent{ID: 1, Seq: 1, Width: 100, Height: 100, Opaque: true, Solid: &ports.SolidColor{A: 1}}
463+ c[5] = ports.SurfaceContent{ID: 5, Seq: 1, Width: 10, Height: 10, Opaque: true, Solid: &ports.SolidColor{A: 1}}
464+ r := portsmocks.NewMockRenderer(t)
465+ r.EXPECT().UseTarget(mock.Anything).Return().Twice()
466+ var drawn []int
467+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
468+ drawn = append(drawn, len(s.Windows))
469+ return nil, nil
470+ }).Twice()
471+ overlayFrames(t, o, r, s, c, 1)
472+ edit(o, &s, c)
473+ overlayFrames(t, o, r, s, c, 1)
474+ if len(drawn) != 2 || drawn[1] != 1 {
475+ t.Fatalf("rendered %v", drawn)
476+ }
477+ })
478+ }
479+}
M internal/adapters/drm/images.go +2 -0
@@ -237,6 +237,8 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool)
237237
238238 // freeImages removes the output images' framebuffers.
239239 func (o *Output) freeImages() {
240+ o.front.forget()
241+ o.forgetOverlayProbes()
240242 for i, fb := range o.fbs {
241243 if fb != 0 {
242244 _ = o.k.rmFB(fb)
@@ -237,6 +237,8 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64, singlePlane bool)
237 237
238 // freeImages removes the output images' framebuffers.238 // freeImages removes the output images' framebuffers.
239 func (o *Output) freeImages() {239 func (o *Output) freeImages() {
240+ o.front.forget()
241+ o.forgetOverlayProbes()
240 for i, fb := range o.fbs {242 for i, fb := range o.fbs {
241 if fb != 0 {243 if fb != 0 {
242 _ = o.k.rmFB(fb)244 _ = o.k.rmFB(fb)
M internal/adapters/drm/output.go +8 -0
@@ -147,6 +147,12 @@ type Output struct {
147147 frameReq, stateReq, withoutReq, probeReq atomicReq
148148 // restWindows is overlayFrame's scene windows, valid until its next call.
149149 restWindows []ports.SceneWindow
150+ // front is what the output image on screen holds (front.go).
151+ front frontImage
152+ // overlayPassed are the last overlay probes KMS took (testOverlay):
153+ // enough for a client cycling through four buffers.
154+ overlayPassed [4]overlayProbe
155+ overlayNext int
150156 // lastFrame is when the last frame was committed. cursorHeld: a cursor
151157 // move waits for the next frame (see cursorWaits).
152158 lastFrame time.Time
@@ -437,6 +443,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
437443 for _, fb := range o.clientFBs {
438444 fb.scaleTestedOK, fb.scaleRefused = false, false
439445 }
446+ o.front.forget()
447+ o.forgetOverlayProbes()
440448 o.forgetColor()
441449 o.forgetColorVerdicts()
442450 o.shown, o.queued = 0, 0
@@ -147,6 +147,12 @@ type Output struct {
147 frameReq, stateReq, withoutReq, probeReq atomicReq147 frameReq, stateReq, withoutReq, probeReq atomicReq
148 // restWindows is overlayFrame's scene windows, valid until its next call.148 // restWindows is overlayFrame's scene windows, valid until its next call.
149 restWindows []ports.SceneWindow149 restWindows []ports.SceneWindow
150+ // front is what the output image on screen holds (front.go).
151+ front frontImage
152+ // overlayPassed are the last overlay probes KMS took (testOverlay):
153+ // enough for a client cycling through four buffers.
154+ overlayPassed [4]overlayProbe
155+ overlayNext int
150 // lastFrame is when the last frame was committed. cursorHeld: a cursor156 // lastFrame is when the last frame was committed. cursorHeld: a cursor
151 // move waits for the next frame (see cursorWaits).157 // move waits for the next frame (see cursorWaits).
152 lastFrame time.Time158 lastFrame time.Time
@@ -437,6 +443,8 @@ func (o *Output) modesetImage(fb uint32, active bool) error {
437 for _, fb := range o.clientFBs {443 for _, fb := range o.clientFBs {
438 fb.scaleTestedOK, fb.scaleRefused = false, false444 fb.scaleTestedOK, fb.scaleRefused = false, false
439 }445 }
446+ o.front.forget()
447+ o.forgetOverlayProbes()
440 o.forgetColor()448 o.forgetColor()
441 o.forgetColorVerdicts()449 o.forgetColorVerdicts()
442 o.shown, o.queued = 0, 0450 o.shown, o.queued = 0, 0
M internal/adapters/drm/overlay.go +43 -8
@@ -3,6 +3,7 @@ package drm
33 import (
44 "errors"
55 "os"
6+ "slices"
67
78 "github.com/bnema/neferwl/internal/ports"
89 "golang.org/x/sys/unix"
@@ -231,13 +232,25 @@ func (ov overlayWin) close() {
231232 // buffer; one that the plane's colour pipeline causes (the same frame passes
232233 // with Bypass) is cached on the plane for the buffer format and the cursor
233234 // state instead.
235+//
236+// A frame identical to one KMS took (same images, place, colour mode and
237+// cursor; the fence does not count) is not probed again: a refusal of the
238+// commit itself still drops the overlay (overlayConflict).
234239 func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
235- err := o.probeOverlay(fb, ov, ov.color.mode)
240+ // One cursor snapshot: the input goroutine may move it meanwhile, and
241+ // what is remembered must be what KMS was asked.
242+ key := o.overlayProbeOf(fb, ov)
243+ if slices.Contains(o.overlayPassed[:], key) {
244+ return true
245+ }
246+ err := o.probeOverlay(fb, ov, ov.color.mode, key.cursor)
236247 if err == nil {
248+ o.overlayPassed[o.overlayNext] = key
249+ o.overlayNext = (o.overlayNext + 1) % len(o.overlayPassed)
237250 return true
238251 }
239- if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass) == nil {
240- o.overlay.setVerdict(ov.color.format, o.cursorShown(), false)
252+ if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass, key.cursor) == nil {
253+ o.overlay.setVerdict(ov.color.format, key.cursor.on, false)
241254 o.setOverlayReason("color_refused")
242255 o.renderLog.Info().Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused")
243256 return false
@@ -250,11 +263,33 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
250263 return false
251264 }
252265
266+// forgetOverlayProbes drops the remembered probes: KMS is asked again.
267+func (o *Output) forgetOverlayProbes() { clear(o.overlayPassed[:]) }
268+
269+// overlayProbe is what an overlay probe depends on, fences aside.
270+type overlayProbe struct {
271+ primary, overlay uint32
272+ rect ports.Rect
273+ w, h int
274+ color colorUse
275+ cursor cursorState
276+ mult uint64
277+ ctm uint32
278+}
279+
280+func (o *Output) overlayProbeOf(fb uint32, ov overlayWin) overlayProbe {
281+ p := overlayProbe{primary: fb, overlay: ov.fb, rect: ov.rect, w: ov.w, h: ov.h, color: ov.color, mult: o.colorMult, ctm: o.ctmBlob}
282+ if o.cursor != nil && !o.protected {
283+ p.cursor = o.cursor.desired()
284+ }
285+ return p
286+}
287+
253288 // probeOverlay is a TEST_ONLY of the frame: the composed image on the
254-// primary plane (Bypass), ov on the overlay with colour mode, the cursor.
255-// Both planes' colour state is written whatever they applied: the test must
256-// not rely on the state of the last commit.
257-func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error {
289+// primary plane (Bypass), ov on the overlay with colour mode, the cursor in
290+// state cur. Both planes' colour state is written whatever they applied:
291+// the test must not rely on the state of the last commit.
292+func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode, cur cursorState) error {
258293 req := &o.probeReq // no frame request is alive: the frame commit follows
259294 req.reset()
260295 o.primaryProps(req, fb)
@@ -262,7 +297,7 @@ func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error {
262297 o.overlayProps(req, ov)
263298 o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob)
264299 if o.cursor != nil {
265- o.cursor.props(req, o.crtc, o.cursor.desired())
300+ o.cursor.props(req, o.crtc, cur)
266301 }
267302 return o.k.commit(req, atomicTestOnly, 0)
268303 }
@@ -3,6 +3,7 @@ package drm
3 import (3 import (
4 "errors"4 "errors"
5 "os"5 "os"
6+ "slices"
6 7
7 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
8 "golang.org/x/sys/unix"9 "golang.org/x/sys/unix"
@@ -231,13 +232,25 @@ func (ov overlayWin) close() {
231 // buffer; one that the plane's colour pipeline causes (the same frame passes232 // buffer; one that the plane's colour pipeline causes (the same frame passes
232 // with Bypass) is cached on the plane for the buffer format and the cursor233 // with Bypass) is cached on the plane for the buffer format and the cursor
233 // state instead.234 // state instead.
235+//
236+// A frame identical to one KMS took (same images, place, colour mode and
237+// cursor; the fence does not count) is not probed again: a refusal of the
238+// commit itself still drops the overlay (overlayConflict).
234 func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {239 func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
235- err := o.probeOverlay(fb, ov, ov.color.mode)240+ // One cursor snapshot: the input goroutine may move it meanwhile, and
241+ // what is remembered must be what KMS was asked.
242+ key := o.overlayProbeOf(fb, ov)
243+ if slices.Contains(o.overlayPassed[:], key) {
244+ return true
245+ }
246+ err := o.probeOverlay(fb, ov, ov.color.mode, key.cursor)
236 if err == nil {247 if err == nil {
248+ o.overlayPassed[o.overlayNext] = key
249+ o.overlayNext = (o.overlayNext + 1) % len(o.overlayPassed)
237 return true250 return true
238 }251 }
239- if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass) == nil {252+ if ov.color.mode != colorBypass && refused(err) && o.probeOverlay(fb, ov, colorBypass, key.cursor) == nil {
240- o.overlay.setVerdict(ov.color.format, o.cursorShown(), false)253+ o.overlay.setVerdict(ov.color.format, key.cursor.on, false)
241 o.setOverlayReason("color_refused")254 o.setOverlayReason("color_refused")
242 o.renderLog.Info().Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused")255 o.renderLog.Info().Err(err).Uint32("format", ov.color.format).Str("connector", o.conn.name).Msg("overlay colour pipeline refused")
243 return false256 return false
@@ -250,11 +263,33 @@ func (o *Output) testOverlay(fb uint32, ov overlayWin) bool {
250 return false263 return false
251 }264 }
252 265
266+// forgetOverlayProbes drops the remembered probes: KMS is asked again.
267+func (o *Output) forgetOverlayProbes() { clear(o.overlayPassed[:]) }
268+
269+// overlayProbe is what an overlay probe depends on, fences aside.
270+type overlayProbe struct {
271+ primary, overlay uint32
272+ rect ports.Rect
273+ w, h int
274+ color colorUse
275+ cursor cursorState
276+ mult uint64
277+ ctm uint32
278+}
279+
280+func (o *Output) overlayProbeOf(fb uint32, ov overlayWin) overlayProbe {
281+ p := overlayProbe{primary: fb, overlay: ov.fb, rect: ov.rect, w: ov.w, h: ov.h, color: ov.color, mult: o.colorMult, ctm: o.ctmBlob}
282+ if o.cursor != nil && !o.protected {
283+ p.cursor = o.cursor.desired()
284+ }
285+ return p
286+}
287+
253 // probeOverlay is a TEST_ONLY of the frame: the composed image on the288 // probeOverlay is a TEST_ONLY of the frame: the composed image on the
254-// primary plane (Bypass), ov on the overlay with colour mode, the cursor.289+// primary plane (Bypass), ov on the overlay with colour mode, the cursor in
255-// Both planes' colour state is written whatever they applied: the test must290+// state cur. Both planes' colour state is written whatever they applied:
256-// not rely on the state of the last commit.291+// the test must not rely on the state of the last commit.
257-func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error {292+func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode, cur cursorState) error {
258 req := &o.probeReq // no frame request is alive: the frame commit follows293 req := &o.probeReq // no frame request is alive: the frame commit follows
259 req.reset()294 req.reset()
260 o.primaryProps(req, fb)295 o.primaryProps(req, fb)
@@ -262,7 +297,7 @@ func (o *Output) probeOverlay(fb uint32, ov overlayWin, mode colorMode) error {
262 o.overlayProps(req, ov)297 o.overlayProps(req, ov)
263 o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob)298 o.overlay.colorProps(req, mode, o.colorMult, o.ctmBlob)
264 if o.cursor != nil {299 if o.cursor != nil {
265- o.cursor.props(req, o.crtc, o.cursor.desired())300+ o.cursor.props(req, o.crtc, cur)
266 }301 }
267 return o.k.commit(req, atomicTestOnly, 0)302 return o.k.commit(req, atomicTestOnly, 0)
268 }303 }
M internal/adapters/drm/protection.go +1 -0
@@ -203,6 +203,7 @@ func (o *Output) protectedModeset(ctx context.Context, r ports.Renderer) error {
203203 if err := o.modesetImage(o.fbs[o.back], true); err != nil {
204204 return err
205205 }
206+ o.front.forget()
206207 o.back = 1 - o.back
207208 o.protectSucceeded()
208209 return nil
@@ -203,6 +203,7 @@ func (o *Output) protectedModeset(ctx context.Context, r ports.Renderer) error {
203 if err := o.modesetImage(o.fbs[o.back], true); err != nil {203 if err := o.modesetImage(o.fbs[o.back], true); err != nil {
204 return err204 return err
205 }205 }
206+ o.front.forget()
206 o.back = 1 - o.back207 o.back = 1 - o.back
207 o.protectSucceeded()208 o.protectSucceeded()
208 return nil209 return nil
M internal/adapters/drm/scanout.go +2 -0
@@ -587,6 +587,8 @@ func (o *Output) dropClientFBs(now time.Time, all bool) {
587587 }
588588 if fb.fbID != 0 {
589589 _ = o.k.rmFB(fb.fbID)
590+ // KMS may give the id to another buffer.
591+ o.forgetOverlayProbes()
590592 }
591593 delete(o.clientFBs, id)
592594 }
@@ -587,6 +587,8 @@ func (o *Output) dropClientFBs(now time.Time, all bool) {
587 }587 }
588 if fb.fbID != 0 {588 if fb.fbID != 0 {
589 _ = o.k.rmFB(fb.fbID)589 _ = o.k.rmFB(fb.fbID)
590+ // KMS may give the id to another buffer.
591+ o.forgetOverlayProbes()
590 }592 }
591 delete(o.clientFBs, id)593 delete(o.clientFBs, id)
592 }594 }
M internal/adapters/drm/security.go +1 -0
@@ -145,6 +145,7 @@ func (o *Output) submitProtectedFrame(ctx context.Context, r ports.Renderer, sce
145145 done.Close()
146146 }
147147 if err == nil {
148+ o.front.forget()
148149 o.back = 1 - o.back
149150 }
150151 return err
@@ -145,6 +145,7 @@ func (o *Output) submitProtectedFrame(ctx context.Context, r ports.Renderer, sce
145 done.Close()145 done.Close()
146 }146 }
147 if err == nil {147 if err == nil {
148+ o.front.forget()
148 o.back = 1 - o.back149 o.back = 1 - o.back
149 }150 }
150 return err151 return err

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 26

Tags 10

↑↓ moveenter openesc close