merge: cursor-perf (vblank-synced hardware cursor, non-blocking input)
8 files · +520 -34
M internal/adapters/drm/cursor.go +124 -22
| @@ -3,6 +3,7 @@ package drm | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "sync" | |
| 6 | + | "time" | |
| 6 | 7 | "unsafe" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -12,6 +13,10 @@ import ( | |||
| 12 | 13 | const ( | |
| 13 | 14 | ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap) | |
| 14 | 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 | 20 | capCursorW = 0x8 | |
| 16 | 21 | capCursorH = 0x9 | |
| 17 | 22 | cursorBO = 0x01 | |
| @@ -20,6 +25,20 @@ const ( | |||
| 20 | 25 | ||
| 21 | 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 | 42 | type modeCursor2 struct { | |
| 24 | 43 | flags, crtcID uint32 | |
| 25 | 44 | x, y int32 | |
| @@ -30,20 +49,50 @@ type modeCursor2 struct { | |||
| 30 | 49 | ||
| 31 | 50 | // Cursor is the hardware cursor of one output. Move is safe to call from the | |
| 32 | 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 | 57 | type Cursor struct { | |
| 34 | - | mu sync.Mutex | |
| 58 | + | io sync.Mutex // serialises cursor ioctls and guards the fields below | |
| 35 | 59 | fd int | |
| 36 | 60 | crtc uint32 | |
| 37 | 61 | buf *dumbBuffer | |
| 38 | 62 | size int // buffer side | |
| 39 | 63 | hotX, hotY int | |
| 40 | - | x, y int // physical position of the hotspot | |
| 41 | 64 | 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 | 93 | // newCursor allocates a cursor buffer at the driver's preferred size (64 when | |
| 45 | 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 | 96 | // The plane is square: the smaller of the width and height caps. | |
| 48 | 97 | size := 0 | |
| 49 | 98 | for _, c := range []uint64{capCursorW, capCursorH} { | |
| @@ -66,7 +115,54 @@ func newCursor(fd int, crtc uint32) (*Cursor, error) { | |||
| 66 | 115 | buf.destroy(fd) | |
| 67 | 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}, 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 | |
| 70 | 166 | } | |
| 71 | 167 | ||
| 72 | 168 | // Limit is the largest image side the cursor plane accepts. | |
| @@ -75,8 +171,8 @@ func (c *Cursor) Limit() int { return c.size } | |||
| 75 | 171 | // SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the | |
| 76 | 172 | // click point at (hotX, hotY). Larger images are cropped to the plane. | |
| 77 | 173 | 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() | |
| 80 | 176 | if c.buf == nil { | |
| 81 | 177 | return nil | |
| 82 | 178 | } | |
| @@ -90,45 +186,51 @@ func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error { | |||
| 90 | 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 | 190 | func (c *Cursor) apply() error { | |
| 95 | 191 | if !c.shown || c.buf == nil { | |
| 96 | 192 | 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)} | |
| 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) | |
| 100 | 197 | } | |
| 101 | 198 | ||
| 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. | |
| 104 | 201 | func (c *Cursor) Move(x, y float64) { | |
| 105 | 202 | c.mu.Lock() | |
| 106 | - | defer c.mu.Unlock() | |
| 107 | 203 | 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 | |
| 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 | 212 | // Reapply shows the cursor again after a modeset (VT switch back). | |
| 116 | 213 | func (c *Cursor) Reapply() error { | |
| 117 | - | c.mu.Lock() | |
| 118 | - | defer c.mu.Unlock() | |
| 214 | + | c.io.Lock() | |
| 215 | + | defer c.io.Unlock() | |
| 119 | 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 | 220 | 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() | |
| 126 | 228 | if c.buf == nil { | |
| 127 | 229 | return | |
| 128 | 230 | } | |
| 129 | 231 | // Best effort: at exit the card may already belong to another session. | |
| 130 | 232 | v := modeCursor2{flags: cursorBO, crtcID: c.crtc} | |
| 131 | - | _ = ioctl(c.fd, ioctlCursor2, unsafe.Pointer(&v)) | |
| 233 | + | _ = c.ioctl(&v) | |
| 132 | 234 | c.buf.destroy(c.fd) | |
| 133 | 235 | c.buf, c.shown = nil, false | |
| 134 | 236 | } | |
| @@ -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 = 0x8 | 20 | capCursorW = 0x8 | ||
| 16 | capCursorH = 0x9 | 21 | capCursorH = 0x9 | ||
| 17 | cursorBO = 0x01 | 22 | 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 uint32 | 43 | flags, crtcID uint32 | ||
| 25 | x, y int32 | 44 | 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 the | 50 | // 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.Mutex | 58 | + | io sync.Mutex // serialises cursor ioctls and guards the fields below |
| 35 | fd int | 59 | fd int | ||
| 36 | crtc uint32 | 60 | crtc uint32 | ||
| 37 | buf *dumbBuffer | 61 | buf *dumbBuffer | ||
| 38 | size int // buffer side | 62 | size int // buffer side | ||
| 39 | hotX, hotY int | 63 | hotX, hotY int | ||
| 40 | - | x, y int // physical position of the hotspot | |||
| 41 | shown bool | 64 | 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 when | 93 | // 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 := 0 | 97 | 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}, 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 | |||
| 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 the | 171 | // 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 nil | 177 | 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 nil | 192 | 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 session | 199 | + | // 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 | - | return | 205 | + | 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 | return | 229 | 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, false | 235 | c.buf, c.shown = nil, false | ||
| 134 | } | 236 | } | ||
M internal/adapters/drm/cursor_test.go +105 -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 | ||
| @@ -13,4 +15,107 @@ func TestCursorABI(t *testing.T) { | |||
| 13 | 15 | if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 { | |
| 14 | 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 | } | |
| @@ -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 | ||||
| @@ -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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "slices" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -96,7 +97,7 @@ func Open(fd int, card string, want Want, log zerowrap.Logger) (*Output, error) | |||
| 96 | 97 | 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 | 101 | log.Warn().Err(err).Msg("no hardware cursor") | |
| 101 | 102 | } | |
| 102 | 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 | 227 | dirty = true | |
| 227 | 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 | 236 | if !dirty || !haveScene || !enabled || o.pending { | |
| 231 | 237 | 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, err | 97 | 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 = true | 227 | 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 | continue | 237 | 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 { | |||
| 148 | 148 | Log zerowrap.Logger | |
| 149 | 149 | } | |
| 150 | 150 | ||
| 151 | + | // statsEvery is how often Run logs input throughput. | |
| 152 | + | const statsEvery = 10 * time.Second | |
| 153 | + | ||
| 151 | 154 | // Run owns libinput and the keymap, converting device events into input events. | |
| 152 | 155 | func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error { | |
| 153 | 156 | defer func() { opts.Keymap.Close() }() | |
| @@ -178,7 +181,19 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 178 | 181 | opts.MoveCursor(p.x, p.y) | |
| 179 | 182 | } | |
| 180 | 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 | 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 | 197 | select { | |
| 183 | 198 | case on := <-opts.Active: | |
| 184 | 199 | if on { | |
| @@ -210,13 +225,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 210 | 225 | if err != nil { | |
| 211 | 226 | return err | |
| 212 | 227 | } | |
| 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) | |
| 220 | 230 | } | |
| 221 | 231 | } | |
| 222 | 232 | } | |
| @@ -148,6 +148,9 @@ type Options struct { | |||||
| 148 | Log zerowrap.Logger | 148 | 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 err | 226 | return err | ||
| 212 | } | 227 | } | ||
| 213 | - | if out == nil { | 228 | + | if out != nil { |
| 214 | - | continue | 229 | + | 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 { | |||
| 174 | 174 | if ctx.Err() != nil { | |
| 175 | 175 | 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 | 181 | 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) | |
| 180 | 187 | } | |
| 181 | - | }) { | |
| 188 | + | }) || !open { | |
| 182 | 189 | 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 | 353 | func (s *Server) apply(cmd ports.ClientCommand) { | |
| 319 | 354 | switch c := cmd.(type) { | |
| 320 | 355 | 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 | return | 175 | 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 | return | 189 | 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: | ||