detached f54b988

merge: cursor-perf (vblank-synced hardware cursor, non-blocking input)

commit
f54b988f22ac9a602a69e85c2ee1310a4a42f2e6
author
brice · · 2026-09-24 16:16 UTC
merge of
b47c444 + 4775988 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +520 -34
M internal/adapters/drm/cursor.go +124 -22
@@ -3,6 +3,7 @@ package drm
33 import (
44 "fmt"
55 "sync"
6+ "time"
67 "unsafe"
78 )
89
@@ -12,6 +13,10 @@ import (
1213 const (
1314 ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
1415 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
1520 capCursorW = 0x8
1621 capCursorH = 0x9
1722 cursorBO = 0x01
@@ -20,6 +25,20 @@ const (
2025
2126 type getCap struct{ capability, value uint64 }
2227
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+
2342 type modeCursor2 struct {
2443 flags, crtcID uint32
2544 x, y int32
@@ -30,20 +49,50 @@ type modeCursor2 struct {
3049
3150 // Cursor is the hardware cursor of one output. Move is safe to call from the
3251 // input goroutine while the output goroutine changes the image.
52+//
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.
3357 type Cursor struct {
34- mu sync.Mutex
58+ io sync.Mutex // serialises cursor ioctls and guards the fields below
3559 fd int
3660 crtc uint32
3761 buf *dumbBuffer
3862 size int // buffer side
3963 hotX, hotY int
40- x, y int // physical position of the hotspot
4164 shown bool
65+ ioctl func(*modeCursor2) error
66+ vblank func() error // waits for the next vblank; nil moves at once
67+
68+ mu sync.Mutex // guards x, y and stats; never held across an ioctl
69+ x, y int // physical position of the hotspot
70+ stats CursorStats
71+
72+ wake chan struct{}
73+ stop chan struct{}
74+ done chan struct{}
75+}
76+
77+// CursorStats counts cursor work since the last TakeStats.
78+type CursorStats struct {
79+ Moves int // Move calls
80+ Ioctls int // move ioctls issued by the worker
81+ MaxIoctl time.Duration // slowest move ioctl
82+}
83+
84+// TakeStats returns and resets the counters.
85+func (c *Cursor) TakeStats() CursorStats {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+ s := c.stats
89+ c.stats = CursorStats{}
90+ return s
4291 }
4392
4493 // newCursor allocates a cursor buffer at the driver's preferred size (64 when
4594 // the driver does not say).
46-func newCursor(fd int, crtc uint32) (*Cursor, error) {
95+func newCursor(fd int, crtc uint32, pipe int) (*Cursor, error) {
4796 // The plane is square: the smaller of the width and height caps.
4897 size := 0
4998 for _, c := range []uint64{capCursorW, capCursorH} {
@@ -66,7 +115,54 @@ func newCursor(fd int, crtc uint32) (*Cursor, error) {
66115 buf.destroy(fd)
67116 return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
68117 }
69- return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil
118+ c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}
119+ c.ioctl = func(v *modeCursor2) error { return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(v)) }
120+ c.vblank = vblankWaiter(fd, pipe)
121+ c.start()
122+ return c, nil
123+}
124+
125+// start runs the worker that applies coalesced moves.
126+func (c *Cursor) start() {
127+ c.wake = make(chan struct{}, 1)
128+ c.stop = make(chan struct{})
129+ c.done = make(chan struct{})
130+ go func() {
131+ defer close(c.done)
132+ for {
133+ select {
134+ case <-c.stop:
135+ return
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+ }
142+ c.io.Lock()
143+ if c.shown && c.buf != nil {
144+ x, y := c.position()
145+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)}
146+ // Errors while another session owns the card (VT switched
147+ // away) are ignored; Reapply restores the cursor.
148+ start := time.Now()
149+ _ = c.ioctl(&v)
150+ d := time.Since(start)
151+ c.mu.Lock()
152+ c.stats.Ioctls++
153+ c.stats.MaxIoctl = max(c.stats.MaxIoctl, d)
154+ c.mu.Unlock()
155+ }
156+ c.io.Unlock()
157+ }
158+ }
159+ }()
160+}
161+
162+func (c *Cursor) position() (int, int) {
163+ c.mu.Lock()
164+ defer c.mu.Unlock()
165+ return c.x, c.y
70166 }
71167
72168 // Limit is the largest image side the cursor plane accepts.
@@ -75,8 +171,8 @@ func (c *Cursor) Limit() int { return c.size }
75171 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
76172 // click point at (hotX, hotY). Larger images are cropped to the plane.
77173 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
78- c.mu.Lock()
79- defer c.mu.Unlock()
174+ c.io.Lock()
175+ defer c.io.Unlock()
80176 if c.buf == nil {
81177 return nil
82178 }
@@ -90,45 +186,51 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
90186 return c.apply()
91187 }
92188
93-// apply (re)attaches the buffer at the current position.
189+// apply (re)attaches the buffer at the current position. Callers hold c.io.
94190 func (c *Cursor) apply() error {
95191 if !c.shown || c.buf == nil {
96192 return nil
97193 }
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))
194+ x, y := c.position()
195+ 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)}
196+ return c.ioctl(&v)
100197 }
101198
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.
199+// Move places the hotspot at physical (x, y). It never blocks on the GPU:
200+// the worker applies the latest position.
104201 func (c *Cursor) Move(x, y float64) {
105202 c.mu.Lock()
106- defer c.mu.Unlock()
107203 c.x, c.y = int(x), int(y)
108- if !c.shown || c.buf == nil {
109- return
204+ c.stats.Moves++
205+ c.mu.Unlock()
206+ select {
207+ case c.wake <- struct{}{}:
208+ default: // a move is already queued; it will read this position
110209 }
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))
113210 }
114211
115212 // Reapply shows the cursor again after a modeset (VT switch back).
116213 func (c *Cursor) Reapply() error {
117- c.mu.Lock()
118- defer c.mu.Unlock()
214+ c.io.Lock()
215+ defer c.io.Unlock()
119216 return c.apply()
120217 }
121218
122-// close hides the cursor and frees its buffer.
219+// close stops the worker, hides the cursor and frees its buffer.
123220 func (c *Cursor) close() {
124- c.mu.Lock()
125- defer c.mu.Unlock()
221+ if c.stop != nil {
222+ close(c.stop)
223+ <-c.done
224+ c.stop = nil
225+ }
226+ c.io.Lock()
227+ defer c.io.Unlock()
126228 if c.buf == nil {
127229 return
128230 }
129231 // Best effort: at exit the card may already belong to another session.
130232 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
131- _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))
233+ _ = c.ioctl(&v)
132234 c.buf.destroy(c.fd)
133235 c.buf, c.shown = nil, false
134236 }
@@ -3,6 +3,7 @@ package drm
3 import (3 import (
4 "fmt"4 "fmt"
5 "sync"5 "sync"
6+ "time"
6 "unsafe"7 "unsafe"
7 )8 )
8 9
@@ -12,6 +13,10 @@ import (
12 const (13 const (
13 ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)14 ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
14 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
15 capCursorW = 0x820 capCursorW = 0x8
16 capCursorH = 0x921 capCursorH = 0x9
17 cursorBO = 0x0122 cursorBO = 0x01
@@ -20,6 +25,20 @@ const (
20 25
21 type getCap struct{ capability, value uint64 }26 type getCap struct{ capability, value uint64 }
22 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+
23 type modeCursor2 struct {42 type modeCursor2 struct {
24 flags, crtcID uint3243 flags, crtcID uint32
25 x, y int3244 x, y int32
@@ -30,20 +49,50 @@ type modeCursor2 struct {
30 49
31 // 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
32 // input goroutine while the output goroutine changes the image.51 // input goroutine while the output goroutine changes the image.
52+//
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.
33 type Cursor struct {57 type Cursor struct {
34- mu sync.Mutex58+ io sync.Mutex // serialises cursor ioctls and guards the fields below
35 fd int59 fd int
36 crtc uint3260 crtc uint32
37 buf *dumbBuffer61 buf *dumbBuffer
38 size int // buffer side62 size int // buffer side
39 hotX, hotY int63 hotX, hotY int
40- x, y int // physical position of the hotspot
41 shown bool64 shown bool
65+ ioctl func(*modeCursor2) error
66+ vblank func() error // waits for the next vblank; nil moves at once
67+
68+ mu sync.Mutex // guards x, y and stats; never held across an ioctl
69+ x, y int // physical position of the hotspot
70+ stats CursorStats
71+
72+ wake chan struct{}
73+ stop chan struct{}
74+ done chan struct{}
75+}
76+
77+// CursorStats counts cursor work since the last TakeStats.
78+type CursorStats struct {
79+ Moves int // Move calls
80+ Ioctls int // move ioctls issued by the worker
81+ MaxIoctl time.Duration // slowest move ioctl
82+}
83+
84+// TakeStats returns and resets the counters.
85+func (c *Cursor) TakeStats() CursorStats {
86+ c.mu.Lock()
87+ defer c.mu.Unlock()
88+ s := c.stats
89+ c.stats = CursorStats{}
90+ return s
42 }91 }
43 92
44 // 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
45 // the driver does not say).94 // the driver does not say).
46-func newCursor(fd int, crtc uint32) (*Cursor, error) {95+func newCursor(fd int, crtc uint32, pipe int) (*Cursor, error) {
47 // 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.
48 size := 097 size := 0
49 for _, c := range []uint64{capCursorW, capCursorH} {98 for _, c := range []uint64{capCursorW, capCursorH} {
@@ -66,7 +115,54 @@ func newCursor(fd int, crtc uint32) (*Cursor, error) {
66 buf.destroy(fd)115 buf.destroy(fd)
67 return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)116 return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
68 }117 }
69- return &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}, nil118+ c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size}
119+ c.ioctl = func(v *modeCursor2) error { return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(v)) }
120+ c.vblank = vblankWaiter(fd, pipe)
121+ c.start()
122+ return c, nil
123+}
124+
125+// start runs the worker that applies coalesced moves.
126+func (c *Cursor) start() {
127+ c.wake = make(chan struct{}, 1)
128+ c.stop = make(chan struct{})
129+ c.done = make(chan struct{})
130+ go func() {
131+ defer close(c.done)
132+ for {
133+ select {
134+ case <-c.stop:
135+ return
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+ }
142+ c.io.Lock()
143+ if c.shown && c.buf != nil {
144+ x, y := c.position()
145+ v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)}
146+ // Errors while another session owns the card (VT switched
147+ // away) are ignored; Reapply restores the cursor.
148+ start := time.Now()
149+ _ = c.ioctl(&v)
150+ d := time.Since(start)
151+ c.mu.Lock()
152+ c.stats.Ioctls++
153+ c.stats.MaxIoctl = max(c.stats.MaxIoctl, d)
154+ c.mu.Unlock()
155+ }
156+ c.io.Unlock()
157+ }
158+ }
159+ }()
160+}
161+
162+func (c *Cursor) position() (int, int) {
163+ c.mu.Lock()
164+ defer c.mu.Unlock()
165+ return c.x, c.y
70 }166 }
71 167
72 // Limit is the largest image side the cursor plane accepts.168 // Limit is the largest image side the cursor plane accepts.
@@ -75,8 +171,8 @@ func (c *Cursor) Limit() int { return c.size }
75 // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the171 // 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.172 // 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 {173 func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
78- c.mu.Lock()174+ c.io.Lock()
79- defer c.mu.Unlock()175+ defer c.io.Unlock()
80 if c.buf == nil {176 if c.buf == nil {
81 return nil177 return nil
82 }178 }
@@ -90,45 +186,51 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
90 return c.apply()186 return c.apply()
91 }187 }
92 188
93-// apply (re)attaches the buffer at the current position.189+// apply (re)attaches the buffer at the current position. Callers hold c.io.
94 func (c *Cursor) apply() error {190 func (c *Cursor) apply() error {
95 if !c.shown || c.buf == nil {191 if !c.shown || c.buf == nil {
96 return nil192 return nil
97 }193 }
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)}194+ x, y := c.position()
99- return ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))195+ 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)}
196+ return c.ioctl(&v)
100 }197 }
101 198
102-// Move places the hotspot at physical (x, y). Errors while another session199+// 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.200+// the worker applies the latest position.
104 func (c *Cursor) Move(x, y float64) {201 func (c *Cursor) Move(x, y float64) {
105 c.mu.Lock()202 c.mu.Lock()
106- defer c.mu.Unlock()
107 c.x, c.y = int(x), int(y)203 c.x, c.y = int(x), int(y)
108- if !c.shown || c.buf == nil {204+ c.stats.Moves++
109- return205+ c.mu.Unlock()
206+ select {
207+ case c.wake <- struct{}{}:
208+ default: // a move is already queued; it will read this position
110 }209 }
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 }210 }
114 211
115 // Reapply shows the cursor again after a modeset (VT switch back).212 // Reapply shows the cursor again after a modeset (VT switch back).
116 func (c *Cursor) Reapply() error {213 func (c *Cursor) Reapply() error {
117- c.mu.Lock()214+ c.io.Lock()
118- defer c.mu.Unlock()215+ defer c.io.Unlock()
119 return c.apply()216 return c.apply()
120 }217 }
121 218
122-// close hides the cursor and frees its buffer.219+// close stops the worker, hides the cursor and frees its buffer.
123 func (c *Cursor) close() {220 func (c *Cursor) close() {
124- c.mu.Lock()221+ if c.stop != nil {
125- defer c.mu.Unlock()222+ close(c.stop)
223+ <-c.done
224+ c.stop = nil
225+ }
226+ c.io.Lock()
227+ defer c.io.Unlock()
126 if c.buf == nil {228 if c.buf == nil {
127 return229 return
128 }230 }
129 // Best effort: at exit the card may already belong to another session.231 // Best effort: at exit the card may already belong to another session.
130 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}232 v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
131- _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v))233+ _ = c.ioctl(&v)
132 c.buf.destroy(c.fd)234 c.buf.destroy(c.fd)
133 c.buf, c.shown = nil, false235 c.buf, c.shown = nil, false
134 }236 }
M internal/adapters/drm/cursor_test.go +105 -0
@@ -1,7 +1,9 @@
11 package drm
22
33 import (
4+ "sync"
45 "testing"
6+ "time"
57 "unsafe"
68 )
79
@@ -13,4 +15,107 @@ func TestCursorABI(t *testing.T) {
1315 if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {
1416 t.Fatalf("drm_get_cap size %d", s)
1517 }
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+ }
50+}
51+
52+// The legacy cursor ioctl may wait for vblank; Move must not, or libinput
53+// stops reading and the kernel drops mouse reports (slow, erratic pointer).
54+func TestCursorMoveDoesNotBlockOnIoctl(t *testing.T) {
55+ var mu sync.Mutex
56+ var calls int
57+ var last modeCursor2
58+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true, hotX: 3, hotY: 4}
59+ c.ioctl = func(v *modeCursor2) error {
60+ time.Sleep(6 * time.Millisecond) // one 165 Hz vblank
61+ mu.Lock()
62+ defer mu.Unlock()
63+ calls++
64+ last = *v
65+ return nil
66+ }
67+ c.start()
68+ defer c.close()
69+ start := time.Now()
70+ for i := range 1000 { // one second of a 1 kHz mouse
71+ c.Move(float64(i), float64(2*i))
72+ }
73+ if d := time.Since(start); d > 500*time.Millisecond {
74+ t.Fatalf("1000 moves took %v; Move blocks on the ioctl", d)
75+ }
76+ deadline := time.Now().Add(2 * time.Second)
77+ for {
78+ mu.Lock()
79+ got, n := last, calls
80+ mu.Unlock()
81+ if got.x == 999-3 && got.y == 1998-4 {
82+ if got.flags != cursorMove || n >= 1000 {
83+ t.Fatalf("flags %#x after %d ioctls; want coalesced moves", got.flags, n)
84+ }
85+ return
86+ }
87+ if time.Now().After(deadline) {
88+ t.Fatalf("last applied %+v after %d ioctls; want the final position", got, n)
89+ }
90+ time.Sleep(time.Millisecond)
91+ }
92+}
93+
94+func TestCursorCloseStopsWorker(t *testing.T) {
95+ c := &Cursor{buf: &dumbBuffer{}, shown: true, fd: -1}
96+ c.ioctl = func(*modeCursor2) error { return nil }
97+ c.start()
98+ c.Move(1, 2)
99+ c.close()
100+ c.close() // idempotent
101+ c.Move(3, 4) // after close: no worker, must not block or panic
102+}
103+
104+func TestCursorStats(t *testing.T) {
105+ applied := make(chan struct{}, 16)
106+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true}
107+ c.ioctl = func(*modeCursor2) error { applied <- struct{}{}; return nil }
108+ c.start()
109+ defer c.close()
110+ c.Move(1, 1)
111+ <-applied
112+ c.Move(2, 2)
113+ <-applied
114+ s := c.TakeStats()
115+ if s.Moves != 2 || s.Ioctls < 1 || s.Ioctls > 2 {
116+ t.Fatalf("stats %+v", s)
117+ }
118+ if s := c.TakeStats(); s.Moves != 0 {
119+ t.Fatalf("not reset: %+v", s)
120+ }
16121 }
@@ -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
@@ -13,4 +15,107 @@ func TestCursorABI(t *testing.T) {
13 if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {15 if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {
14 t.Fatalf("drm_get_cap size %d", s)16 t.Fatalf("drm_get_cap size %d", s)
15 }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+ }
50+}
51+
52+// The legacy cursor ioctl may wait for vblank; Move must not, or libinput
53+// stops reading and the kernel drops mouse reports (slow, erratic pointer).
54+func TestCursorMoveDoesNotBlockOnIoctl(t *testing.T) {
55+ var mu sync.Mutex
56+ var calls int
57+ var last modeCursor2
58+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true, hotX: 3, hotY: 4}
59+ c.ioctl = func(v *modeCursor2) error {
60+ time.Sleep(6 * time.Millisecond) // one 165 Hz vblank
61+ mu.Lock()
62+ defer mu.Unlock()
63+ calls++
64+ last = *v
65+ return nil
66+ }
67+ c.start()
68+ defer c.close()
69+ start := time.Now()
70+ for i := range 1000 { // one second of a 1 kHz mouse
71+ c.Move(float64(i), float64(2*i))
72+ }
73+ if d := time.Since(start); d > 500*time.Millisecond {
74+ t.Fatalf("1000 moves took %v; Move blocks on the ioctl", d)
75+ }
76+ deadline := time.Now().Add(2 * time.Second)
77+ for {
78+ mu.Lock()
79+ got, n := last, calls
80+ mu.Unlock()
81+ if got.x == 999-3 && got.y == 1998-4 {
82+ if got.flags != cursorMove || n >= 1000 {
83+ t.Fatalf("flags %#x after %d ioctls; want coalesced moves", got.flags, n)
84+ }
85+ return
86+ }
87+ if time.Now().After(deadline) {
88+ t.Fatalf("last applied %+v after %d ioctls; want the final position", got, n)
89+ }
90+ time.Sleep(time.Millisecond)
91+ }
92+}
93+
94+func TestCursorCloseStopsWorker(t *testing.T) {
95+ c := &Cursor{buf: &dumbBuffer{}, shown: true, fd: -1}
96+ c.ioctl = func(*modeCursor2) error { return nil }
97+ c.start()
98+ c.Move(1, 2)
99+ c.close()
100+ c.close() // idempotent
101+ c.Move(3, 4) // after close: no worker, must not block or panic
102+}
103+
104+func TestCursorStats(t *testing.T) {
105+ applied := make(chan struct{}, 16)
106+ c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true}
107+ c.ioctl = func(*modeCursor2) error { applied <- struct{}{}; return nil }
108+ c.start()
109+ defer c.close()
110+ c.Move(1, 1)
111+ <-applied
112+ c.Move(2, 2)
113+ <-applied
114+ s := c.TakeStats()
115+ if s.Moves != 2 || s.Ioctls < 1 || s.Ioctls > 2 {
116+ t.Fatalf("stats %+v", s)
117+ }
118+ if s := c.TakeStats(); s.Moves != 0 {
119+ t.Fatalf("not reset: %+v", s)
120+ }
16 }121 }
M internal/adapters/drm/output.go +8 -2
@@ -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")
@@ -225,7 +226,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
225226 }
226227 dirty = true
227228 case <-stats.C:
228- o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats")
229+ ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
230+ if o.cursor != nil {
231+ cs := o.cursor.TakeStats()
232+ ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_ioctls", cs.Ioctls).Dur("cursor_max_ioctl_ms", cs.MaxIoctl)
233+ }
234+ ev.Msg("stats")
229235 }
230236 if !dirty || !haveScene || !enabled || o.pending {
231237 continue
@@ -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")
@@ -225,7 +226,12 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (Renderer,
225 }226 }
226 dirty = true227 dirty = true
227 case <-stats.C:228 case <-stats.C:
228- o.log.Info().Int("frames", frame).Int("flips", o.flips).Msg("stats")229+ ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
230+ if o.cursor != nil {
231+ cs := o.cursor.TakeStats()
232+ ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_ioctls", cs.Ioctls).Dur("cursor_max_ioctl_ms", cs.MaxIoctl)
233+ }
234+ ev.Msg("stats")
229 }235 }
230 if !dirty || !haveScene || !enabled || o.pending {236 if !dirty || !haveScene || !enabled || o.pending {
231 continue237 continue
A internal/adapters/libinput/forward.go +99 -0
@@ -0,0 +1,99 @@
1+package libinput
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// forwarder decouples reading libinput from core. libinput keeps reading
12+// devices at their full rate and never blocks on core; consecutive pointer
13+// motions still waiting to be sent collapse into the latest one, while keys
14+// and buttons keep their order relative to motion.
15+type forwarder struct {
16+ mu sync.Mutex
17+ queue []ports.InputEvent
18+ wake chan struct{}
19+ stats forwardStats
20+}
21+
22+// forwardStats is reset by take.
23+type forwardStats struct {
24+ Motions int // pointer motions read
25+ Coalesced int // motions merged into a later one
26+ Sent int // events delivered to core
27+ MaxBlock time.Duration // longest wait for core to accept one event
28+}
29+
30+func newForwarder() *forwarder { return &forwarder{wake: make(chan struct{}, 1)} }
31+
32+// push queues ev without blocking.
33+func (f *forwarder) push(ev ports.InputEvent) {
34+ f.mu.Lock()
35+ if m, ok := ev.(ports.PointerMotion); ok {
36+ f.stats.Motions++
37+ if n := len(f.queue); n > 0 {
38+ if _, tail := f.queue[n-1].(ports.PointerMotion); tail {
39+ f.queue[n-1] = m
40+ f.stats.Coalesced++
41+ f.mu.Unlock()
42+ return
43+ }
44+ }
45+ }
46+ f.queue = append(f.queue, ev)
47+ f.mu.Unlock()
48+ select {
49+ case f.wake <- struct{}{}:
50+ default:
51+ }
52+}
53+
54+func (f *forwarder) pop() (ports.InputEvent, bool) {
55+ f.mu.Lock()
56+ defer f.mu.Unlock()
57+ if len(f.queue) == 0 {
58+ return nil, false
59+ }
60+ ev := f.queue[0]
61+ f.queue[0] = nil
62+ f.queue = f.queue[1:]
63+ return ev, true
64+}
65+
66+// take returns and resets the counters.
67+func (f *forwarder) take() forwardStats {
68+ f.mu.Lock()
69+ defer f.mu.Unlock()
70+ s := f.stats
71+ f.stats = forwardStats{}
72+ return s
73+}
74+
75+// run delivers queued events to input until ctx ends.
76+func (f *forwarder) run(ctx context.Context, input chan<- ports.InputEvent) {
77+ for {
78+ ev, ok := f.pop()
79+ if !ok {
80+ select {
81+ case <-ctx.Done():
82+ return
83+ case <-f.wake:
84+ continue
85+ }
86+ }
87+ start := time.Now()
88+ select {
89+ case input <- ev:
90+ case <-ctx.Done():
91+ return
92+ }
93+ d := time.Since(start)
94+ f.mu.Lock()
95+ f.stats.Sent++
96+ f.stats.MaxBlock = max(f.stats.MaxBlock, d)
97+ f.mu.Unlock()
98+ }
99+}
@@ -0,0 +1,99 @@
1+package libinput
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// forwarder decouples reading libinput from core. libinput keeps reading
12+// devices at their full rate and never blocks on core; consecutive pointer
13+// motions still waiting to be sent collapse into the latest one, while keys
14+// and buttons keep their order relative to motion.
15+type forwarder struct {
16+ mu sync.Mutex
17+ queue []ports.InputEvent
18+ wake chan struct{}
19+ stats forwardStats
20+}
21+
22+// forwardStats is reset by take.
23+type forwardStats struct {
24+ Motions int // pointer motions read
25+ Coalesced int // motions merged into a later one
26+ Sent int // events delivered to core
27+ MaxBlock time.Duration // longest wait for core to accept one event
28+}
29+
30+func newForwarder() *forwarder { return &forwarder{wake: make(chan struct{}, 1)} }
31+
32+// push queues ev without blocking.
33+func (f *forwarder) push(ev ports.InputEvent) {
34+ f.mu.Lock()
35+ if m, ok := ev.(ports.PointerMotion); ok {
36+ f.stats.Motions++
37+ if n := len(f.queue); n > 0 {
38+ if _, tail := f.queue[n-1].(ports.PointerMotion); tail {
39+ f.queue[n-1] = m
40+ f.stats.Coalesced++
41+ f.mu.Unlock()
42+ return
43+ }
44+ }
45+ }
46+ f.queue = append(f.queue, ev)
47+ f.mu.Unlock()
48+ select {
49+ case f.wake <- struct{}{}:
50+ default:
51+ }
52+}
53+
54+func (f *forwarder) pop() (ports.InputEvent, bool) {
55+ f.mu.Lock()
56+ defer f.mu.Unlock()
57+ if len(f.queue) == 0 {
58+ return nil, false
59+ }
60+ ev := f.queue[0]
61+ f.queue[0] = nil
62+ f.queue = f.queue[1:]
63+ return ev, true
64+}
65+
66+// take returns and resets the counters.
67+func (f *forwarder) take() forwardStats {
68+ f.mu.Lock()
69+ defer f.mu.Unlock()
70+ s := f.stats
71+ f.stats = forwardStats{}
72+ return s
73+}
74+
75+// run delivers queued events to input until ctx ends.
76+func (f *forwarder) run(ctx context.Context, input chan<- ports.InputEvent) {
77+ for {
78+ ev, ok := f.pop()
79+ if !ok {
80+ select {
81+ case <-ctx.Done():
82+ return
83+ case <-f.wake:
84+ continue
85+ }
86+ }
87+ start := time.Now()
88+ select {
89+ case input <- ev:
90+ case <-ctx.Done():
91+ return
92+ }
93+ d := time.Since(start)
94+ f.mu.Lock()
95+ f.stats.Sent++
96+ f.stats.MaxBlock = max(f.stats.MaxBlock, d)
97+ f.mu.Unlock()
98+ }
99+}
A internal/adapters/libinput/forward_test.go +70 -0
@@ -0,0 +1,70 @@
1+package libinput
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// A slow consumer must not block push, and stale motion must collapse into
12+// the latest position while keys and buttons keep their order.
13+func TestForwarderCoalescesMotionWithoutBlocking(t *testing.T) {
14+ f := newForwarder()
15+ input := make(chan ports.InputEvent) // unbuffered: core is busy
16+ start := time.Now()
17+ for i := range 1000 {
18+ f.push(ports.PointerMotion{X: float64(i)})
19+ }
20+ f.push(ports.PointerButton{Button: 272, Pressed: true})
21+ f.push(ports.PointerMotion{X: 2000})
22+ f.push(ports.PointerMotion{X: 2001})
23+ if d := time.Since(start); d > 100*time.Millisecond {
24+ t.Fatalf("push blocked for %v", d)
25+ }
26+ ctx, cancel := context.WithCancel(context.Background())
27+ defer cancel()
28+ go f.run(ctx, input)
29+ want := []ports.InputEvent{
30+ ports.PointerMotion{X: 999},
31+ ports.PointerButton{Button: 272, Pressed: true},
32+ ports.PointerMotion{X: 2001},
33+ }
34+ for i, w := range want {
35+ select {
36+ case got := <-input:
37+ if got != w {
38+ t.Fatalf("event %d: got %#v, want %#v", i, got, w)
39+ }
40+ case <-time.After(2 * time.Second):
41+ t.Fatalf("event %d not delivered", i)
42+ }
43+ }
44+ select {
45+ case got := <-input:
46+ t.Fatalf("unexpected extra event %#v", got)
47+ case <-time.After(20 * time.Millisecond):
48+ }
49+ s := f.take()
50+ if s.Motions != 1002 || s.Coalesced != 1000 || s.Sent != 3 {
51+ t.Fatalf("stats %+v", s)
52+ }
53+ if s := f.take(); s != (forwardStats{}) {
54+ t.Fatalf("take did not reset: %+v", s)
55+ }
56+}
57+
58+func TestForwarderStopsOnCancel(t *testing.T) {
59+ f := newForwarder()
60+ f.push(ports.PointerMotion{X: 1})
61+ ctx, cancel := context.WithCancel(context.Background())
62+ done := make(chan struct{})
63+ go func() { f.run(ctx, make(chan ports.InputEvent)); close(done) }()
64+ cancel()
65+ select {
66+ case <-done:
67+ case <-time.After(2 * time.Second):
68+ t.Fatal("run did not stop while blocked on send")
69+ }
70+}
@@ -0,0 +1,70 @@
1+package libinput
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/ports"
9+)
10+
11+// A slow consumer must not block push, and stale motion must collapse into
12+// the latest position while keys and buttons keep their order.
13+func TestForwarderCoalescesMotionWithoutBlocking(t *testing.T) {
14+ f := newForwarder()
15+ input := make(chan ports.InputEvent) // unbuffered: core is busy
16+ start := time.Now()
17+ for i := range 1000 {
18+ f.push(ports.PointerMotion{X: float64(i)})
19+ }
20+ f.push(ports.PointerButton{Button: 272, Pressed: true})
21+ f.push(ports.PointerMotion{X: 2000})
22+ f.push(ports.PointerMotion{X: 2001})
23+ if d := time.Since(start); d > 100*time.Millisecond {
24+ t.Fatalf("push blocked for %v", d)
25+ }
26+ ctx, cancel := context.WithCancel(context.Background())
27+ defer cancel()
28+ go f.run(ctx, input)
29+ want := []ports.InputEvent{
30+ ports.PointerMotion{X: 999},
31+ ports.PointerButton{Button: 272, Pressed: true},
32+ ports.PointerMotion{X: 2001},
33+ }
34+ for i, w := range want {
35+ select {
36+ case got := <-input:
37+ if got != w {
38+ t.Fatalf("event %d: got %#v, want %#v", i, got, w)
39+ }
40+ case <-time.After(2 * time.Second):
41+ t.Fatalf("event %d not delivered", i)
42+ }
43+ }
44+ select {
45+ case got := <-input:
46+ t.Fatalf("unexpected extra event %#v", got)
47+ case <-time.After(20 * time.Millisecond):
48+ }
49+ s := f.take()
50+ if s.Motions != 1002 || s.Coalesced != 1000 || s.Sent != 3 {
51+ t.Fatalf("stats %+v", s)
52+ }
53+ if s := f.take(); s != (forwardStats{}) {
54+ t.Fatalf("take did not reset: %+v", s)
55+ }
56+}
57+
58+func TestForwarderStopsOnCancel(t *testing.T) {
59+ f := newForwarder()
60+ f.push(ports.PointerMotion{X: 1})
61+ ctx, cancel := context.WithCancel(context.Background())
62+ done := make(chan struct{})
63+ go func() { f.run(ctx, make(chan ports.InputEvent)); close(done) }()
64+ cancel()
65+ select {
66+ case <-done:
67+ case <-time.After(2 * time.Second):
68+ t.Fatal("run did not stop while blocked on send")
69+ }
70+}
M internal/adapters/libinput/libinput.go +17 -7
@@ -148,6 +148,9 @@ type Options struct {
148148 Log zerowrap.Logger
149149 }
150150
151+// statsEvery is how often Run logs input throughput.
152+const statsEvery = 10 * time.Second
153+
151154 // Run owns libinput and the keymap, converting device events into input events.
152155 func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
153156 defer func() { opts.Keymap.Close() }()
@@ -178,7 +181,19 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
178181 opts.MoveCursor(p.x, p.y)
179182 }
180183 fd := getFD(li)
184+ fwd := newForwarder()
185+ fwdCtx, stopFwd := context.WithCancel(ctx)
186+ var fwdDone sync.WaitGroup
187+ fwdDone.Go(func() { fwd.run(fwdCtx, input) })
188+ defer func() { stopFwd(); fwdDone.Wait() }()
189+ lastStats := time.Now()
181190 for ctx.Err() == nil {
191+ if time.Since(lastStats) >= statsEvery {
192+ lastStats = time.Now()
193+ if s := fwd.take(); s.Motions > 0 || s.Sent > 0 {
194+ opts.Log.Info().Int("motions", s.Motions).Int("coalesced", s.Coalesced).Int("sent", s.Sent).Dur("max_block_ms", s.MaxBlock).Msg("input stats")
195+ }
196+ }
182197 select {
183198 case on := <-opts.Active:
184199 if on {
@@ -210,13 +225,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
210225 if err != nil {
211226 return err
212227 }
213- if out == nil {
214- continue
215- }
216- select {
217- case input <- out:
218- case <-ctx.Done():
219- return nil
228+ if out != nil {
229+ fwd.push(out)
220230 }
221231 }
222232 }
@@ -148,6 +148,9 @@ type Options struct {
148 Log zerowrap.Logger148 Log zerowrap.Logger
149 }149 }
150 150
151+// statsEvery is how often Run logs input throughput.
152+const statsEvery = 10 * time.Second
153+
151 // Run owns libinput and the keymap, converting device events into input events.154 // Run owns libinput and the keymap, converting device events into input events.
152 func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {155 func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error {
153 defer func() { opts.Keymap.Close() }()156 defer func() { opts.Keymap.Close() }()
@@ -178,7 +181,19 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
178 opts.MoveCursor(p.x, p.y)181 opts.MoveCursor(p.x, p.y)
179 }182 }
180 fd := getFD(li)183 fd := getFD(li)
184+ fwd := newForwarder()
185+ fwdCtx, stopFwd := context.WithCancel(ctx)
186+ var fwdDone sync.WaitGroup
187+ fwdDone.Go(func() { fwd.run(fwdCtx, input) })
188+ defer func() { stopFwd(); fwdDone.Wait() }()
189+ lastStats := time.Now()
181 for ctx.Err() == nil {190 for ctx.Err() == nil {
191+ if time.Since(lastStats) >= statsEvery {
192+ lastStats = time.Now()
193+ if s := fwd.take(); s.Motions > 0 || s.Sent > 0 {
194+ opts.Log.Info().Int("motions", s.Motions).Int("coalesced", s.Coalesced).Int("sent", s.Sent).Dur("max_block_ms", s.MaxBlock).Msg("input stats")
195+ }
196+ }
182 select {197 select {
183 case on := <-opts.Active:198 case on := <-opts.Active:
184 if on {199 if on {
@@ -210,13 +225,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
210 if err != nil {225 if err != nil {
211 return err226 return err
212 }227 }
213- if out == nil {228+ if out != nil {
214- continue229+ fwd.push(out)
215- }
216- select {
217- case input <- out:
218- case <-ctx.Done():
219- return nil
220 }230 }
221 }231 }
222 }232 }
A internal/adapters/wayland/commands_test.go +59 -0
@@ -0,0 +1,59 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "reflect"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefertty/internal/logging"
10+ "github.com/bnema/nefertty/internal/ports"
11+)
12+
13+func TestDrainCommandsCollapsesMotion(t *testing.T) {
14+ cmds := make(chan ports.ClientCommand, 16)
15+ cmds <- ports.PointerMotionTo{ID: 1, X: 2}
16+ cmds <- ports.PointerMotionTo{ID: 1, X: 3}
17+ cmds <- ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true}
18+ cmds <- ports.PointerMotionTo{ID: 1, X: 4}
19+ cmds <- ports.PointerFocus{ID: 2}
20+ cmds <- ports.PointerMotionTo{ID: 2, X: 5}
21+ cmds <- ports.PointerMotionTo{ID: 2, X: 6}
22+ batch, open := drainCommands(ports.PointerMotionTo{ID: 1, X: 1}, cmds)
23+ want := []ports.ClientCommand{
24+ ports.PointerMotionTo{ID: 1, X: 3},
25+ ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true},
26+ ports.PointerMotionTo{ID: 1, X: 4},
27+ ports.PointerFocus{ID: 2},
28+ ports.PointerMotionTo{ID: 2, X: 6},
29+ }
30+ if !open || !reflect.DeepEqual(batch, want) {
31+ t.Fatalf("open=%v batch=%#v", open, batch)
32+ }
33+ close(cmds)
34+ if _, open := drainCommands(ports.PointerFocus{}, cmds); open {
35+ t.Fatal("closed channel reported open")
36+ }
37+}
38+
39+// Each command used to cost one display.Do round trip (up to one 5 ms
40+// event-loop dispatch), capping pointer motion at ~150/s. A 1 kHz mouse
41+// must be absorbed without core blocking.
42+func TestCommandThroughput(t *testing.T) {
43+ cmds := make(chan ports.ClientCommand, 32)
44+ s, err := New(Options{RuntimeDir: t.TempDir(), OutputWidth: 1920, OutputHeight: 1080}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ ctx, cancel := context.WithCancel(context.Background())
49+ done := make(chan error, 1)
50+ go func() { done <- s.Run(ctx) }()
51+ defer func() { cancel(); <-done }()
52+ start := time.Now()
53+ for i := range 2000 {
54+ cmds <- ports.PointerMotionTo{ID: 1, X: float64(i), Y: 1}
55+ }
56+ if d := time.Since(start); d > time.Second {
57+ t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds())
58+ }
59+}
@@ -0,0 +1,59 @@
1+package wayland
2+
3+import (
4+ "context"
5+ "reflect"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefertty/internal/logging"
10+ "github.com/bnema/nefertty/internal/ports"
11+)
12+
13+func TestDrainCommandsCollapsesMotion(t *testing.T) {
14+ cmds := make(chan ports.ClientCommand, 16)
15+ cmds <- ports.PointerMotionTo{ID: 1, X: 2}
16+ cmds <- ports.PointerMotionTo{ID: 1, X: 3}
17+ cmds <- ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true}
18+ cmds <- ports.PointerMotionTo{ID: 1, X: 4}
19+ cmds <- ports.PointerFocus{ID: 2}
20+ cmds <- ports.PointerMotionTo{ID: 2, X: 5}
21+ cmds <- ports.PointerMotionTo{ID: 2, X: 6}
22+ batch, open := drainCommands(ports.PointerMotionTo{ID: 1, X: 1}, cmds)
23+ want := []ports.ClientCommand{
24+ ports.PointerMotionTo{ID: 1, X: 3},
25+ ports.PointerButtonTo{ID: 1, Button: 272, Pressed: true},
26+ ports.PointerMotionTo{ID: 1, X: 4},
27+ ports.PointerFocus{ID: 2},
28+ ports.PointerMotionTo{ID: 2, X: 6},
29+ }
30+ if !open || !reflect.DeepEqual(batch, want) {
31+ t.Fatalf("open=%v batch=%#v", open, batch)
32+ }
33+ close(cmds)
34+ if _, open := drainCommands(ports.PointerFocus{}, cmds); open {
35+ t.Fatal("closed channel reported open")
36+ }
37+}
38+
39+// Each command used to cost one display.Do round trip (up to one 5 ms
40+// event-loop dispatch), capping pointer motion at ~150/s. A 1 kHz mouse
41+// must be absorbed without core blocking.
42+func TestCommandThroughput(t *testing.T) {
43+ cmds := make(chan ports.ClientCommand, 32)
44+ s, err := New(Options{RuntimeDir: t.TempDir(), OutputWidth: 1920, OutputHeight: 1080}, Channels{Commands: cmds}, logging.For(context.Background(), "wayland"))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ ctx, cancel := context.WithCancel(context.Background())
49+ done := make(chan error, 1)
50+ go func() { done <- s.Run(ctx) }()
51+ defer func() { cancel(); <-done }()
52+ start := time.Now()
53+ for i := range 2000 {
54+ cmds <- ports.PointerMotionTo{ID: 1, X: float64(i), Y: 1}
55+ }
56+ if d := time.Since(start); d > time.Second {
57+ t.Fatalf("2000 motion commands took %v (%.0f/s); want well above 1000/s", d, 2000/d.Seconds())
58+ }
59+}
M internal/adapters/wayland/server.go +38 -3
@@ -174,11 +174,18 @@ func (s *Server) Run(ctx context.Context) error {
174174 if ctx.Err() != nil {
175175 return
176176 }
177+ // One display round trip per batch: Do waits for the event
178+ // loop, so a round trip per motion capped pointer events at
179+ // ~150/s and let a backlog of stale motions build up.
180+ batch, open := drainCommands(cmd, s.channels.Commands)
177181 if !s.display.Do(func() {
178- if ctx.Err() == nil {
179- s.apply(cmd)
182+ for _, c := range batch {
183+ if ctx.Err() != nil {
184+ return
185+ }
186+ s.apply(c)
180187 }
181- }) {
188+ }) || !open {
182189 return
183190 }
184191 }
@@ -315,6 +322,34 @@ func (s *Server) forwardContents(ctx context.Context) {
315322 }
316323 }
317324
325+// maxCommandBatch bounds how many queued commands one display round trip applies.
326+const maxCommandBatch = 256
327+
328+// drainCommands returns first plus the commands already queued, with runs of
329+// pointer motion for the same window collapsed into the latest one. open is
330+// false once the channel is closed.
331+func drainCommands(first ports.ClientCommand, cmds <-chan ports.ClientCommand) (batch []ports.ClientCommand, open bool) {
332+ batch = append(batch, first)
333+ for len(batch) < maxCommandBatch {
334+ select {
335+ case c, ok := <-cmds:
336+ if !ok {
337+ return batch, false
338+ }
339+ if m, isMotion := c.(ports.PointerMotionTo); isMotion {
340+ if last, lastMotion := batch[len(batch)-1].(ports.PointerMotionTo); lastMotion && last.ID == m.ID {
341+ batch[len(batch)-1] = m
342+ continue
343+ }
344+ }
345+ batch = append(batch, c)
346+ default:
347+ return batch, true
348+ }
349+ }
350+ return batch, true
351+}
352+
318353 func (s *Server) apply(cmd ports.ClientCommand) {
319354 switch c := cmd.(type) {
320355 case ports.PointerFocus:
@@ -174,11 +174,18 @@ func (s *Server) Run(ctx context.Context) error {
174 if ctx.Err() != nil {174 if ctx.Err() != nil {
175 return175 return
176 }176 }
177+ // One display round trip per batch: Do waits for the event
178+ // loop, so a round trip per motion capped pointer events at
179+ // ~150/s and let a backlog of stale motions build up.
180+ batch, open := drainCommands(cmd, s.channels.Commands)
177 if !s.display.Do(func() {181 if !s.display.Do(func() {
178- if ctx.Err() == nil {182+ for _, c := range batch {
179- s.apply(cmd)183+ if ctx.Err() != nil {
184+ return
185+ }
186+ s.apply(c)
180 }187 }
181- }) {188+ }) || !open {
182 return189 return
183 }190 }
184 }191 }
@@ -315,6 +322,34 @@ func (s *Server) forwardContents(ctx context.Context) {
315 }322 }
316 }323 }
317 324
325+// maxCommandBatch bounds how many queued commands one display round trip applies.
326+const maxCommandBatch = 256
327+
328+// drainCommands returns first plus the commands already queued, with runs of
329+// pointer motion for the same window collapsed into the latest one. open is
330+// false once the channel is closed.
331+func drainCommands(first ports.ClientCommand, cmds <-chan ports.ClientCommand) (batch []ports.ClientCommand, open bool) {
332+ batch = append(batch, first)
333+ for len(batch) < maxCommandBatch {
334+ select {
335+ case c, ok := <-cmds:
336+ if !ok {
337+ return batch, false
338+ }
339+ if m, isMotion := c.(ports.PointerMotionTo); isMotion {
340+ if last, lastMotion := batch[len(batch)-1].(ports.PointerMotionTo); lastMotion && last.ID == m.ID {
341+ batch[len(batch)-1] = m
342+ continue
343+ }
344+ }
345+ batch = append(batch, c)
346+ default:
347+ return batch, true
348+ }
349+ }
350+ return batch, true
351+}
352+
318 func (s *Server) apply(cmd ports.ClientCommand) {353 func (s *Server) apply(cmd ports.ClientCommand) {
319 switch c := cmd.(type) {354 switch c := cmd.(type) {
320 case ports.PointerFocus:355 case ports.PointerFocus:

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