detached cdcafdb

fix(drm): keep VRR across lost master and short compositions, remember refused async flips

commit
cdcafdbe3cbc6e6df0779a68e1297c7cea7a7fae
author
brice · · 2026-09-25 18:01 UTC
parent
b633319
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +65 -10
M examples/config +4 -0
@@ -63,6 +63,10 @@ layout.presets = 1/3, 1/2, 2/3, 1
6363 # output.DP-2.scale = 4/3
6464
6565 render.direct-scanout = on
66+# Games asking for tearing flip without waiting for vblank, and variable
67+# refresh runs; both only while a fullscreen window is scanned out directly.
68+render.tearing = on
69+render.vrr = on
6670
6771 # log.level: debug, info, warn or error. log.debug: components or all.
6872 log.level = info
@@ -63,6 +63,10 @@ layout.presets = 1/3, 1/2, 2/3, 1
63 # output.DP-2.scale = 4/363 # output.DP-2.scale = 4/3
64 64
65 render.direct-scanout = on65 render.direct-scanout = on
66+# Games asking for tearing flip without waiting for vblank, and variable
67+# refresh runs; both only while a fullscreen window is scanned out directly.
68+render.tearing = on
69+render.vrr = on
66 70
67 # log.level: debug, info, warn or error. log.debug: components or all.71 # log.level: debug, info, warn or error. log.debug: components or all.
68 log.level = info72 log.level = info
M internal/adapters/drm/output.go +33 -8
@@ -50,6 +50,8 @@ type Output struct {
5050 tearing bool
5151 vrrProp uint32
5252 vrrOn, async bool
53+ // composedSince is when composition took over from scanout.
54+ composedSince time.Time
5355 // kind is how the images were made; validated once a modeset took them.
5456 kind imageKind
5557 validated bool
@@ -115,7 +117,13 @@ func (o *Output) present(r ports.Renderer) error {
115117 if b := o.bufs[o.back]; b != nil {
116118 r.CopyBGRX(b.mem, int(b.pitch))
117119 }
118- o.setVRR(false)
120+ // VRR goes off only after composing for vrrHold: each toggle is a
121+ // blocking commit and may flicker, so short compositions keep it.
122+ if o.composedSince.IsZero() {
123+ o.composedSince = time.Now()
124+ } else if time.Since(o.composedSince) > vrrHold {
125+ o.setVRR(false)
126+ }
119127 o.setAsync(false)
120128 if err := flip(o.fd, o.crtc, o.fbs[o.back], false); err != nil {
121129 return err
@@ -149,23 +157,28 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
149157 // Already on screen: nothing to flip.
150158 return true, nil
151159 }
152- o.setVRR(true)
153160 // Drivers refuse async flips that change the format or modifier: the
154161 // flip from the composed image into scanout waits for vblank.
155- async := c.Async && o.tearing && o.shown != 0
156- o.setAsync(async)
162+ cfb := o.clientFBs[c.DMABuf.ID]
163+ async := c.Async && o.tearing && o.shown != 0 && !cfb.noAsync
157164 err := flip(o.fd, o.crtc, fb, async)
158165 if err != nil && async && errors.Is(err, unix.EINVAL) {
159- // Refused anyway (e.g. the client changed buffer layout): flip
160- // at vblank this time.
166+ // Refused (e.g. a layout change, or not a fast update): this
167+ // buffer flips at vblank from now on.
161168 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
169+ cfb.noAsync, async = true, false
162170 err = flip(o.fd, o.crtc, fb, false)
163171 }
172+ if err == nil {
173+ o.setAsync(async)
174+ o.setVRR(true)
175+ o.composedSince = time.Time{}
176+ }
164177 if err != nil {
165178 if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
166179 // KMS refuses this buffer on the plane: compose it instead.
167180 o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
168- o.clientFBs[c.DMABuf.ID].failed = "flip_refused"
181+ cfb.failed = "flip_refused"
169182 o.reason = "flip_refused"
170183 o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
171184 return false, nil
@@ -177,6 +190,9 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
177190 return true, nil
178191 }
179192
193+// vrrHold is how long composition runs before VRR turns off.
194+const vrrHold = 500 * time.Millisecond
195+
180196 // setVRR turns variable refresh on or off, when the output has it.
181197 func (o *Output) setVRR(on bool) {
182198 if o.vrrProp == 0 || on == o.vrrOn {
@@ -186,13 +202,18 @@ func (o *Output) setVRR(on bool) {
186202 if on {
187203 v = 1
188204 }
205+ // SETPROPERTY is a blocking atomic commit (about one refresh).
206+ start := time.Now()
189207 if err := setProp(o.fd, o.crtc, objCrtc, o.vrrProp, v); err != nil {
208+ if lostMaster(err) {
209+ return // switched away: retried on the next change
210+ }
190211 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr; disabled")
191212 o.vrrProp = 0
192213 return
193214 }
194215 o.vrrOn = on
195- o.log.Info().Bool("vrr", on).Str("connector", o.conn.name).Msg("vrr")
216+ o.log.Info().Bool("vrr", on).Str("connector", o.conn.name).Dur("took", time.Since(start)).Msg("vrr")
196217 }
197218
198219 // setAsync logs when the output starts or stops tearing.
@@ -344,6 +365,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
344365 dirty = dirty || scene.Shows(c.ID)
345366 case <-stats.C:
346367 o.dropClientFBs(time.Now(), false)
368+ // A composed screen that stopped changing still drops VRR.
369+ if !o.composedSince.IsZero() && time.Since(o.composedSince) > vrrHold {
370+ o.setVRR(false)
371+ }
347372 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
348373 if o.cursor != nil {
349374 cs := o.cursor.TakeStats()
@@ -50,6 +50,8 @@ type Output struct {
50 tearing bool50 tearing bool
51 vrrProp uint3251 vrrProp uint32
52 vrrOn, async bool52 vrrOn, async bool
53+ // composedSince is when composition took over from scanout.
54+ composedSince time.Time
53 // kind is how the images were made; validated once a modeset took them.55 // kind is how the images were made; validated once a modeset took them.
54 kind imageKind56 kind imageKind
55 validated bool57 validated bool
@@ -115,7 +117,13 @@ func (o *Output) present(r ports.Renderer) error {
115 if b := o.bufs[o.back]; b != nil {117 if b := o.bufs[o.back]; b != nil {
116 r.CopyBGRX(b.mem, int(b.pitch))118 r.CopyBGRX(b.mem, int(b.pitch))
117 }119 }
118- o.setVRR(false)120+ // VRR goes off only after composing for vrrHold: each toggle is a
121+ // blocking commit and may flicker, so short compositions keep it.
122+ if o.composedSince.IsZero() {
123+ o.composedSince = time.Now()
124+ } else if time.Since(o.composedSince) > vrrHold {
125+ o.setVRR(false)
126+ }
119 o.setAsync(false)127 o.setAsync(false)
120 if err := flip(o.fd, o.crtc, o.fbs[o.back], false); err != nil {128 if err := flip(o.fd, o.crtc, o.fbs[o.back], false); err != nil {
121 return err129 return err
@@ -149,23 +157,28 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
149 // Already on screen: nothing to flip.157 // Already on screen: nothing to flip.
150 return true, nil158 return true, nil
151 }159 }
152- o.setVRR(true)
153 // Drivers refuse async flips that change the format or modifier: the160 // Drivers refuse async flips that change the format or modifier: the
154 // flip from the composed image into scanout waits for vblank.161 // flip from the composed image into scanout waits for vblank.
155- async := c.Async && o.tearing && o.shown != 0162+ cfb := o.clientFBs[c.DMABuf.ID]
156- o.setAsync(async)163+ async := c.Async && o.tearing && o.shown != 0 && !cfb.noAsync
157 err := flip(o.fd, o.crtc, fb, async)164 err := flip(o.fd, o.crtc, fb, async)
158 if err != nil && async && errors.Is(err, unix.EINVAL) {165 if err != nil && async && errors.Is(err, unix.EINVAL) {
159- // Refused anyway (e.g. the client changed buffer layout): flip166+ // Refused (e.g. a layout change, or not a fast update): this
160- // at vblank this time.167+ // buffer flips at vblank from now on.
161 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")168 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
169+ cfb.noAsync, async = true, false
162 err = flip(o.fd, o.crtc, fb, false)170 err = flip(o.fd, o.crtc, fb, false)
163 }171 }
172+ if err == nil {
173+ o.setAsync(async)
174+ o.setVRR(true)
175+ o.composedSince = time.Time{}
176+ }
164 if err != nil {177 if err != nil {
165 if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {178 if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
166 // KMS refuses this buffer on the plane: compose it instead.179 // KMS refuses this buffer on the plane: compose it instead.
167 o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")180 o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
168- o.clientFBs[c.DMABuf.ID].failed = "flip_refused"181+ cfb.failed = "flip_refused"
169 o.reason = "flip_refused"182 o.reason = "flip_refused"
170 o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")183 o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
171 return false, nil184 return false, nil
@@ -177,6 +190,9 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
177 return true, nil190 return true, nil
178 }191 }
179 192
193+// vrrHold is how long composition runs before VRR turns off.
194+const vrrHold = 500 * time.Millisecond
195+
180 // setVRR turns variable refresh on or off, when the output has it.196 // setVRR turns variable refresh on or off, when the output has it.
181 func (o *Output) setVRR(on bool) {197 func (o *Output) setVRR(on bool) {
182 if o.vrrProp == 0 || on == o.vrrOn {198 if o.vrrProp == 0 || on == o.vrrOn {
@@ -186,13 +202,18 @@ func (o *Output) setVRR(on bool) {
186 if on {202 if on {
187 v = 1203 v = 1
188 }204 }
205+ // SETPROPERTY is a blocking atomic commit (about one refresh).
206+ start := time.Now()
189 if err := setProp(o.fd, o.crtc, objCrtc, o.vrrProp, v); err != nil {207 if err := setProp(o.fd, o.crtc, objCrtc, o.vrrProp, v); err != nil {
208+ if lostMaster(err) {
209+ return // switched away: retried on the next change
210+ }
190 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr; disabled")211 o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr; disabled")
191 o.vrrProp = 0212 o.vrrProp = 0
192 return213 return
193 }214 }
194 o.vrrOn = on215 o.vrrOn = on
195- o.log.Info().Bool("vrr", on).Str("connector", o.conn.name).Msg("vrr")216+ o.log.Info().Bool("vrr", on).Str("connector", o.conn.name).Dur("took", time.Since(start)).Msg("vrr")
196 }217 }
197 218
198 // setAsync logs when the output starts or stops tearing.219 // setAsync logs when the output starts or stops tearing.
@@ -344,6 +365,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
344 dirty = dirty || scene.Shows(c.ID)365 dirty = dirty || scene.Shows(c.ID)
345 case <-stats.C:366 case <-stats.C:
346 o.dropClientFBs(time.Now(), false)367 o.dropClientFBs(time.Now(), false)
368+ // A composed screen that stopped changing still drops VRR.
369+ if !o.composedSince.IsZero() && time.Since(o.composedSince) > vrrHold {
370+ o.setVRR(false)
371+ }
347 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)372 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
348 if o.cursor != nil {373 if o.cursor != nil {
349 cs := o.cursor.TakeStats()374 cs := o.cursor.TakeStats()
M internal/adapters/drm/scanout.go +2 -0
@@ -90,6 +90,8 @@ type clientFB struct {
9090 last time.Time // when last wanted on screen
9191 // failed is the reason KMS refused the buffer; it is not retried.
9292 failed string
93+ // noAsync: KMS refused an async flip to it; it flips at vblank.
94+ noAsync bool
9395 }
9496
9597 // scanoutFB returns the framebuffer of a client buffer, importing it on
@@ -90,6 +90,8 @@ type clientFB struct {
90 last time.Time // when last wanted on screen90 last time.Time // when last wanted on screen
91 // failed is the reason KMS refused the buffer; it is not retried.91 // failed is the reason KMS refused the buffer; it is not retried.
92 failed string92 failed string
93+ // noAsync: KMS refused an async flip to it; it flips at vblank.
94+ noAsync bool
93 }95 }
94 96
95 // scanoutFB returns the framebuffer of a client buffer, importing it on97 // scanoutFB returns the framebuffer of a client buffer, importing it on
M internal/adapters/wayland/surface.go +1 -0
@@ -140,6 +140,7 @@ func (s *surface) detach() {
140140
141141 func (s *surface) Destroy(*wayland.Surface) {
142142 s.destroyed = true
143+ s.tearing = nil // the control becomes inert
143144 if s.server.cursorSurface == s {
144145 // The pointer keeps no cursor until the client sets another.
145146 s.server.cursorSurface = nil
@@ -140,6 +140,7 @@ func (s *surface) detach() {
140 140
141 func (s *surface) Destroy(*wayland.Surface) {141 func (s *surface) Destroy(*wayland.Surface) {
142 s.destroyed = true142 s.destroyed = true
143+ s.tearing = nil // the control becomes inert
143 if s.server.cursorSurface == s {144 if s.server.cursorSurface == s {
144 // The pointer keeps no cursor until the client sets another.145 // The pointer keeps no cursor until the client sets another.
145 s.server.cursorSurface = nil146 s.server.cursorSurface = nil
M internal/adapters/wayland/tearing_test.go +25 -2
@@ -34,6 +34,17 @@ func TestTearingHintAppliesOnCommit(t *testing.T) {
3434 registerProtocol(t, c, control)
3535 requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, control, surf)
3636 requestProtocol(t, c, control, tearingcontrol.WpTearingControlV1RequestSetPresentationHint, uint32(tearingcontrol.WpTearingControlV1PresentationHintAsync))
37+ if err := c.Roundtrip(); err != nil {
38+ t.Fatal(err)
39+ }
40+ // Double-buffered: nothing changes before the commit.
41+ select {
42+ case got := <-contents:
43+ if got.Async {
44+ t.Fatal("hint applied before commit")
45+ }
46+ case <-time.After(50 * time.Millisecond):
47+ }
3748 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
3849 if err := c.Roundtrip(); err != nil {
3950 t.Fatal(err)
@@ -48,11 +59,23 @@ func TestTearingHintAppliesOnCommit(t *testing.T) {
4859 }
4960 asyncContent(t, contents, w.ID, false)
5061
62+ // A control outliving its surface is inert.
63+ inert := c.AllocateID()
64+ registerProtocol(t, c, inert)
65+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, inert, surf)
66+ requestProtocol(t, c, surf, wayland.SurfaceRequestDestroy)
67+ requestProtocol(t, c, inert, tearingcontrol.WpTearingControlV1RequestSetPresentationHint, uint32(tearingcontrol.WpTearingControlV1PresentationHintAsync))
68+ if err := c.Roundtrip(); err != nil {
69+ t.Fatal(err)
70+ }
71+ unmapped(t, events, w.ID)
72+
5173 // A surface has at most one control.
74+ _, other, _ := surfaceMapper(t, c, events)()
5275 first, second := c.AllocateID(), c.AllocateID()
5376 registerProtocol(t, c, first)
5477 registerProtocol(t, c, second)
55- requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, first, surf)
56- requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, second, surf)
78+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, first, other)
79+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, second, other)
5780 expectProtocolError(t, c, manager, uint32(tearingcontrol.WpTearingControlManagerV1ErrorTearingControlExists))
5881 }
@@ -34,6 +34,17 @@ func TestTearingHintAppliesOnCommit(t *testing.T) {
34 registerProtocol(t, c, control)34 registerProtocol(t, c, control)
35 requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, control, surf)35 requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, control, surf)
36 requestProtocol(t, c, control, tearingcontrol.WpTearingControlV1RequestSetPresentationHint, uint32(tearingcontrol.WpTearingControlV1PresentationHintAsync))36 requestProtocol(t, c, control, tearingcontrol.WpTearingControlV1RequestSetPresentationHint, uint32(tearingcontrol.WpTearingControlV1PresentationHintAsync))
37+ if err := c.Roundtrip(); err != nil {
38+ t.Fatal(err)
39+ }
40+ // Double-buffered: nothing changes before the commit.
41+ select {
42+ case got := <-contents:
43+ if got.Async {
44+ t.Fatal("hint applied before commit")
45+ }
46+ case <-time.After(50 * time.Millisecond):
47+ }
37 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)48 requestProtocol(t, c, surf, wayland.SurfaceRequestCommit)
38 if err := c.Roundtrip(); err != nil {49 if err := c.Roundtrip(); err != nil {
39 t.Fatal(err)50 t.Fatal(err)
@@ -48,11 +59,23 @@ func TestTearingHintAppliesOnCommit(t *testing.T) {
48 }59 }
49 asyncContent(t, contents, w.ID, false)60 asyncContent(t, contents, w.ID, false)
50 61
62+ // A control outliving its surface is inert.
63+ inert := c.AllocateID()
64+ registerProtocol(t, c, inert)
65+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, inert, surf)
66+ requestProtocol(t, c, surf, wayland.SurfaceRequestDestroy)
67+ requestProtocol(t, c, inert, tearingcontrol.WpTearingControlV1RequestSetPresentationHint, uint32(tearingcontrol.WpTearingControlV1PresentationHintAsync))
68+ if err := c.Roundtrip(); err != nil {
69+ t.Fatal(err)
70+ }
71+ unmapped(t, events, w.ID)
72+
51 // A surface has at most one control.73 // A surface has at most one control.
74+ _, other, _ := surfaceMapper(t, c, events)()
52 first, second := c.AllocateID(), c.AllocateID()75 first, second := c.AllocateID(), c.AllocateID()
53 registerProtocol(t, c, first)76 registerProtocol(t, c, first)
54 registerProtocol(t, c, second)77 registerProtocol(t, c, second)
55- requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, first, surf)78+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, first, other)
56- requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, second, surf)79+ requestProtocol(t, c, manager, tearingcontrol.WpTearingControlManagerV1RequestGetTearingControl, second, other)
57 expectProtocolError(t, c, manager, uint32(tearingcontrol.WpTearingControlManagerV1ErrorTearingControlExists))80 expectProtocolError(t, c, manager, uint32(tearingcontrol.WpTearingControlManagerV1ErrorTearingControlExists))
58 }81 }

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 23

Tags 10

↑↓ moveenter openesc close