detached 4775988

fix(drm): move the hardware cursor once per vblank to stop tearing

commit
47759887f5a0549f0b6bada488c8ddab51a05abd
author
brice · · 2026-09-24 16:14 UTC
parent
1a34c2a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +64 -5
M internal/adapters/drm/cursor.go +30 -4
@@ -13,6 +13,10 @@ import (
1313 const (
1414 ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
1515 ioctlCursor2 = 0xC02464BB // DRM_IOWR('d', 0xBB, struct drm_mode_cursor2)
16+ ioctlVblank = 0xC018643A // DRM_IOWR('d', 0x3A, union drm_wait_vblank)
17+ vblankRel = 0x1 // _DRM_VBLANK_RELATIVE
18+ vblankCrtcSh = 1 // _DRM_VBLANK_HIGH_CRTC_SHIFT
19+ vblankCrtcMk = 0x3e // _DRM_VBLANK_HIGH_CRTC_MASK
1620 capCursorW = 0x8
1721 capCursorH = 0x9
1822 cursorBO = 0x01
@@ -21,6 +25,20 @@ const (
2125
2226 type getCap struct{ capability, value uint64 }
2327
28+// waitVblank is union drm_wait_vblank (request and reply share 24 bytes).
29+type waitVblank struct {
30+ typ, sequence uint32
31+ sec, usec int64
32+}
33+
34+// vblankWaiter blocks until the next vblank of the CRTC at index pipe.
35+func vblankWaiter(fd, pipe int) func() error {
36+ return func() error {
37+ v := waitVblank{typ: vblankRel | uint32(pipe<<vblankCrtcSh)&vblankCrtcMk, sequence: 1}
38+ return ioctl(fd, ioctlVblank, unsafe.Pointer(&v))
39+ }
40+}
41+
2442 type modeCursor2 struct {
2543 flags, crtcID uint32
2644 x, y int32
@@ -32,9 +50,10 @@ type modeCursor2 struct {
3250 // Cursor is the hardware cursor of one output. Move is safe to call from the
3351 // input goroutine while the output goroutine changes the image.
3452 //
35-// The legacy cursor ioctl can block until the next vblank (amdgpu does), so
36-// Move only records the position and a worker goroutine applies the latest
37-// one: input is never throttled to the refresh rate and motion is coalesced.
53+// Move only records the position. A worker waits for the next vblank and then
54+// applies the latest one, so the cursor moves at most once per frame, between
55+// scanouts: moving it mid-scanout tears it into doubled images, and input is
56+// never throttled by the ioctl.
3857 type Cursor struct {
3958 io sync.Mutex // serialises cursor ioctls and guards the fields below
4059 fd int
@@ -44,6 +63,7 @@ type Cursor struct {
4463 hotX, hotY int
4564 shown bool
4665 ioctl func(*modeCursor2) error
66+ vblank func() error // waits for the next vblank; nil moves at once
4767
4868 mu sync.Mutex // guards x, y and stats; never held across an ioctl
4969 x, y int // physical position of the hotspot
@@ -72,7 +92,7 @@ func (c *Cursor) TakeStats() CursorStats {
7292
7393 // newCursor allocates a cursor buffer at the driver's preferred size (64 when
7494 // the driver does not say).
75-func newCursor(fd int, crtc uint32) (*Cursor, error) {
95+func newCursor(fd int, crtc uint32, pipe int) (*Cursor, error) {
7696 // The plane is square: the smaller of the width and height caps.
7797 size := 0
7898 for _, c := range []uint64{capCursorW, capCursorH} {
@@ -97,6 +117,7 @@ func newCursor(fd int, crtc uint32) (*Cursor, error) {
97117 }
98118 c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}
99119 c.ioctl = func(v *modeCursor2) error { return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(v)) }
120+ c.vblank = vblankWaiter(fd, pipe)
100121 c.start()
101122 return c, nil
102123 }
@@ -113,6 +134,11 @@ func (c *Cursor) start() {
113134 case <-c.stop:
114135 return
115136 case <-c.wake:
137+ // Errors (VT switched away) fall through: the move
138+ // fails too and Reapply restores the cursor.
139+ if c.vblank != nil {
140+ _ = c.vblank()
141+ }
116142 c.io.Lock()
117143 if c.shown && c.buf != nil {
118144 x, y := c.position()
@@ -13,6 +13,10 @@ import (
13 const (13 const (
14 ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)14 ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
15 ioctlCursor2 = 0xC02464BB // DRM_IOWR('d', 0xBB, struct drm_mode_cursor2)15 ioctlCursor2 = 0xC02464BB // DRM_IOWR('d', 0xBB, struct drm_mode_cursor2)
16+ ioctlVblank = 0xC018643A // DRM_IOWR('d', 0x3A, union drm_wait_vblank)
17+ vblankRel = 0x1 // _DRM_VBLANK_RELATIVE
18+ vblankCrtcSh = 1 // _DRM_VBLANK_HIGH_CRTC_SHIFT
19+ vblankCrtcMk = 0x3e // _DRM_VBLANK_HIGH_CRTC_MASK
16 capCursorW = 0x820 capCursorW = 0x8
17 capCursorH = 0x921 capCursorH = 0x9
18 cursorBO = 0x0122 cursorBO = 0x01
@@ -21,6 +25,20 @@ const (
21 25
22 type getCap struct{ capability, value uint64 }26 type getCap struct{ capability, value uint64 }
23 27
28+// waitVblank is union drm_wait_vblank (request and reply share 24 bytes).
29+type waitVblank struct {
30+ typ, sequence uint32
31+ sec, usec int64
32+}
33+
34+// vblankWaiter blocks until the next vblank of the CRTC at index pipe.
35+func vblankWaiter(fd, pipe int) func() error {
36+ return func() error {
37+ v := waitVblank{typ: vblankRel | uint32(pipe<<vblankCrtcSh)&vblankCrtcMk, sequence: 1}
38+ return ioctl(fd, ioctlVblank, unsafe.Pointer(&v))
39+ }
40+}
41+
24 type modeCursor2 struct {42 type modeCursor2 struct {
25 flags, crtcID uint3243 flags, crtcID uint32
26 x, y int3244 x, y int32
@@ -32,9 +50,10 @@ type modeCursor2 struct {
32 // Cursor is the hardware cursor of one output. Move is safe to call from the50 // Cursor is the hardware cursor of one output. Move is safe to call from the
33 // input goroutine while the output goroutine changes the image.51 // input goroutine while the output goroutine changes the image.
34 //52 //
35-// The legacy cursor ioctl can block until the next vblank (amdgpu does), so53+// Move only records the position. A worker waits for the next vblank and then
36-// Move only records the position and a worker goroutine applies the latest54+// applies the latest one, so the cursor moves at most once per frame, between
37-// one: input is never throttled to the refresh rate and motion is coalesced.55+// scanouts: moving it mid-scanout tears it into doubled images, and input is
56+// never throttled by the ioctl.
38 type Cursor struct {57 type Cursor struct {
39 io sync.Mutex // serialises cursor ioctls and guards the fields below58 io sync.Mutex // serialises cursor ioctls and guards the fields below
40 fd int59 fd int
@@ -44,6 +63,7 @@ type Cursor struct {
44 hotX, hotY int63 hotX, hotY int
45 shown bool64 shown bool
46 ioctl func(*modeCursor2) error65 ioctl func(*modeCursor2) error
66+ vblank func() error // waits for the next vblank; nil moves at once
47 67
48 mu sync.Mutex // guards x, y and stats; never held across an ioctl68 mu sync.Mutex // guards x, y and stats; never held across an ioctl
49 x, y int // physical position of the hotspot69 x, y int // physical position of the hotspot
@@ -72,7 +92,7 @@ func (c *Cursor) TakeStats() CursorStats {
72 92
73 // newCursor allocates a cursor buffer at the driver's preferred size (64 when93 // newCursor allocates a cursor buffer at the driver's preferred size (64 when
74 // the driver does not say).94 // the driver does not say).
75-func newCursor(fd int, crtc uint32) (*Cursor, error) {95+func newCursor(fd int, crtc uint32, pipe int) (*Cursor, error) {
76 // The plane is square: the smaller of the width and height caps.96 // The plane is square: the smaller of the width and height caps.
77 size := 097 size := 0
78 for _, c := range []uint64{capCursorW, capCursorH} {98 for _, c := range []uint64{capCursorW, capCursorH} {
@@ -97,6 +117,7 @@ func newCursor(fd int, crtc uint32) (*Cursor, error) {
97 }117 }
98 c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}118 c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}
99 c.ioctl = func(v *modeCursor2) error { return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(v)) }119 c.ioctl = func(v *modeCursor2) error { return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(v)) }
120+ c.vblank = vblankWaiter(fd, pipe)
100 c.start()121 c.start()
101 return c, nil122 return c, nil
102 }123 }
@@ -113,6 +134,11 @@ func (c *Cursor) start() {
113 case <-c.stop:134 case <-c.stop:
114 return135 return
115 case <-c.wake:136 case <-c.wake:
137+ // Errors (VT switched away) fall through: the move
138+ // fails too and Reapply restores the cursor.
139+ if c.vblank != nil {
140+ _ = c.vblank()
141+ }
116 c.io.Lock()142 c.io.Lock()
117 if c.shown && c.buf != nil {143 if c.shown && c.buf != nil {
118 x, y := c.position()144 x, y := c.position()
M internal/adapters/drm/cursor_test.go +32 -0
@@ -15,6 +15,38 @@ func TestCursorABI(t *testing.T) {
1515 if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {
1616 t.Fatalf("drm_get_cap size %d", s)
1717 }
18+ if s := unsafe.Sizeof(waitVblank{}); s != 24 || ioctlVblank>>16&0x3fff != 24 {
19+ t.Fatalf("drm_wait_vblank size %d", s)
20+ }
21+}
22+
23+// The cursor moves once per vblank, to the latest position: moving it during
24+// scanout tears it into doubled images.
25+func TestCursorMovesOncePerVblank(t *testing.T) {
26+ vblank := make(chan struct{})
27+ moved := make(chan modeCursor2, 16)
28+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true}
29+ c.ioctl = func(v *modeCursor2) error { moved <- *v; return nil }
30+ c.vblank = func() error { <-vblank; return nil }
31+ c.start()
32+ defer func() { close(vblank); c.close() }()
33+ for i := range 100 {
34+ c.Move(float64(i), 0)
35+ }
36+ select {
37+ case v := <-moved:
38+ t.Fatalf("moved to %d before vblank", v.x)
39+ case <-time.After(20 * time.Millisecond):
40+ }
41+ vblank <- struct{}{}
42+ if v := <-moved; v.x != 99 {
43+ t.Fatalf("applied x=%d, want latest 99", v.x)
44+ }
45+ select {
46+ case v := <-moved:
47+ t.Fatalf("extra move to %d without new input", v.x)
48+ case <-time.After(20 * time.Millisecond):
49+ }
1850 }
1951
2052 // The legacy cursor ioctl may wait for vblank; Move must not, or libinput
@@ -15,6 +15,38 @@ func TestCursorABI(t *testing.T) {
15 if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {15 if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {
16 t.Fatalf("drm_get_cap size %d", s)16 t.Fatalf("drm_get_cap size %d", s)
17 }17 }
18+ if s := unsafe.Sizeof(waitVblank{}); s != 24 || ioctlVblank>>16&0x3fff != 24 {
19+ t.Fatalf("drm_wait_vblank size %d", s)
20+ }
21+}
22+
23+// The cursor moves once per vblank, to the latest position: moving it during
24+// scanout tears it into doubled images.
25+func TestCursorMovesOncePerVblank(t *testing.T) {
26+ vblank := make(chan struct{})
27+ moved := make(chan modeCursor2, 16)
28+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true}
29+ c.ioctl = func(v *modeCursor2) error { moved <- *v; return nil }
30+ c.vblank = func() error { <-vblank; return nil }
31+ c.start()
32+ defer func() { close(vblank); c.close() }()
33+ for i := range 100 {
34+ c.Move(float64(i), 0)
35+ }
36+ select {
37+ case v := <-moved:
38+ t.Fatalf("moved to %d before vblank", v.x)
39+ case <-time.After(20 * time.Millisecond):
40+ }
41+ vblank <- struct{}{}
42+ if v := <-moved; v.x != 99 {
43+ t.Fatalf("applied x=%d, want latest 99", v.x)
44+ }
45+ select {
46+ case v := <-moved:
47+ t.Fatalf("extra move to %d without new input", v.x)
48+ case <-time.After(20 * time.Millisecond):
49+ }
18 }50 }
19 51
20 // The legacy cursor ioctl may wait for vblank; Move must not, or libinput52 // The legacy cursor ioctl may wait for vblank; Move must not, or libinput
M internal/adapters/drm/output.go +2 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "slices"
78 "sync"
89 "time"
910
@@ -96,7 +97,7 @@ func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error)
9697 return nil, err
9798 }
9899 }
99- if o.cursor, err = newCursor(fd, crtc); err != nil {
100+ if o.cursor, err = newCursor(fd, crtc, slices.Index(crtcs, crtc)); err != nil {
100101 log.Warn().Err(err).Msg("no hardware cursor")
101102 }
102103 log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "slices"
7 "sync"8 "sync"
8 "time"9 "time"
9 10
@@ -96,7 +97,7 @@ func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error)
96 return nil, err97 return nil, err
97 }98 }
98 }99 }
99- if o.cursor, err = newCursor(fd, crtc); err != nil {100+ if o.cursor, err = newCursor(fd, crtc, slices.Index(crtcs, crtc)); err != nil {
100 log.Warn().Err(err).Msg("no hardware cursor")101 log.Warn().Err(err).Msg("no hardware cursor")
101 }102 }
102 log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")103 log.Info().Str("card", card).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Msg("output")

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