detached ff79c0c

fix(drm): move the hardware cursor off the input goroutine

The legacy cursor ioctl waits for vblank on amdgpu, so calling it for every
libinput motion capped input reading at the refresh rate: the evdev queue of a
1 kHz mouse overflowed, SYN_DROPPED discarded reports and the pointer crawled
and jumped. Move now records the position and a worker applies the latest one,
coalescing motion and never blocking input.
commit
ff79c0cb82d4165ea3d51f75d594fa57e9c76c28
author
brice · · 2026-09-24 15:49 UTC
parent
b47c444
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +126 -21
M internal/adapters/drm/cursor.go +72 -21
@@ -30,15 +30,26 @@ type modeCursor2 struct {
3030
3131 // Cursor is the hardware cursor of one output. Move is safe to call from the
3232 // input goroutine while the output goroutine changes the image.
33+//
34+// The legacy cursor ioctl can block until the next vblank (amdgpu does), so
35+// Move only records the position and a worker goroutine applies the latest
36+// one: input is never throttled to the refresh rate and motion is coalesced.
3337 type Cursor struct {
34- mu sync.Mutex
38+ io sync.Mutex // serialises cursor ioctls and guards the fields below
3539 fd int
3640 crtc uint32
3741 buf *dumbBuffer
3842 size int // buffer side
3943 hotX, hotY int
40- x, y int // physical position of the hotspot
4144 shown bool
45+ ioctl func(*modeCursor2) error
46+
47+ mu sync.Mutex // guards x, y; never held across an ioctl
48+ x, y int // physical position of the hotspot
49+
50+ wake chan struct{}
51+ stop chan struct{}
52+ done chan struct{}
4253 }
4354
4455 // newCursor allocates a cursor buffer at the driver's preferred size (64 when
@@ -66,7 +77,42 @@ func newCursor(fd int, crtc uint32) (*Cursor, error) {
6677 buf.destroy(fd)
6778 return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
6879 }
69- return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
80+ c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}
81+ c.ioctl = func(v *modeCursor2) error { return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(v)) }
82+ c.start()
83+ return c, nil
84+}
85+
86+// start runs the worker that applies coalesced moves.
87+func (c *Cursor) start() {
88+ c.wake = make(chan struct{}, 1)
89+ c.stop = make(chan struct{})
90+ c.done = make(chan struct{})
91+ go func() {
92+ defer close(c.done)
93+ for {
94+ select {
95+ case <-c.stop:
96+ return
97+ case <-c.wake:
98+ c.io.Lock()
99+ if c.shown && c.buf != nil {
100+ x, y := c.position()
101+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)}
102+ // Errors while another session owns the card (VT switched
103+ // away) are ignored; Reapply restores the cursor.
104+ _ = c.ioctl(&v)
105+ }
106+ c.io.Unlock()
107+ }
108+ }
109+ }()
110+}
111+
112+func (c *Cursor) position() (int, int) {
113+ c.mu.Lock()
114+ defer c.mu.Unlock()
115+ return c.x, c.y
70116 }
71117
72118 // Limit is the largest image side the cursor plane accepts.
@@ -75,8 +121,8 @@ func (c *Cursor) Limit() int { return c.size }
75121 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
76122 // click point at (hotX, hotY). Larger images are cropped to the plane.
77123 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
78- c.mu.Lock()
79- defer c.mu.Unlock()
124+ c.io.Lock()
125+ defer c.io.Unlock()
80126 if c.buf == nil {
81127 return nil
82128 }
@@ -90,45 +136,50 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
90136 return c.apply()
91137 }
92138
93-// apply (re)attaches the buffer at the current position.
139+// apply (re)attaches the buffer at the current position. Callers hold c.io.
94140 func (c *Cursor) apply() error {
95141 if !c.shown || c.buf == nil {
96142 return nil
97143 }
98- v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}
99- return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
144+ x, y := c.position()
145+ v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}
146+ return c.ioctl(&v)
100147 }
101148
102-// Move places the hotspot at physical (x, y). Errors while another session
103-// owns the card (VT switched away) are ignored; Reapply restores the cursor.
149+// Move places the hotspot at physical (x, y). It never blocks on the GPU:
150+// the worker applies the latest position.
104151 func (c *Cursor) Move(x, y float64) {
105152 c.mu.Lock()
106- defer c.mu.Unlock()
107153 c.x, c.y = int(x), int(y)
108- if !c.shown || c.buf == nil {
109- return
154+ c.mu.Unlock()
155+ select {
156+ case c.wake <- struct{}{}:
157+ default: // a move is already queued; it will read this position
110158 }
111- v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
112- _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
113159 }
114160
115161 // Reapply shows the cursor again after a modeset (VT switch back).
116162 func (c *Cursor) Reapply() error {
117- c.mu.Lock()
118- defer c.mu.Unlock()
163+ c.io.Lock()
164+ defer c.io.Unlock()
119165 return c.apply()
120166 }
121167
122-// close hides the cursor and frees its buffer.
168+// close stops the worker, hides the cursor and frees its buffer.
123169 func (c *Cursor) close() {
124- c.mu.Lock()
125- defer c.mu.Unlock()
170+ if c.stop != nil {
171+ close(c.stop)
172+ <-c.done
173+ c.stop = nil
174+ }
175+ c.io.Lock()
176+ defer c.io.Unlock()
126177 if c.buf == nil {
127178 return
128179 }
129180 // Best effort: at exit the card may already belong to another session.
130181 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
131- _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
182+ _ = c.ioctl(&v)
132183 c.buf.destroy(c.fd)
133184 c.buf, c.shown = nil, false
134185 }
@@ -30,15 +30,26 @@ type modeCursor2 struct {
30 30
31 // Cursor is the hardware cursor of one output. Move is safe to call from the31 // Cursor is the hardware cursor of one output. Move is safe to call from the
32 // input goroutine while the output goroutine changes the image.32 // input goroutine while the output goroutine changes the image.
33+//
34+// The legacy cursor ioctl can block until the next vblank (amdgpu does), so
35+// Move only records the position and a worker goroutine applies the latest
36+// one: input is never throttled to the refresh rate and motion is coalesced.
33 type Cursor struct {37 type Cursor struct {
34- mu sync.Mutex38+ io sync.Mutex // serialises cursor ioctls and guards the fields below
35 fd int39 fd int
36 crtc uint3240 crtc uint32
37 buf *dumbBuffer41 buf *dumbBuffer
38 size int // buffer side42 size int // buffer side
39 hotX, hotY int43 hotX, hotY int
40- x, y int // physical position of the hotspot
41 shown bool44 shown bool
45+ ioctl func(*modeCursor2) error
46+
47+ mu sync.Mutex // guards x, y; never held across an ioctl
48+ x, y int // physical position of the hotspot
49+
50+ wake chan struct{}
51+ stop chan struct{}
52+ done chan struct{}
42 }53 }
43 54
44 // newCursor allocates a cursor buffer at the driver's preferred size (64 when55 // newCursor allocates a cursor buffer at the driver's preferred size (64 when
@@ -66,7 +77,42 @@ func newCursor(fd int, crtc uint32) (*Cursor, error) {
66 buf.destroy(fd)77 buf.destroy(fd)
67 return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)78 return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
68 }79 }
69- return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil80+ c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}
81+ c.ioctl = func(v *modeCursor2) error { return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(v)) }
82+ c.start()
83+ return c, nil
84+}
85+
86+// start runs the worker that applies coalesced moves.
87+func (c *Cursor) start() {
88+ c.wake = make(chan struct{}, 1)
89+ c.stop = make(chan struct{})
90+ c.done = make(chan struct{})
91+ go func() {
92+ defer close(c.done)
93+ for {
94+ select {
95+ case <-c.stop:
96+ return
97+ case <-c.wake:
98+ c.io.Lock()
99+ if c.shown && c.buf != nil {
100+ x, y := c.position()
101+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)}
102+ // Errors while another session owns the card (VT switched
103+ // away) are ignored; Reapply restores the cursor.
104+ _ = c.ioctl(&v)
105+ }
106+ c.io.Unlock()
107+ }
108+ }
109+ }()
110+}
111+
112+func (c *Cursor) position() (int, int) {
113+ c.mu.Lock()
114+ defer c.mu.Unlock()
115+ return c.x, c.y
70 }116 }
71 117
72 // Limit is the largest image side the cursor plane accepts.118 // Limit is the largest image side the cursor plane accepts.
@@ -75,8 +121,8 @@ func (c *Cursor) Limit() int { return c.size }
75 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the121 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
76 // click point at (hotX, hotY). Larger images are cropped to the plane.122 // click point at (hotX, hotY). Larger images are cropped to the plane.
77 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {123 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
78- c.mu.Lock()124+ c.io.Lock()
79- defer c.mu.Unlock()125+ defer c.io.Unlock()
80 if c.buf == nil {126 if c.buf == nil {
81 return nil127 return nil
82 }128 }
@@ -90,45 +136,50 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
90 return c.apply()136 return c.apply()
91 }137 }
92 138
93-// apply (re)attaches the buffer at the current position.139+// apply (re)attaches the buffer at the current position. Callers hold c.io.
94 func (c *Cursor) apply() error {140 func (c *Cursor) apply() error {
95 if !c.shown || c.buf == nil {141 if !c.shown || c.buf == nil {
96 return nil142 return nil
97 }143 }
98- v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}144+ x, y := c.position()
99- return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))145+ v := modeCursor2{flags: cursorBO | cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY), width: uint32(c.size), height: uint32(c.size), handle: c.buf.handle, hotX: int32(c.hotX), hotY: int32(c.hotY)}
146+ return c.ioctl(&v)
100 }147 }
101 148
102-// Move places the hotspot at physical (x, y). Errors while another session149+// Move places the hotspot at physical (x, y). It never blocks on the GPU:
103-// owns the card (VT switched away) are ignored; Reapply restores the cursor.150+// the worker applies the latest position.
104 func (c *Cursor) Move(x, y float64) {151 func (c *Cursor) Move(x, y float64) {
105 c.mu.Lock()152 c.mu.Lock()
106- defer c.mu.Unlock()
107 c.x, c.y = int(x), int(y)153 c.x, c.y = int(x), int(y)
108- if !c.shown || c.buf == nil {154+ c.mu.Unlock()
109- return155+ select {
156+ case c.wake <- struct{}{}:
157+ default: // a move is already queued; it will read this position
110 }158 }
111- v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(c.x - c.hotX), y: int32(c.y - c.hotY)}
112- _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
113 }159 }
114 160
115 // Reapply shows the cursor again after a modeset (VT switch back).161 // Reapply shows the cursor again after a modeset (VT switch back).
116 func (c *Cursor) Reapply() error {162 func (c *Cursor) Reapply() error {
117- c.mu.Lock()163+ c.io.Lock()
118- defer c.mu.Unlock()164+ defer c.io.Unlock()
119 return c.apply()165 return c.apply()
120 }166 }
121 167
122-// close hides the cursor and frees its buffer.168+// close stops the worker, hides the cursor and frees its buffer.
123 func (c *Cursor) close() {169 func (c *Cursor) close() {
124- c.mu.Lock()170+ if c.stop != nil {
125- defer c.mu.Unlock()171+ close(c.stop)
172+ <-c.done
173+ c.stop = nil
174+ }
175+ c.io.Lock()
176+ defer c.io.Unlock()
126 if c.buf == nil {177 if c.buf == nil {
127 return178 return
128 }179 }
129 // Best effort: at exit the card may already belong to another session.180 // Best effort: at exit the card may already belong to another session.
130 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}181 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
131- _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))182+ _ = c.ioctl(&v)
132 c.buf.destroy(c.fd)183 c.buf.destroy(c.fd)
133 c.buf, c.shown = nil, false184 c.buf, c.shown = nil, false
134 }185 }
M internal/adapters/drm/cursor_test.go +54 -0
@@ -1,7 +1,9 @@
11 package drm
22
33 import (
4+ "sync"
45 "testing"
6+ "time"
57 "unsafe"
68 )
79
@@ -14,3 +16,55 @@ func TestCursorABI(t *testing.T) {
1416 t.Fatalf("drm_get_cap size %d", s)
1517 }
1618 }
19+
20+// The legacy cursor ioctl may wait for vblank; Move must not, or libinput
21+// stops reading and the kernel drops mouse reports (slow, erratic pointer).
22+func TestCursorMoveDoesNotBlockOnIoctl(t *testing.T) {
23+ var mu sync.Mutex
24+ var calls int
25+ var last modeCursor2
26+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true, hotX: 3, hotY: 4}
27+ c.ioctl = func(v *modeCursor2) error {
28+ time.Sleep(6 * time.Millisecond) // one 165 Hz vblank
29+ mu.Lock()
30+ defer mu.Unlock()
31+ calls++
32+ last = *v
33+ return nil
34+ }
35+ c.start()
36+ defer c.close()
37+ start := time.Now()
38+ for i := range 1000 { // one second of a 1 kHz mouse
39+ c.Move(float64(i), float64(2*i))
40+ }
41+ if d := time.Since(start); d > 500*time.Millisecond {
42+ t.Fatalf("1000 moves took %v; Move blocks on the ioctl", d)
43+ }
44+ deadline := time.Now().Add(2 * time.Second)
45+ for {
46+ mu.Lock()
47+ got, n := last, calls
48+ mu.Unlock()
49+ if got.x == 999-3 && got.y == 1998-4 {
50+ if got.flags != cursorMove || n >= 1000 {
51+ t.Fatalf("flags %#x after %d ioctls; want coalesced moves", got.flags, n)
52+ }
53+ return
54+ }
55+ if time.Now().After(deadline) {
56+ t.Fatalf("last applied %+v after %d ioctls; want the final position", got, n)
57+ }
58+ time.Sleep(time.Millisecond)
59+ }
60+}
61+
62+func TestCursorCloseStopsWorker(t *testing.T) {
63+ c := &Cursor{buf: &dumbBuffer{}, shown: true, fd: -1}
64+ c.ioctl = func(*modeCursor2) error { return nil }
65+ c.start()
66+ c.Move(1, 2)
67+ c.close()
68+ c.close() // idempotent
69+ c.Move(3, 4) // after close: no worker, must not block or panic
70+}
@@ -1,7 +1,9 @@
1 package drm1 package drm
2 2
3 import (3 import (
4+ "sync"
4 "testing"5 "testing"
6+ "time"
5 "unsafe"7 "unsafe"
6 )8 )
7 9
@@ -14,3 +16,55 @@ func TestCursorABI(t *testing.T) {
14 t.Fatalf("drm_get_cap size %d", s)16 t.Fatalf("drm_get_cap size %d", s)
15 }17 }
16 }18 }
19+
20+// The legacy cursor ioctl may wait for vblank; Move must not, or libinput
21+// stops reading and the kernel drops mouse reports (slow, erratic pointer).
22+func TestCursorMoveDoesNotBlockOnIoctl(t *testing.T) {
23+ var mu sync.Mutex
24+ var calls int
25+ var last modeCursor2
26+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true, hotX: 3, hotY: 4}
27+ c.ioctl = func(v *modeCursor2) error {
28+ time.Sleep(6 * time.Millisecond) // one 165 Hz vblank
29+ mu.Lock()
30+ defer mu.Unlock()
31+ calls++
32+ last = *v
33+ return nil
34+ }
35+ c.start()
36+ defer c.close()
37+ start := time.Now()
38+ for i := range 1000 { // one second of a 1 kHz mouse
39+ c.Move(float64(i), float64(2*i))
40+ }
41+ if d := time.Since(start); d > 500*time.Millisecond {
42+ t.Fatalf("1000 moves took %v; Move blocks on the ioctl", d)
43+ }
44+ deadline := time.Now().Add(2 * time.Second)
45+ for {
46+ mu.Lock()
47+ got, n := last, calls
48+ mu.Unlock()
49+ if got.x == 999-3 && got.y == 1998-4 {
50+ if got.flags != cursorMove || n >= 1000 {
51+ t.Fatalf("flags %#x after %d ioctls; want coalesced moves", got.flags, n)
52+ }
53+ return
54+ }
55+ if time.Now().After(deadline) {
56+ t.Fatalf("last applied %+v after %d ioctls; want the final position", got, n)
57+ }
58+ time.Sleep(time.Millisecond)
59+ }
60+}
61+
62+func TestCursorCloseStopsWorker(t *testing.T) {
63+ c := &Cursor{buf: &dumbBuffer{}, shown: true, fd: -1}
64+ c.ioctl = func(*modeCursor2) error { return nil }
65+ c.start()
66+ c.Move(1, 2)
67+ c.close()
68+ c.close() // idempotent
69+ c.Move(3, 4) // after close: no worker, must not block or panic
70+}

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