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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 30 | 30 | ||
| 31 | 31 | // Cursor is the hardware cursor of one output. Move is safe to call from the | |
| 32 | 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 | 37 | type Cursor struct { | |
| 34 | - | mu sync.Mutex | |
| 38 | + | io sync.Mutex // serialises cursor ioctls and guards the fields below | |
| 35 | 39 | fd int | |
| 36 | 40 | crtc uint32 | |
| 37 | 41 | buf *dumbBuffer | |
| 38 | 42 | size int // buffer side | |
| 39 | 43 | hotX, hotY int | |
| 40 | - | x, y int // physical position of the hotspot | |
| 41 | 44 | 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 | 55 | // 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 | 77 | buf.destroy(fd) | |
| 67 | 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}, 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 | |
| 70 | 116 | } | |
| 71 | 117 | ||
| 72 | 118 | // Limit is the largest image side the cursor plane accepts. | |
| @@ -75,8 +121,8 @@ func (c *Cursor) Limit() int { return c.size } | |||
| 75 | 121 | // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the | |
| 76 | 122 | // click point at (hotX, hotY). Larger images are cropped to the plane. | |
| 77 | 123 | 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() | |
| 80 | 126 | if c.buf == nil { | |
| 81 | 127 | return nil | |
| 82 | 128 | } | |
| @@ -90,45 +136,50 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error { | |||
| 90 | 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 | 140 | func (c *Cursor) apply() error { | |
| 95 | 141 | if !c.shown || c.buf == nil { | |
| 96 | 142 | 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)} | |
| 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) | |
| 100 | 147 | } | |
| 101 | 148 | ||
| 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. | |
| 104 | 151 | func (c *Cursor) Move(x, y float64) { | |
| 105 | 152 | c.mu.Lock() | |
| 106 | - | defer c.mu.Unlock() | |
| 107 | 153 | 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 | |
| 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 | 161 | // Reapply shows the cursor again after a modeset (VT switch back). | |
| 116 | 162 | func (c *Cursor) Reapply() error { | |
| 117 | - | c.mu.Lock() | |
| 118 | - | defer c.mu.Unlock() | |
| 163 | + | c.io.Lock() | |
| 164 | + | defer c.io.Unlock() | |
| 119 | 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 | 169 | 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() | |
| 126 | 177 | if c.buf == nil { | |
| 127 | 178 | return | |
| 128 | 179 | } | |
| 129 | 180 | // Best effort: at exit the card may already belong to another session. | |
| 130 | 181 | v := modeCursor2{flags: cursorBO, crtcID: c.crtc} | |
| 131 | - | _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v)) | |
| 182 | + | _ = c.ioctl(&v) | |
| 132 | 183 | c.buf.destroy(c.fd) | |
| 133 | 184 | c.buf, c.shown = nil, false | |
| 134 | 185 | } | |
| @@ -30,15 +30,26 @@ type modeCursor2 struct { | |||||
| 30 | 30 | ||||
| 31 | // Cursor is the hardware cursor of one output. Move is safe to call from the | 31 | // 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.Mutex | 38 | + | io sync.Mutex // serialises cursor ioctls and guards the fields below |
| 35 | fd int | 39 | fd int | ||
| 36 | crtc uint32 | 40 | crtc uint32 | ||
| 37 | buf *dumbBuffer | 41 | buf *dumbBuffer | ||
| 38 | size int // buffer side | 42 | size int // buffer side | ||
| 39 | hotX, hotY int | 43 | hotX, hotY int | ||
| 40 | - | x, y int // physical position of the hotspot | |||
| 41 | shown bool | 44 | 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 when | 55 | // 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}, 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 | |||
| 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 the | 121 | // 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 nil | 127 | 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 nil | 142 | 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 session | 149 | + | // 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 | - | return | 155 | + | 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 | return | 178 | 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, false | 184 | c.buf, c.shown = nil, false | ||
| 134 | } | 185 | } | ||
M internal/adapters/drm/cursor_test.go +54 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package drm | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 4 | 5 | "testing" | |
| 6 | + | "time" | |
| 5 | 7 | "unsafe" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| @@ -14,3 +16,55 @@ func TestCursorABI(t *testing.T) { | |||
| 14 | 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 | + | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package drm | 1 | 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 | + | } | |||