detached 7449251

feat(drm): drive outputs with atomic commits only, fence-synced flips with kernel timestamps

Every screen change is one nonblocking atomic commit with a flip event:
primary plane, cursor plane and VRR together, the frame GPU fence as
IN_FENCE_FD. Tearing uses primary-only async commits, probed once per
output. Legacy page flips, SETCRTC, cursor ioctls, WAIT_VBLANK,
SETPROPERTY and dumb buffers are gone; a card without atomic KMS or a GPU
that cannot export scanout images is refused.

Flip reports carry the kernel timestamp and vblank sequence and are never
merged below 16 queued; composed content counts as read only after its
flip. The renderer port returns the frame fence and provides cursor images;
CopyBGRX is removed. Mockery is pinned to v3.8.0 like CI.
commit
7449251830e02cb0cc6666dabf1384212c3d0187
author
brice · · 2026-09-26 10:34 UTC
parent
1d61aca
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
25 files · +2492 -1062
M .mockery.yml +2 -2
@@ -18,11 +18,11 @@ packages:
1818 # Unexported adapter seams: mocks live next to the code, in test files.
1919 github.com/bnema/nefertty/internal/adapters/drm:
2020 interfaces:
21- cursorPlane:
21+ kms:
2222 config:
2323 dir: internal/adapters/drm
2424 pkgname: drm
25- filename: cursor_plane_mock_test.go
25+ filename: kms_mock_test.go
2626 github.com/bnema/nefertty/internal/adapters/wayland:
2727 interfaces:
2828 procEnv:
A internal/adapters/drm/atomic.go +351 -0
@@ -0,0 +1,351 @@
1+package drm
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "runtime"
7+ "slices"
8+ "sync"
9+ "time"
10+ "unsafe"
11+
12+ "github.com/bnema/nefertty/internal/ports"
13+)
14+
15+// Atomic KMS (ADR 015): every change on screen is one atomic commit. A
16+// frame commit flips the primary plane, moves the cursor plane and sets
17+// VRR together; the kernel waits for the frame's GPU fence (IN_FENCE_FD)
18+// before flipping, so no thread waits for the GPU or a vblank.
19+
20+const (
21+ ioctlSetClientCap = 0x4010640D // DRM_IOW('d', 0x0D, struct drm_set_client_cap)
22+ ioctlGetPlaneRes = 0xC01064B5 // DRM_IOWR('d', 0xB5, struct drm_mode_get_plane_res)
23+ ioctlGetPlane = 0xC02064B6 // DRM_IOWR('d', 0xB6, struct drm_mode_get_plane)
24+ ioctlGetPropBlob = 0xC01064AC // DRM_IOWR('d', 0xAC, struct drm_mode_get_blob)
25+ ioctlAtomic = 0xC03864BC // DRM_IOWR('d', 0xBC, struct drm_mode_atomic)
26+ ioctlCreateBlob = 0xC01064BD // DRM_IOWR('d', 0xBD, struct drm_mode_create_blob)
27+ ioctlDestroyBlob = 0xC00464BE // DRM_IOWR('d', 0xBE, struct drm_mode_destroy_blob)
28+
29+ clientCapUniversalPlanes = 2
30+ clientCapAtomic = 3
31+ capTimestampMonotonic = 0x6
32+ capAtomicAsync = 0x15
33+
34+ objPlane = 0xeeeeeeee
35+
36+ flipEventFlag = 0x1 // DRM_MODE_PAGE_FLIP_EVENT
37+ flipAsyncFlag = 0x2 // DRM_MODE_PAGE_FLIP_ASYNC
38+ atomicTestOnly = 0x100 // DRM_MODE_ATOMIC_TEST_ONLY
39+ atomicNonblock = 0x200 // DRM_MODE_ATOMIC_NONBLOCK
40+ atomicAllowModes = 0x400 // DRM_MODE_ATOMIC_ALLOW_MODESET
41+
42+ planeOverlay = 0
43+ planePrimary = 1
44+ planeCursor = 2
45+)
46+
47+type setClientCap struct{ capability, value uint64 }
48+
49+type getPlaneRes struct {
50+ planeIDs uint64
51+ count, _ uint32
52+}
53+
54+type getPlane struct {
55+ planeID, crtcID, fbID, possibleCrtcs, gammaSize, countFormats uint32
56+ formats uint64
57+}
58+
59+type getBlob struct {
60+ blobID, length uint32
61+ data uint64
62+}
63+
64+type createBlob struct {
65+ data uint64
66+ length, blobID uint32
67+}
68+
69+type modeAtomic struct {
70+ flags, countObjs uint32
71+ objs, countProps, props, values, reserved, userData uint64
72+}
73+
74+// kms is the kernel side of the output path. kmsDevice is the real one;
75+// tests use its Mockery mock.
76+type kms interface {
77+ // commit issues DRM_IOCTL_MODE_ATOMIC; userData comes back in the flip event.
78+ commit(req *atomicReq, flags uint32, userData uint64) error
79+ createBlob(data []byte) (uint32, error)
80+ destroyBlob(id uint32) error
81+ getBlob(id uint32) ([]byte, error)
82+ // planes lists every plane with the CRTCs it can show on.
83+ planes() ([]planeRes, error)
84+ objProps(obj, typ uint32) (map[string][2]uint64, error)
85+ // addFB imports a single-plane dmabuf as a framebuffer of format.
86+ addFB(b *ports.DMABuf, format uint32) (uint32, error)
87+ rmFB(id uint32) error
88+}
89+
90+// planeRes is one plane of the card: possible is a bitmask of CRTC indices.
91+type planeRes struct{ id, possible uint32 }
92+
93+// atomicReq collects (object, property, value) triples in insertion order.
94+type atomicReq struct {
95+ objs []uint32
96+ props [][]propValue
97+}
98+
99+type propValue struct {
100+ prop uint32
101+ val uint64
102+}
103+
104+// set adds a property; unknown properties (id 0) are skipped.
105+func (r *atomicReq) set(obj, prop uint32, v uint64) {
106+ if prop == 0 || obj == 0 {
107+ return
108+ }
109+ i := slices.Index(r.objs, obj)
110+ if i < 0 {
111+ r.objs = append(r.objs, obj)
112+ r.props = append(r.props, nil)
113+ i = len(r.objs) - 1
114+ }
115+ for j, p := range r.props[i] {
116+ if p.prop == prop {
117+ r.props[i][j].val = v
118+ return
119+ }
120+ }
121+ r.props[i] = append(r.props[i], propValue{prop, v})
122+}
123+
124+// value returns a property set on an object.
125+func (r *atomicReq) value(obj, prop uint32) (uint64, bool) {
126+ i := slices.Index(r.objs, obj)
127+ if i < 0 {
128+ return 0, false
129+ }
130+ for _, p := range r.props[i] {
131+ if p.prop == prop {
132+ return p.val, true
133+ }
134+ }
135+ return 0, false
136+}
137+
138+// kmsDevice drives a card fd.
139+type kmsDevice struct {
140+ fd int
141+ // gemMu serialises GEM handle import and close: handles are shared by
142+ // every import of a buffer on the fd, and outputs run on their own
143+ // goroutines. modifiers is DRM_CAP_ADDFB2_MODIFIERS.
144+ gemMu *sync.Mutex
145+ modifiers bool
146+}
147+
148+func (k kmsDevice) commit(req *atomicReq, flags uint32, userData uint64) error {
149+ if len(req.objs) == 0 {
150+ return errors.New("empty atomic commit")
151+ }
152+ counts := make([]uint32, len(req.objs))
153+ var props []uint32
154+ var vals []uint64
155+ for i, ps := range req.props {
156+ counts[i] = uint32(len(ps))
157+ for _, p := range ps {
158+ props = append(props, p.prop)
159+ vals = append(vals, p.val)
160+ }
161+ }
162+ a := modeAtomic{flags: flags, countObjs: uint32(len(req.objs)), userData: userData,
163+ objs: uint64(uintptr(unsafe.Pointer(&req.objs[0]))), countProps: uint64(uintptr(unsafe.Pointer(&counts[0]))),
164+ props: uint64(uintptr(unsafe.Pointer(&props[0]))), values: uint64(uintptr(unsafe.Pointer(&vals[0])))}
165+ err := ioctl(k.fd, ioctlAtomic, unsafe.Pointer(&a))
166+ runtime.KeepAlive(req)
167+ runtime.KeepAlive(counts)
168+ runtime.KeepAlive(props)
169+ runtime.KeepAlive(vals)
170+ return err
171+}
172+
173+func (k kmsDevice) createBlob(data []byte) (uint32, error) {
174+ b := createBlob{data: uint64(uintptr(unsafe.Pointer(&data[0]))), length: uint32(len(data))}
175+ err := ioctl(k.fd, ioctlCreateBlob, unsafe.Pointer(&b))
176+ runtime.KeepAlive(data)
177+ return b.blobID, err
178+}
179+
180+func (k kmsDevice) destroyBlob(id uint32) error {
181+ v := id
182+ return ioctl(k.fd, ioctlDestroyBlob, unsafe.Pointer(&v))
183+}
184+
185+func (k kmsDevice) getBlob(id uint32) ([]byte, error) {
186+ b := getBlob{blobID: id}
187+ if err := ioctl(k.fd, ioctlGetPropBlob, unsafe.Pointer(&b)); err != nil {
188+ return nil, err
189+ }
190+ if b.length == 0 {
191+ return nil, nil
192+ }
193+ data := make([]byte, b.length)
194+ b.data = uint64(uintptr(unsafe.Pointer(&data[0])))
195+ err := ioctl(k.fd, ioctlGetPropBlob, unsafe.Pointer(&b))
196+ runtime.KeepAlive(data)
197+ return data[:min(int(b.length), len(data))], err
198+}
199+
200+func (k kmsDevice) planes() ([]planeRes, error) {
201+ var r getPlaneRes
202+ if err := ioctl(k.fd, ioctlGetPlaneRes, unsafe.Pointer(&r)); err != nil {
203+ return nil, err
204+ }
205+ if r.count == 0 {
206+ return nil, nil
207+ }
208+ ids := make([]uint32, r.count)
209+ r.planeIDs = uint64(uintptr(unsafe.Pointer(&ids[0])))
210+ if err := ioctl(k.fd, ioctlGetPlaneRes, unsafe.Pointer(&r)); err != nil {
211+ return nil, err
212+ }
213+ runtime.KeepAlive(ids)
214+ var out []planeRes
215+ for _, id := range ids[:min(int(r.count), len(ids))] {
216+ p := getPlane{planeID: id}
217+ if ioctl(k.fd, ioctlGetPlane, unsafe.Pointer(&p)) != nil {
218+ continue
219+ }
220+ out = append(out, planeRes{id: id, possible: p.possibleCrtcs})
221+ }
222+ return out, nil
223+}
224+
225+func (k kmsDevice) objProps(obj, typ uint32) (map[string][2]uint64, error) {
226+ return objProps(k.fd, obj, typ)
227+}
228+
229+func (k kmsDevice) rmFB(id uint32) error {
230+ v := id
231+ return ioctl(k.fd, ioctlRmFB, unsafe.Pointer(&v))
232+}
233+
234+// enableAtomic turns on atomic modesetting and checks the caps outputs rely on.
235+func enableAtomic(fd int) error {
236+ for _, c := range []uint64{clientCapUniversalPlanes, clientCapAtomic} {
237+ v := setClientCap{capability: c, value: 1}
238+ if err := ioctl(fd, ioctlSetClientCap, unsafe.Pointer(&v)); err != nil {
239+ return errors.Join(errors.New("atomic modesetting unsupported"), err)
240+ }
241+ }
242+ if !hasCap(fd, capTimestampMonotonic) {
243+ return errors.New("flip timestamps are not CLOCK_MONOTONIC")
244+ }
245+ return nil
246+}
247+
248+func hasCap(fd int, c uint64) bool {
249+ v := getCap{capability: c}
250+ return ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value == 1
251+}
252+
253+// plane is a KMS plane an output drives, with its property IDs by name.
254+type plane struct {
255+ id, typ uint32
256+ props map[string]uint32
257+ crtc uint32 // CRTC_ID when read
258+ formats []ports.DMABufFormat
259+}
260+
261+func (p *plane) prop(name string) uint32 {
262+ if p == nil {
263+ return 0
264+ }
265+ return p.props[name]
266+}
267+
268+// readPlanes returns the planes that can show on the CRTC at index pipe.
269+func readPlanes(k kms, pipe int) ([]*plane, error) {
270+ res, err := k.planes()
271+ if err != nil {
272+ return nil, err
273+ }
274+ var out []*plane
275+ for _, r := range res {
276+ if r.possible&(1<<pipe) == 0 {
277+ continue
278+ }
279+ props, err := k.objProps(r.id, objPlane)
280+ if err != nil {
281+ continue
282+ }
283+ p := &plane{id: r.id, typ: uint32(props["type"][1]), props: map[string]uint32{}, crtc: uint32(props["CRTC_ID"][1])}
284+ for name, v := range props {
285+ p.props[name] = uint32(v[0])
286+ }
287+ if blob := uint32(props["IN_FORMATS"][1]); blob != 0 {
288+ if data, err := k.getBlob(blob); err == nil {
289+ p.formats = parseInFormats(data)
290+ }
291+ }
292+ out = append(out, p)
293+ }
294+ return out, nil
295+}
296+
297+// parseInFormats decodes an IN_FORMATS blob (struct drm_format_modifier_blob)
298+// into format and modifier pairs.
299+func parseInFormats(b []byte) []ports.DMABufFormat {
300+ if len(b) < 24 {
301+ return nil
302+ }
303+ le := binary.LittleEndian
304+ nf, fo := int(le.Uint32(b[8:])), int(le.Uint32(b[12:]))
305+ nm, mo := int(le.Uint32(b[16:])), int(le.Uint32(b[20:]))
306+ if fo < 0 || fo+nf*4 > len(b) || mo < 0 || mo+nm*24 > len(b) {
307+ return nil
308+ }
309+ var out []ports.DMABufFormat
310+ for m := range nm {
311+ e := b[mo+m*24:]
312+ mask, offset, mod := le.Uint64(e), int(le.Uint32(e[8:])), le.Uint64(e[16:])
313+ for bit := range 64 {
314+ i := offset + bit
315+ if mask&(1<<bit) == 0 || i >= nf {
316+ continue
317+ }
318+ out = append(out, ports.DMABufFormat{Format: le.Uint32(b[fo+i*4:]), Modifier: mod})
319+ }
320+ }
321+ return out
322+}
323+
324+// flipEvent is a completed commit on a CRTC: when is its CLOCK_MONOTONIC
325+// time, seq the CRTC's vblank counter, user the commit's userData.
326+type flipEvent struct {
327+ crtc uint32
328+ user uint64
329+ when time.Duration
330+ seq uint32
331+}
332+
333+// parseFlips parses DRM events read from the card fd.
334+func parseFlips(buf []byte) []flipEvent {
335+ var out []flipEvent
336+ le := binary.LittleEndian
337+ for len(buf) >= 8 {
338+ typ := le.Uint32(buf)
339+ length := int(le.Uint32(buf[4:]))
340+ if length < 8 || length > len(buf) {
341+ break
342+ }
343+ // struct drm_event_vblank: base(8) user_data(8) sec usec sequence crtc_id.
344+ if typ == eventFlipDone && length >= 32 {
345+ sec, usec := le.Uint32(buf[16:]), le.Uint32(buf[20:])
346+ out = append(out, flipEvent{user: le.Uint64(buf[8:]), when: time.Duration(sec)*time.Second + time.Duration(usec)*time.Microsecond, seq: le.Uint32(buf[24:]), crtc: le.Uint32(buf[28:])})
347+ }
348+ buf = buf[length:]
349+ }
350+ return out
351+}
M internal/adapters/drm/card.go +42 -27
@@ -6,15 +6,14 @@ import (
66 "fmt"
77 "slices"
88 "sync"
9- "unsafe"
109
1110 "github.com/bnema/zerowrap"
1211 "golang.org/x/sys/unix"
1312 )
1413
1514 // Card is one DRM device and the outputs it drives. Scan finds connected
16-// connectors; each gets its own Output (CRTC, buffers, cursor) run by one
17-// goroutine. One reader goroutine reads page-flip events for all of them
15+// connectors; each gets its own Output (CRTC, planes, cursor) run by one
16+// goroutine. One reader goroutine reads commit events for all of them
1817 // and hands each to its output by CRTC.
1918 type Card struct {
2019 fd int
@@ -24,30 +23,30 @@ type Card struct {
2423 crtcs []uint32
2524 // outputs by connector name; owned by the goroutine that calls Scan.
2625 outputs map[string]*Output
27- flips map[uint32]chan int // by CRTC; set before the reader starts
28- // gemMu serialises GEM handle import and close on fd (outputs run on
29- // their own goroutines); modifiers is DRM_CAP_ADDFB2_MODIFIERS.
30- gemMu sync.Mutex
31- modifiers bool
32- // async is DRM_CAP_ASYNC_PAGE_FLIP.
26+ flips map[uint32]chan flipEvent // by CRTC; set before the reader starts
27+ k kmsDevice
28+ // taken are planes driven by an output, by plane ID.
29+ taken map[uint32]bool
30+ // async is DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP.
3331 async bool
3432 }
3533
36-// OpenCard reads the card's CRTCs. fd stays owned by the caller.
34+// OpenCard turns on atomic modesetting and reads the card's CRTCs. fd
35+// stays owned by the caller. A card without atomic KMS is refused.
3736 func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error) {
37+ if err := enableAtomic(fd); err != nil {
38+ return nil, fmt.Errorf("%s: %w", path, err)
39+ }
3840 crtcs, _, err := resources(fd)
3941 if err != nil {
4042 return nil, fmt.Errorf("%s: %w", path, err)
4143 }
42- flips := make(map[uint32]chan int, len(crtcs))
44+ flips := make(map[uint32]chan flipEvent, len(crtcs))
4345 for _, c := range crtcs {
44- flips[c] = make(chan int, 4)
46+ flips[c] = make(chan flipEvent, 4)
4547 }
46- cp := getCap{capability: capAddFB2Modifiers}
47- mods := ioctl(fd, ioctlGetCap, unsafe.Pointer(&cp)) == nil && cp.value == 1
48- ap := getCap{capability: capAsyncPageFlip}
49- async := ioctl(fd, ioctlGetCap, unsafe.Pointer(&ap)) == nil && ap.value == 1
50- return &Card{fd: fd, path: path, want: want, log: log, crtcs: crtcs, outputs: map[string]*Output{}, flips: flips, modifiers: mods, async: async}, nil
48+ k := kmsDevice{fd: fd, gemMu: &sync.Mutex{}, modifiers: hasCap(fd, capAddFB2Modifiers)}
49+ return &Card{fd: fd, path: path, want: want, log: log, crtcs: crtcs, outputs: map[string]*Output{}, flips: flips, k: k, taken: map[uint32]bool{}, async: hasCap(fd, capAtomicAsync)}, nil
5150 }
5251
5352 // Path is the device path, e.g. /dev/dri/card1.
@@ -103,9 +102,16 @@ func (c *Card) Scan() (added []*Output, removed, replaced []string, err error) {
103102 return added, removed, replaced, nil
104103 }
105104
106-// Release forgets an output closed by its goroutine, freeing its CRTC for the
107-// next Scan.
108-func (c *Card) Release(name string) { delete(c.outputs, name) }
105+// Release forgets an output closed by its goroutine, freeing its CRTC and
106+// planes for the next Scan.
107+func (c *Card) Release(name string) {
108+ if o := c.outputs[name]; o != nil {
109+ for _, p := range o.owned() {
110+ delete(c.taken, p.id)
111+ }
112+ }
113+ delete(c.outputs, name)
114+}
109115
110116 // Outputs returns the driven outputs.
111117 func (c *Card) Outputs() []*Output {
@@ -131,11 +137,19 @@ func (c *Card) open(conn connector, mode modeInfo) (*Output, error) {
131137 if err != nil {
132138 return nil, err
133139 }
134- return newOutput(c, conn, mode, crtc)
140+ o, err := newOutput(c, conn, mode, crtc)
141+ if err != nil {
142+ return nil, err
143+ }
144+ for _, p := range o.owned() {
145+ c.taken[p.id] = true
146+ }
147+ return o, nil
135148 }
136149
137-// ReadEvents reads page-flip events until ctx ends and routes them to the
138-// output of their CRTC. Run it once per card.
150+// ReadEvents reads commit events until ctx ends and routes them to the
151+// output of their CRTC. Run it once per card. An event is never dropped:
152+// the output owes it a pending commit.
139153 func (c *Card) ReadEvents(ctx context.Context) error {
140154 buf := make([]byte, 1024)
141155 for ctx.Err() == nil {
@@ -154,14 +168,15 @@ func (c *Card) ReadEvents(ctx context.Context) error {
154168 }
155169 return fmt.Errorf("drm read: %w", err)
156170 }
157- for _, crtc := range flipCrtcs(buf[:m]) {
158- ch := c.flips[crtc]
171+ for _, ev := range parseFlips(buf[:m]) {
172+ ch := c.flips[ev.crtc]
159173 if ch == nil {
160174 continue
161175 }
162176 select {
163- case ch <- 1:
164- default: // the output is busy; the flip is counted on its next event
177+ case ch <- ev:
178+ case <-ctx.Done():
179+ return nil
165180 }
166181 }
167182 }
M internal/adapters/drm/cursor.go +123 -228
@@ -1,281 +1,176 @@
11 package drm
22
33 import (
4- "fmt"
4+ "errors"
55 "sync"
6- "time"
7- "unsafe"
6+
7+ "github.com/bnema/nefertty/internal/ports"
88 )
99
10-// Hardware cursor through the legacy cursor ioctls: the GPU overlays a small
11-// buffer at a position, so moving it never re-renders the frame.
10+// Hardware cursor on the CRTC's cursor plane. Move and Hide come from the
11+// input goroutine: they only record the position and wake the output,
12+// whose goroutine puts the cursor plane in its next atomic commit. A move
13+// while a commit is pending waits for it, so the cursor moves at most once
14+// per flip and never blocks input.
1215
1316 const (
14- ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
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
20- capCursorW = 0x8
21- capCursorH = 0x9
22- cursorBO = 0x01
23- cursorMove = 0x02
17+ ioctlGetCap = 0xC010640C // DRM_IOWR('d', 0x0C, struct drm_get_cap)
18+ capCursorW = 0x8
19+ capCursorH = 0x9
2420 )
2521
2622 type getCap struct{ capability, value uint64 }
2723
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-// cursorPlane is the kernel side of the hardware cursor.
35-type cursorPlane interface {
36- // Set issues DRM_IOCTL_MODE_CURSOR2.
37- Set(v *modeCursor2) error
38- // WaitVblank blocks until the next vblank of the cursor's CRTC.
39- WaitVblank() error
40-}
41-
42-// kmsCursorPlane drives the cursor of the CRTC at index pipe.
43-type kmsCursorPlane struct{ fd, pipe int }
44-
45-func (p kmsCursorPlane) Set(v *modeCursor2) error {
46- return ioctl(p.fd, ioctlCursor2, unsafe.Pointer(v))
47-}
48-
49-func (p kmsCursorPlane) WaitVblank() error {
50- v := waitVblank{typ: vblankRel | uint32(p.pipe<<vblankCrtcSh)&vblankCrtcMk, sequence: 1}
51- return ioctl(p.fd, ioctlVblank, unsafe.Pointer(&v))
52-}
53-
54-type modeCursor2 struct {
55- flags, crtcID uint32
56- x, y int32
57- width, height uint32
58- handle uint32
59- hotX, hotY int32
60-}
61-
62-// Cursor is the hardware cursor of one output. Move is safe to call from the
63-// input goroutine while the output goroutine changes the image.
64-//
65-// Move only records the position. A worker waits for the next vblank and then
66-// applies the latest one, so the cursor moves at most once per frame, between
67-// scanouts: moving it mid-scanout tears it into doubled images, and input is
68-// never throttled by the ioctl.
24+// Cursor is the hardware cursor of one output.
6925 type Cursor struct {
70- io sync.Mutex // serialises cursor ioctls and guards the fields below
71- fd int
72- crtc uint32
73- buf *dumbBuffer
74- size int // buffer side
75- hotX, hotY int
76- shown bool
77- hidden bool // the plane is off because the pointer left this output
78- plane cursorPlane
79-
80- mu sync.Mutex // guards x, y, away and stats; never held across an ioctl
26+ mu sync.Mutex // guards x, y, away and moves
8127 x, y int // physical position of the hotspot
8228 away bool // Hide was called and no Move since
83- stats CursorStats
29+ moves int
30+ wake chan struct{}
31+
32+ // Owned by the output goroutine.
33+ plane *plane
34+ size int
35+ fbs [2]uint32 // the two images; cur is on screen or next
36+ cur int
37+ image bool // an image is loaded
38+ hotX, hotY int
39+ applied cursorState // what the last commit put on the plane
40+ commits int
41+}
8442
85- wake chan struct{}
86- stop chan struct{}
87- done chan struct{}
43+// cursorState is the cursor plane as committed: off, or fb at x, y.
44+type cursorState struct {
45+ on bool
46+ fb uint32
47+ x, y int
8848 }
8949
9050 // CursorStats counts cursor work since the last TakeStats.
9151 type CursorStats struct {
92- Moves int // Move calls
93- Ioctls int // move ioctls issued by the worker
94- MaxIoctl time.Duration // slowest move ioctl
52+ Moves int // Move calls
53+ Commits int // commits that moved or changed the cursor
54+}
55+
56+func newCursor(p *plane, size int) *Cursor {
57+ return &Cursor{plane: p, size: size, wake: make(chan struct{}, 1)}
9558 }
9659
97-// TakeStats returns and resets the counters.
60+// TakeStats returns and resets the counters. Output goroutine only.
9861 func (c *Cursor) TakeStats() CursorStats {
9962 c.mu.Lock()
100- defer c.mu.Unlock()
101- s := c.stats
102- c.stats = CursorStats{}
63+ s := CursorStats{Moves: c.moves, Commits: c.commits}
64+ c.moves, c.commits = 0, 0
65+ c.mu.Unlock()
10366 return s
10467 }
10568
106-// newCursor allocates a cursor buffer at the driver's preferred size (64 when
107-// the driver does not say).
108-func newCursor(fd int, crtc uint32, pipe int) (*Cursor, error) {
109- // The plane is square: the smaller of the width and height caps.
110- size := 0
111- for _, c := range []uint64{capCursorW, capCursorH} {
112- v := getCap{capability: c}
113- if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 16 && v.value <= 512 {
114- if size == 0 || int(v.value) < size {
115- size = int(v.value)
116- }
117- }
118- }
119- if size == 0 {
120- size = 64
121- }
122- buf, err := newDumb(fd, size, size)
123- if err != nil {
124- return nil, err
125- }
126- // The cursor ioctl passes no pitch: drivers assume width*4.
127- if int(buf.pitch) != size*4 {
128- buf.destroy(fd)
129- return nil, fmt.Errorf("cursor buffer pitch %d, want %d", buf.pitch, size*4)
130- }
131- c := &Cursor{fd: fd, crtc: crtc, buf: buf, size: size, plane: kmsCursorPlane{fd: fd, pipe: pipe}}
132- c.start()
133- return c, nil
134-}
69+// Limit is the largest image side the cursor plane accepts.
70+func (c *Cursor) Limit() int { return c.size }
13571
136-// start runs the worker that applies coalesced moves.
137-func (c *Cursor) start() {
138- c.wake = make(chan struct{}, 1)
139- c.stop = make(chan struct{})
140- c.done = make(chan struct{})
141- go func() {
142- defer close(c.done)
143- for {
144- select {
145- case <-c.stop:
146- return
147- case <-c.wake:
148- // Errors (VT switched away) fall through: the move
149- // fails too and Reapply restores the cursor.
150- _ = c.plane.WaitVblank()
151- c.io.Lock()
152- c.mu.Lock()
153- away := c.away
154- c.mu.Unlock()
155- if away {
156- // A move queued before Hide: the pointer is elsewhere.
157- } else if c.hidden {
158- // A move shows it again, even before its first image.
159- c.hidden = false
160- _ = c.apply()
161- } else if c.shown && c.buf != nil {
162- x, y := c.position()
163- v := modeCursor2{flags: cursorMove, crtcID: c.crtc, x: int32(x - c.hotX), y: int32(y - c.hotY)}
164- // Errors while another session owns the card (VT switched
165- // away) are ignored; Reapply restores the cursor.
166- start := time.Now()
167- _ = c.plane.Set(&v)
168- d := time.Since(start)
169- c.mu.Lock()
170- c.stats.Ioctls++
171- c.stats.MaxIoctl = max(c.stats.MaxIoctl, d)
172- c.mu.Unlock()
173- }
174- c.io.Unlock()
175- }
176- }
177- }()
72+// Move places the hotspot at physical (x, y). It never blocks.
73+func (c *Cursor) Move(x, y float64) {
74+ c.mu.Lock()
75+ c.x, c.y = int(x), int(y)
76+ c.away = false
77+ c.moves++
78+ c.mu.Unlock()
79+ c.poke()
17880 }
17981
180-func (c *Cursor) position() (int, int) {
82+// Hide removes the cursor from the output until the next move (the pointer
83+// is on another output).
84+func (c *Cursor) Hide() {
18185 c.mu.Lock()
182- defer c.mu.Unlock()
183- return c.x, c.y
86+ c.away = true
87+ c.mu.Unlock()
88+ c.poke()
18489 }
18590
186-// Limit is the largest image side the cursor plane accepts.
187-func (c *Cursor) Limit() int { return c.size }
91+func (c *Cursor) poke() {
92+ select {
93+ case c.wake <- struct{}{}:
94+ default: // a wake is queued; the output reads the latest state
95+ }
96+}
18897
189-// SetImage shows premultiplied ARGB8888 pixels (w×h, w*4 per row) with the
190-// click point at (hotX, hotY). Larger images are cropped to the plane; an
191-// empty image hides the cursor (a client asked for none).
192-func (c *Cursor) SetImage(pixels []byte, w, h, hotX, hotY int) error {
193- c.io.Lock()
194- defer c.io.Unlock()
195- if c.buf == nil {
196- return nil
98+// setup allocates the two cursor images and their framebuffers.
99+func (c *Cursor) setup(k kms, r ports.Renderer) error {
100+ bufs, err := r.CursorBuffers(c.size)
101+ if err != nil {
102+ return err
197103 }
198- if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
199- wasShown := c.shown
200- c.shown = false
201- if !wasShown || c.hidden {
202- return nil
104+ for i := range bufs {
105+ if err == nil {
106+ c.fbs[i], err = k.addFB(&bufs[i], fourccARGB)
107+ }
108+ for _, p := range bufs[i].Planes {
109+ p.File.Close()
203110 }
204- v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
205- return c.plane.Set(&v)
206111 }
207- pitch := int(c.buf.pitch)
208- clear(c.buf.mem)
209- for y := range min(h, c.size) {
210- row := pixels[y*w*4:]
211- copy(c.buf.mem[y*pitch:y*pitch+min(w, c.size)*4], row[:min(w, c.size)*4])
112+ if err != nil {
113+ c.free(k)
212114 }
213- c.hotX, c.hotY, c.shown = hotX, hotY, true
214- return c.apply()
115+ return err
215116 }
216117
217-// apply (re)attaches the buffer at the current position. Callers hold c.io.
218-func (c *Cursor) apply() error {
219- if !c.shown || c.hidden || c.buf == nil {
118+// setImage loads premultiplied ARGB8888 pixels into the image not on
119+// screen; an empty image hides the cursor (a client asked for none).
120+func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {
121+ if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
122+ c.image = false
220123 return nil
221124 }
222- x, y := c.position()
223- 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)}
224- return c.plane.Set(&v)
225-}
226-
227-// Move places the hotspot at physical (x, y). It never blocks on the GPU:
228-// the worker applies the latest position.
229-func (c *Cursor) Move(x, y float64) {
230- c.mu.Lock()
231- c.x, c.y = int(x), int(y)
232- c.away = false
233- c.stats.Moves++
234- c.mu.Unlock()
235- select {
236- case c.wake <- struct{}{}:
237- default: // a move is already queued; it will read this position
125+ if c.fbs[0] == 0 {
126+ return errors.New("no cursor images")
127+ }
128+ next := 1 - c.cur
129+ if err := r.WriteCursor(next, pixels, w, h); err != nil {
130+ return err
238131 }
132+ c.cur, c.image, c.hotX, c.hotY = next, true, hotX, hotY
133+ return nil
239134 }
240135
241-// Hide removes the cursor from the output until the next move (the pointer
242-// is on another output).
243-func (c *Cursor) Hide() {
244- c.io.Lock()
245- defer c.io.Unlock()
136+// desired is what the cursor plane should show now.
137+func (c *Cursor) desired() cursorState {
246138 c.mu.Lock()
247- c.away = true
139+ x, y, away := c.x, c.y, c.away
248140 c.mu.Unlock()
249- if c.buf == nil || c.hidden {
250- return
141+ if !c.image || away || c.fbs[c.cur] == 0 {
142+ return cursorState{}
251143 }
252- c.hidden = true
253- v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
254- _ = c.plane.Set(&v)
255-}
256-
257-// Reapply shows the cursor again after a modeset (VT switch back).
258-func (c *Cursor) Reapply() error {
259- c.io.Lock()
260- defer c.io.Unlock()
261- return c.apply()
144+ return cursorState{on: true, fb: c.fbs[c.cur], x: x - c.hotX, y: y - c.hotY}
262145 }
263146
264-// close stops the worker, hides the cursor and frees its buffer.
265-func (c *Cursor) close() {
266- if c.stop != nil {
267- close(c.stop)
268- <-c.done
269- c.stop = nil
270- }
271- c.io.Lock()
272- defer c.io.Unlock()
273- if c.buf == nil {
147+// props adds the cursor plane in state s to req.
148+func (c *Cursor) props(req *atomicReq, crtc uint32, s cursorState) {
149+ p := c.plane
150+ if !s.on {
151+ req.set(p.id, p.prop("FB_ID"), 0)
152+ req.set(p.id, p.prop("CRTC_ID"), 0)
274153 return
275154 }
276- // Best effort: at exit the card may already belong to another session.
277- v := modeCursor2{flags: cursorBO, crtcID: c.crtc}
278- _ = c.plane.Set(&v)
279- c.buf.destroy(c.fd)
280- c.buf, c.shown = nil, false
155+ size := uint64(c.size)
156+ req.set(p.id, p.prop("FB_ID"), uint64(s.fb))
157+ req.set(p.id, p.prop("CRTC_ID"), uint64(crtc))
158+ req.set(p.id, p.prop("SRC_X"), 0)
159+ req.set(p.id, p.prop("SRC_Y"), 0)
160+ req.set(p.id, p.prop("SRC_W"), size<<16)
161+ req.set(p.id, p.prop("SRC_H"), size<<16)
162+ req.set(p.id, p.prop("CRTC_X"), uint64(int64(s.x)))
163+ req.set(p.id, p.prop("CRTC_Y"), uint64(int64(s.y)))
164+ req.set(p.id, p.prop("CRTC_W"), size)
165+ req.set(p.id, p.prop("CRTC_H"), size)
166+}
167+
168+func (c *Cursor) free(k kms) {
169+ for i, fb := range c.fbs {
170+ if fb != 0 {
171+ _ = k.rmFB(fb)
172+ c.fbs[i] = 0
173+ }
174+ }
175+ c.image = false
281176 }
D internal/adapters/drm/cursor_plane_mock_test.go +0 -140
@@ -1,140 +0,0 @@
1-// Code generated by mockery; DO NOT EDIT.
2-// github.com/vektra/mockery
3-// template: testify
4-
5-package drm
6-
7-import (
8- mock "github.com/stretchr/testify/mock"
9-)
10-
11-// newMockcursorPlane creates a new instance of mockcursorPlane. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12-// The first argument is typically a *testing.T value.
13-func newMockcursorPlane(t interface {
14- mock.TestingT
15- Cleanup(func())
16-}) *mockcursorPlane {
17- if helper, ok := t.(interface{ Helper() }); ok {
18- helper.Helper()
19- }
20-
21- mock := &mockcursorPlane{}
22- mock.Mock.Test(t)
23-
24- t.Cleanup(func() {
25- if helper, ok := t.(interface{ Helper() }); ok {
26- helper.Helper()
27- }
28- mock.AssertExpectations(t)
29- })
30-
31- return mock
32-}
33-
34-// mockcursorPlane is an autogenerated mock type for the cursorPlane type
35-type mockcursorPlane struct {
36- mock.Mock
37-}
38-
39-type mockcursorPlane_Expecter struct {
40- mock *mock.Mock
41-}
42-
43-func (_m *mockcursorPlane) EXPECT() *mockcursorPlane_Expecter {
44- return &mockcursorPlane_Expecter{mock: &_m.Mock}
45-}
46-
47-// Set provides a mock function for the type mockcursorPlane
48-func (_mock *mockcursorPlane) Set(v *modeCursor2) error {
49- ret := _mock.Called(v)
50-
51- if len(ret) == 0 {
52- panic("no return value specified for Set")
53- }
54-
55- var r0 error
56- if returnFunc, ok := ret.Get(0).(func(*modeCursor2) error); ok {
57- r0 = returnFunc(v)
58- } else {
59- r0 = ret.Error(0)
60- }
61- return r0
62-}
63-
64-// mockcursorPlane_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
65-type mockcursorPlane_Set_Call struct {
66- *mock.Call
67-}
68-
69-// Set is a helper method to define mock.On call
70-// - v *modeCursor2
71-func (_e *mockcursorPlane_Expecter) Set(v any) *mockcursorPlane_Set_Call {
72- return &mockcursorPlane_Set_Call{Call: _e.mock.On("Set", v)}
73-}
74-
75-func (_c *mockcursorPlane_Set_Call) Run(run func(v *modeCursor2)) *mockcursorPlane_Set_Call {
76- _c.Call.Run(func(args mock.Arguments) {
77- var arg0 *modeCursor2
78- if args[0] != nil {
79- arg0 = args[0].(*modeCursor2)
80- }
81- run(
82- arg0,
83- )
84- })
85- return _c
86-}
87-
88-func (_c *mockcursorPlane_Set_Call) Return(err error) *mockcursorPlane_Set_Call {
89- _c.Call.Return(err)
90- return _c
91-}
92-
93-func (_c *mockcursorPlane_Set_Call) RunAndReturn(run func(v *modeCursor2) error) *mockcursorPlane_Set_Call {
94- _c.Call.Return(run)
95- return _c
96-}
97-
98-// WaitVblank provides a mock function for the type mockcursorPlane
99-func (_mock *mockcursorPlane) WaitVblank() error {
100- ret := _mock.Called()
101-
102- if len(ret) == 0 {
103- panic("no return value specified for WaitVblank")
104- }
105-
106- var r0 error
107- if returnFunc, ok := ret.Get(0).(func() error); ok {
108- r0 = returnFunc()
109- } else {
110- r0 = ret.Error(0)
111- }
112- return r0
113-}
114-
115-// mockcursorPlane_WaitVblank_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaitVblank'
116-type mockcursorPlane_WaitVblank_Call struct {
117- *mock.Call
118-}
119-
120-// WaitVblank is a helper method to define mock.On call
121-func (_e *mockcursorPlane_Expecter) WaitVblank() *mockcursorPlane_WaitVblank_Call {
122- return &mockcursorPlane_WaitVblank_Call{Call: _e.mock.On("WaitVblank")}
123-}
124-
125-func (_c *mockcursorPlane_WaitVblank_Call) Run(run func()) *mockcursorPlane_WaitVblank_Call {
126- _c.Call.Run(func(args mock.Arguments) {
127- run()
128- })
129- return _c
130-}
131-
132-func (_c *mockcursorPlane_WaitVblank_Call) Return(err error) *mockcursorPlane_WaitVblank_Call {
133- _c.Call.Return(err)
134- return _c
135-}
136-
137-func (_c *mockcursorPlane_WaitVblank_Call) RunAndReturn(run func() error) *mockcursorPlane_WaitVblank_Call {
138- _c.Call.Return(run)
139- return _c
140-}
D internal/adapters/drm/cursor_test.go +0 -195
@@ -1,195 +0,0 @@
1-package drm
2-
3-import (
4- "sync"
5- "testing"
6- "time"
7- "unsafe"
8-
9- "github.com/stretchr/testify/mock"
10-)
11-
12-// The ioctl numbers encode the struct size; they must match the kernel ABI.
13-func TestCursorABI(t *testing.T) {
14- if s := unsafe.Sizeof(modeCursor2{}); s != 36 || ioctlCursor2>>16&0x3fff != 36 {
15- t.Fatalf("drm_mode_cursor2 size %d", s)
16- }
17- if s := unsafe.Sizeof(getCap{}); s != 16 || ioctlGetCap>>16&0x3fff != 16 {
18- t.Fatalf("drm_get_cap size %d", s)
19- }
20- if s := unsafe.Sizeof(waitVblank{}); s != 24 || ioctlVblank>>16&0x3fff != 24 {
21- t.Fatalf("drm_wait_vblank size %d", s)
22- }
23-}
24-
25-// testCursor returns a shown cursor on a mocked plane; the worker is started.
26-func testCursor(t *testing.T) (*Cursor, *mockcursorPlane) {
27- plane := newMockcursorPlane(t)
28- c := &Cursor{fd: -1, buf: &dumbBuffer{}, size: 64, shown: true, plane: plane}
29- return c, plane
30-}
31-
32-// The cursor moves once per vblank, to the latest position: moving it during
33-// scanout tears it into doubled images.
34-func TestCursorMovesOncePerVblank(t *testing.T) {
35- c, plane := testCursor(t)
36- vblank := make(chan struct{})
37- moved := make(chan modeCursor2, 16)
38- plane.EXPECT().WaitVblank().RunAndReturn(func() error { <-vblank; return nil })
39- plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { moved <- *v; return nil })
40- c.start()
41- defer func() { close(vblank); c.close() }()
42- for i := range 100 {
43- c.Move(float64(i), 0)
44- }
45- select {
46- case v := <-moved:
47- t.Fatalf("moved to %d before vblank", v.x)
48- case <-time.After(20 * time.Millisecond):
49- }
50- vblank <- struct{}{}
51- if v := <-moved; v.x != 99 || v.flags != cursorMove {
52- t.Fatalf("applied %+v, want a move to the latest x=99", v)
53- }
54- select {
55- case v := <-moved:
56- t.Fatalf("extra move to %d without new input", v.x)
57- case <-time.After(20 * time.Millisecond):
58- }
59-}
60-
61-// Move must never wait for the plane, or libinput stops reading and the
62-// kernel drops mouse reports. Moves made meanwhile are coalesced.
63-func TestCursorMoveDoesNotBlockOnPlane(t *testing.T) {
64- c, plane := testCursor(t)
65- c.hotX, c.hotY = 3, 4
66- started, release := make(chan struct{}), make(chan struct{})
67- var once sync.Once
68- moved := make(chan modeCursor2, 1000)
69- plane.EXPECT().WaitVblank().RunAndReturn(func() error {
70- once.Do(func() { close(started) })
71- <-release
72- return nil
73- })
74- plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { moved <- *v; return nil })
75- c.start()
76- defer c.close()
77- c.Move(0, 0)
78- <-started // the worker is now held in WaitVblank
79- done := make(chan struct{})
80- go func() {
81- for i := range 1000 {
82- c.Move(float64(i), float64(2*i))
83- }
84- close(done)
85- }()
86- select {
87- case <-done:
88- case <-time.After(2 * time.Second):
89- t.Fatal("Move blocks while the plane waits for vblank")
90- }
91- close(release)
92- for n := 1; ; n++ {
93- v := <-moved
94- if v.flags != cursorMove {
95- t.Fatalf("flags %#x", v.flags)
96- }
97- if v.x == 999-3 && v.y == 1998-4 {
98- if n >= 1000 {
99- t.Fatalf("%d moves applied; want coalesced", n)
100- }
101- return
102- }
103- }
104-}
105-
106-func TestCursorCloseStopsWorker(t *testing.T) {
107- c, plane := testCursor(t)
108- plane.EXPECT().WaitVblank().Return(nil).Maybe()
109- plane.EXPECT().Set(mock.Anything).Return(nil)
110- c.start()
111- c.Move(1, 2)
112- c.close()
113- c.close() // idempotent
114- c.Move(3, 4) // after close: no worker, must not block or panic
115-}
116-
117-func TestCursorStats(t *testing.T) {
118- c, plane := testCursor(t)
119- applied := make(chan struct{}, 16)
120- plane.EXPECT().WaitVblank().Return(nil)
121- plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error {
122- if v.flags == cursorMove {
123- applied <- struct{}{}
124- }
125- return nil
126- })
127- c.start()
128- defer c.close()
129- c.Move(1, 1)
130- <-applied
131- c.Move(2, 2)
132- <-applied
133- // Set signals before the worker counts the ioctl.
134- s := c.TakeStats()
135- if s.Moves != 2 || s.Ioctls < 1 || s.Ioctls > 2 {
136- t.Fatalf("stats %+v", s)
137- }
138- if s := c.TakeStats(); s.Moves != 0 {
139- t.Fatalf("not reset: %+v", s)
140- }
141-}
142-
143-// A move still waiting for its vblank when the pointer leaves the output
144-// must not show the cursor again.
145-func TestCursorHideDropsQueuedMove(t *testing.T) {
146- c, plane := testCursor(t)
147- vblank := make(chan struct{})
148- set := make(chan modeCursor2, 16)
149- plane.EXPECT().WaitVblank().RunAndReturn(func() error { <-vblank; return nil })
150- plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { set <- *v; return nil })
151- c.start()
152- defer func() { close(vblank); c.close() }()
153- c.Move(10, 10)
154- c.Hide()
155- if v := <-set; v.handle != 0 || v.flags != cursorBO {
156- t.Fatalf("hide: %+v", v)
157- }
158- vblank <- struct{}{}
159- select {
160- case v := <-set:
161- t.Fatalf("queued move applied after hide: %+v", v)
162- case <-time.After(20 * time.Millisecond):
163- }
164- // The next move shows it again.
165- c.Move(20, 20)
166- vblank <- struct{}{}
167- if v := <-set; v.flags != cursorBO|cursorMove || v.x != 20 {
168- t.Fatalf("show: %+v", v)
169- }
170-}
171-
172-// An empty image takes the buffer off the plane; the next image shows it.
173-func TestCursorEmptyImageHides(t *testing.T) {
174- c, plane := testCursor(t)
175- c.buf.mem = make([]byte, 64*64*4)
176- c.buf.pitch = 64 * 4
177- var sets []modeCursor2
178- plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { sets = append(sets, *v); return nil })
179- if err := c.SetImage(nil, 0, 0, 0, 0); err != nil {
180- t.Fatal(err)
181- }
182- if len(sets) != 1 || sets[0].flags != cursorBO || sets[0].handle != 0 || c.shown {
183- t.Fatalf("hide: %+v shown=%v", sets, c.shown)
184- }
185- // Still hidden: no second ioctl.
186- if err := c.SetImage(nil, 0, 0, 0, 0); err != nil || len(sets) != 1 {
187- t.Fatalf("second hide: %+v %v", sets, err)
188- }
189- if err := c.SetImage(make([]byte, 4), 1, 1, 0, 0); err != nil {
190- t.Fatal(err)
191- }
192- if len(sets) != 2 || sets[1].flags != cursorBO|cursorMove || !c.shown {
193- t.Fatalf("show: %+v", sets)
194- }
195-}
M internal/adapters/drm/kms.go +1 -109
@@ -1,8 +1,7 @@
1-// Package drm drives KMS outputs with raw ioctls and dumb buffers.
1+// Package drm drives KMS outputs with raw ioctls and atomic commits.
22 package drm
33
44 import (
5- "encoding/binary"
65 "fmt"
76 "math"
87 "unsafe"
@@ -14,18 +13,10 @@ import (
1413 const (
1514 ioctlGetResources = 0xC04064A0
1615 ioctlGetCrtc = 0xC06864A1
17- ioctlSetCrtc = 0xC06864A2
1816 ioctlGetEncoder = 0xC01464A6
1917 ioctlGetConnector = 0xC05064A7
20- ioctlAddFB = 0xC01C64AE
2118 ioctlRmFB = 0xC00464AF
22- ioctlPageFlip = 0xC01864B0
23- ioctlCreateDumb = 0xC02064B2
24- ioctlMapDumb = 0xC01064B3
25- ioctlDestroyDumb = 0xC00464B4
2619
27- pageFlipEvent = 1
28- pageFlipAsync = 2
2920 eventFlipDone = 2
3021 modeTypePrefered = 1 << 3
3122 connected = 1
@@ -72,24 +63,6 @@ type modeCrtc struct {
7263 mode modeInfo
7364 }
7465
75-type pageFlip struct {
76- crtcID, fbID, flags, reserved uint32
77- userData uint64
78-}
79-
80-type createDumb struct {
81- height, width, bpp, flags uint32
82- handle, pitch uint32
83- size uint64
84-}
85-
86-type mapDumb struct {
87- handle, pad uint32
88- offset uint64
89-}
90-
91-type fbCmd struct{ fbID, width, height, pitch, bpp, depth, handle uint32 }
92-
9366 func ioctl(fd int, req uintptr, arg unsafe.Pointer) error {
9467 for {
9568 _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), req, uintptr(arg))
@@ -261,89 +234,8 @@ func pickCrtc(fd int, c connector, crtcs []uint32, used map[uint32]bool) (uint32
261234 return 0, fmt.Errorf("no free CRTC for %s", c.name)
262235 }
263236
264-type dumbBuffer struct {
265- handle, fbID, pitch uint32
266- mem []byte
267-}
268-
269-func newDumb(fd int, w, h int) (*dumbBuffer, error) {
270- c := createDumb{width: uint32(w), height: uint32(h), bpp: 32}
271- if err := ioctl(fd, ioctlCreateDumb, unsafe.Pointer(&c)); err != nil {
272- return nil, fmt.Errorf("create dumb: %w", err)
273- }
274- b := &dumbBuffer{handle: c.handle, pitch: c.pitch}
275- f := fbCmd{width: uint32(w), height: uint32(h), pitch: c.pitch, bpp: 32, depth: 24, handle: c.handle}
276- if err := ioctl(fd, ioctlAddFB, unsafe.Pointer(&f)); err != nil {
277- b.destroy(fd)
278- return nil, fmt.Errorf("add fb: %w", err)
279- }
280- b.fbID = f.fbID
281- m := mapDumb{handle: c.handle}
282- if err := ioctl(fd, ioctlMapDumb, unsafe.Pointer(&m)); err != nil {
283- b.destroy(fd)
284- return nil, fmt.Errorf("map dumb: %w", err)
285- }
286- mem, err := unix.Mmap(fd, int64(m.offset), int(c.size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
287- if err != nil {
288- b.destroy(fd)
289- return nil, fmt.Errorf("mmap dumb: %w", err)
290- }
291- b.mem = mem
292- return b, nil
293-}
294-
295-func (b *dumbBuffer) destroy(fd int) {
296- if b.mem != nil {
297- _ = unix.Munmap(b.mem)
298- b.mem = nil
299- }
300- if b.fbID != 0 {
301- id := b.fbID
302- _ = ioctl(fd, ioctlRmFB, unsafe.Pointer(&id))
303- b.fbID = 0
304- }
305- h := b.handle
306- _ = ioctl(fd, ioctlDestroyDumb, unsafe.Pointer(&h))
307-}
308-
309-func setCrtc(fd int, crtc, conn, fb uint32, mode *modeInfo) error {
310- ids := []uint32{conn}
311- c := modeCrtc{crtcID: crtc, fbID: fb, countConnectors: 1, setConnectors: unsafe.Pointer(&ids[0])}
312- if mode != nil {
313- c.mode, c.modeValid = *mode, 1
314- }
315- return ioctl(fd, ioctlSetCrtc, unsafe.Pointer(&c))
316-}
317-
318237 func getCrtc(fd int, crtc uint32) (modeCrtc, error) {
319238 c := modeCrtc{crtcID: crtc}
320239 err := ioctl(fd, ioctlGetCrtc, unsafe.Pointer(&c))
321240 return c, err
322241 }
323-
324-func flip(fd int, crtc, fb uint32, async bool) error {
325- p := pageFlip{crtcID: crtc, fbID: fb, flags: pageFlipEvent}
326- if async {
327- p.flags |= pageFlipAsync
328- }
329- return ioctl(fd, ioctlPageFlip, unsafe.Pointer(&p))
330-}
331-
332-// flipCrtcs parses DRM events read from the card fd and returns the CRTC of
333-// each completed page flip.
334-func flipCrtcs(buf []byte) []uint32 {
335- var out []uint32
336- for len(buf) >= 8 {
337- typ := binary.LittleEndian.Uint32(buf)
338- length := int(binary.LittleEndian.Uint32(buf[4:]))
339- if length < 8 || length > len(buf) {
340- break
341- }
342- // struct drm_event_vblank: base(8) user_data(8) sec usec sequence crtc_id.
343- if typ == eventFlipDone && length >= 32 {
344- out = append(out, binary.LittleEndian.Uint32(buf[28:]))
345- }
346- buf = buf[length:]
347- }
348- return out
349-}
A internal/adapters/drm/kms_mock_test.go +522 -0
@@ -0,0 +1,522 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package drm
6+
7+import (
8+ "github.com/bnema/nefertty/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// newMockkms creates a new instance of mockkms. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func newMockkms(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *mockkms {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &mockkms{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// mockkms is an autogenerated mock type for the kms type
36+type mockkms struct {
37+ mock.Mock
38+}
39+
40+type mockkms_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *mockkms) EXPECT() *mockkms_Expecter {
45+ return &mockkms_Expecter{mock: &_m.Mock}
46+}
47+
48+// addFB provides a mock function for the type mockkms
49+func (_mock *mockkms) addFB(b *ports.DMABuf, format uint32) (uint32, error) {
50+ ret := _mock.Called(b, format)
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for addFB")
54+ }
55+
56+ var r0 uint32
57+ var r1 error
58+ if returnFunc, ok := ret.Get(0).(func(*ports.DMABuf, uint32) (uint32, error)); ok {
59+ return returnFunc(b, format)
60+ }
61+ if returnFunc, ok := ret.Get(0).(func(*ports.DMABuf, uint32) uint32); ok {
62+ r0 = returnFunc(b, format)
63+ } else {
64+ r0 = ret.Get(0).(uint32)
65+ }
66+ if returnFunc, ok := ret.Get(1).(func(*ports.DMABuf, uint32) error); ok {
67+ r1 = returnFunc(b, format)
68+ } else {
69+ r1 = ret.Error(1)
70+ }
71+ return r0, r1
72+}
73+
74+// mockkms_addFB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'addFB'
75+type mockkms_addFB_Call struct {
76+ *mock.Call
77+}
78+
79+// addFB is a helper method to define mock.On call
80+// - b *ports.DMABuf
81+// - format uint32
82+func (_e *mockkms_Expecter) addFB(b any, format any) *mockkms_addFB_Call {
83+ return &mockkms_addFB_Call{Call: _e.mock.On("addFB", b, format)}
84+}
85+
86+func (_c *mockkms_addFB_Call) Run(run func(b *ports.DMABuf, format uint32)) *mockkms_addFB_Call {
87+ _c.Call.Run(func(args mock.Arguments) {
88+ var arg0 *ports.DMABuf
89+ if args[0] != nil {
90+ arg0 = args[0].(*ports.DMABuf)
91+ }
92+ var arg1 uint32
93+ if args[1] != nil {
94+ arg1 = args[1].(uint32)
95+ }
96+ run(
97+ arg0,
98+ arg1,
99+ )
100+ })
101+ return _c
102+}
103+
104+func (_c *mockkms_addFB_Call) Return(v uint32, err error) *mockkms_addFB_Call {
105+ _c.Call.Return(v, err)
106+ return _c
107+}
108+
109+func (_c *mockkms_addFB_Call) RunAndReturn(run func(b *ports.DMABuf, format uint32) (uint32, error)) *mockkms_addFB_Call {
110+ _c.Call.Return(run)
111+ return _c
112+}
113+
114+// commit provides a mock function for the type mockkms
115+func (_mock *mockkms) commit(req *atomicReq, flags uint32, userData uint64) error {
116+ ret := _mock.Called(req, flags, userData)
117+
118+ if len(ret) == 0 {
119+ panic("no return value specified for commit")
120+ }
121+
122+ var r0 error
123+ if returnFunc, ok := ret.Get(0).(func(*atomicReq, uint32, uint64) error); ok {
124+ r0 = returnFunc(req, flags, userData)
125+ } else {
126+ r0 = ret.Error(0)
127+ }
128+ return r0
129+}
130+
131+// mockkms_commit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'commit'
132+type mockkms_commit_Call struct {
133+ *mock.Call
134+}
135+
136+// commit is a helper method to define mock.On call
137+// - req *atomicReq
138+// - flags uint32
139+// - userData uint64
140+func (_e *mockkms_Expecter) commit(req any, flags any, userData any) *mockkms_commit_Call {
141+ return &mockkms_commit_Call{Call: _e.mock.On("commit", req, flags, userData)}
142+}
143+
144+func (_c *mockkms_commit_Call) Run(run func(req *atomicReq, flags uint32, userData uint64)) *mockkms_commit_Call {
145+ _c.Call.Run(func(args mock.Arguments) {
146+ var arg0 *atomicReq
147+ if args[0] != nil {
148+ arg0 = args[0].(*atomicReq)
149+ }
150+ var arg1 uint32
151+ if args[1] != nil {
152+ arg1 = args[1].(uint32)
153+ }
154+ var arg2 uint64
155+ if args[2] != nil {
156+ arg2 = args[2].(uint64)
157+ }
158+ run(
159+ arg0,
160+ arg1,
161+ arg2,
162+ )
163+ })
164+ return _c
165+}
166+
167+func (_c *mockkms_commit_Call) Return(err error) *mockkms_commit_Call {
168+ _c.Call.Return(err)
169+ return _c
170+}
171+
172+func (_c *mockkms_commit_Call) RunAndReturn(run func(req *atomicReq, flags uint32, userData uint64) error) *mockkms_commit_Call {
173+ _c.Call.Return(run)
174+ return _c
175+}
176+
177+// createBlob provides a mock function for the type mockkms
178+func (_mock *mockkms) createBlob(data []byte) (uint32, error) {
179+ ret := _mock.Called(data)
180+
181+ if len(ret) == 0 {
182+ panic("no return value specified for createBlob")
183+ }
184+
185+ var r0 uint32
186+ var r1 error
187+ if returnFunc, ok := ret.Get(0).(func([]byte) (uint32, error)); ok {
188+ return returnFunc(data)
189+ }
190+ if returnFunc, ok := ret.Get(0).(func([]byte) uint32); ok {
191+ r0 = returnFunc(data)
192+ } else {
193+ r0 = ret.Get(0).(uint32)
194+ }
195+ if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
196+ r1 = returnFunc(data)
197+ } else {
198+ r1 = ret.Error(1)
199+ }
200+ return r0, r1
201+}
202+
203+// mockkms_createBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'createBlob'
204+type mockkms_createBlob_Call struct {
205+ *mock.Call
206+}
207+
208+// createBlob is a helper method to define mock.On call
209+// - data []byte
210+func (_e *mockkms_Expecter) createBlob(data any) *mockkms_createBlob_Call {
211+ return &mockkms_createBlob_Call{Call: _e.mock.On("createBlob", data)}
212+}
213+
214+func (_c *mockkms_createBlob_Call) Run(run func(data []byte)) *mockkms_createBlob_Call {
215+ _c.Call.Run(func(args mock.Arguments) {
216+ var arg0 []byte
217+ if args[0] != nil {
218+ arg0 = args[0].([]byte)
219+ }
220+ run(
221+ arg0,
222+ )
223+ })
224+ return _c
225+}
226+
227+func (_c *mockkms_createBlob_Call) Return(v uint32, err error) *mockkms_createBlob_Call {
228+ _c.Call.Return(v, err)
229+ return _c
230+}
231+
232+func (_c *mockkms_createBlob_Call) RunAndReturn(run func(data []byte) (uint32, error)) *mockkms_createBlob_Call {
233+ _c.Call.Return(run)
234+ return _c
235+}
236+
237+// destroyBlob provides a mock function for the type mockkms
238+func (_mock *mockkms) destroyBlob(id uint32) error {
239+ ret := _mock.Called(id)
240+
241+ if len(ret) == 0 {
242+ panic("no return value specified for destroyBlob")
243+ }
244+
245+ var r0 error
246+ if returnFunc, ok := ret.Get(0).(func(uint32) error); ok {
247+ r0 = returnFunc(id)
248+ } else {
249+ r0 = ret.Error(0)
250+ }
251+ return r0
252+}
253+
254+// mockkms_destroyBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'destroyBlob'
255+type mockkms_destroyBlob_Call struct {
256+ *mock.Call
257+}
258+
259+// destroyBlob is a helper method to define mock.On call
260+// - id uint32
261+func (_e *mockkms_Expecter) destroyBlob(id any) *mockkms_destroyBlob_Call {
262+ return &mockkms_destroyBlob_Call{Call: _e.mock.On("destroyBlob", id)}
263+}
264+
265+func (_c *mockkms_destroyBlob_Call) Run(run func(id uint32)) *mockkms_destroyBlob_Call {
266+ _c.Call.Run(func(args mock.Arguments) {
267+ var arg0 uint32
268+ if args[0] != nil {
269+ arg0 = args[0].(uint32)
270+ }
271+ run(
272+ arg0,
273+ )
274+ })
275+ return _c
276+}
277+
278+func (_c *mockkms_destroyBlob_Call) Return(err error) *mockkms_destroyBlob_Call {
279+ _c.Call.Return(err)
280+ return _c
281+}
282+
283+func (_c *mockkms_destroyBlob_Call) RunAndReturn(run func(id uint32) error) *mockkms_destroyBlob_Call {
284+ _c.Call.Return(run)
285+ return _c
286+}
287+
288+// getBlob provides a mock function for the type mockkms
289+func (_mock *mockkms) getBlob(id uint32) ([]byte, error) {
290+ ret := _mock.Called(id)
291+
292+ if len(ret) == 0 {
293+ panic("no return value specified for getBlob")
294+ }
295+
296+ var r0 []byte
297+ var r1 error
298+ if returnFunc, ok := ret.Get(0).(func(uint32) ([]byte, error)); ok {
299+ return returnFunc(id)
300+ }
301+ if returnFunc, ok := ret.Get(0).(func(uint32) []byte); ok {
302+ r0 = returnFunc(id)
303+ } else {
304+ if ret.Get(0) != nil {
305+ r0 = ret.Get(0).([]byte)
306+ }
307+ }
308+ if returnFunc, ok := ret.Get(1).(func(uint32) error); ok {
309+ r1 = returnFunc(id)
310+ } else {
311+ r1 = ret.Error(1)
312+ }
313+ return r0, r1
314+}
315+
316+// mockkms_getBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'getBlob'
317+type mockkms_getBlob_Call struct {
318+ *mock.Call
319+}
320+
321+// getBlob is a helper method to define mock.On call
322+// - id uint32
323+func (_e *mockkms_Expecter) getBlob(id any) *mockkms_getBlob_Call {
324+ return &mockkms_getBlob_Call{Call: _e.mock.On("getBlob", id)}
325+}
326+
327+func (_c *mockkms_getBlob_Call) Run(run func(id uint32)) *mockkms_getBlob_Call {
328+ _c.Call.Run(func(args mock.Arguments) {
329+ var arg0 uint32
330+ if args[0] != nil {
331+ arg0 = args[0].(uint32)
332+ }
333+ run(
334+ arg0,
335+ )
336+ })
337+ return _c
338+}
339+
340+func (_c *mockkms_getBlob_Call) Return(bytes []byte, err error) *mockkms_getBlob_Call {
341+ _c.Call.Return(bytes, err)
342+ return _c
343+}
344+
345+func (_c *mockkms_getBlob_Call) RunAndReturn(run func(id uint32) ([]byte, error)) *mockkms_getBlob_Call {
346+ _c.Call.Return(run)
347+ return _c
348+}
349+
350+// objProps provides a mock function for the type mockkms
351+func (_mock *mockkms) objProps(obj uint32, typ uint32) (map[string][2]uint64, error) {
352+ ret := _mock.Called(obj, typ)
353+
354+ if len(ret) == 0 {
355+ panic("no return value specified for objProps")
356+ }
357+
358+ var r0 map[string][2]uint64
359+ var r1 error
360+ if returnFunc, ok := ret.Get(0).(func(uint32, uint32) (map[string][2]uint64, error)); ok {
361+ return returnFunc(obj, typ)
362+ }
363+ if returnFunc, ok := ret.Get(0).(func(uint32, uint32) map[string][2]uint64); ok {
364+ r0 = returnFunc(obj, typ)
365+ } else {
366+ if ret.Get(0) != nil {
367+ r0 = ret.Get(0).(map[string][2]uint64)
368+ }
369+ }
370+ if returnFunc, ok := ret.Get(1).(func(uint32, uint32) error); ok {
371+ r1 = returnFunc(obj, typ)
372+ } else {
373+ r1 = ret.Error(1)
374+ }
375+ return r0, r1
376+}
377+
378+// mockkms_objProps_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'objProps'
379+type mockkms_objProps_Call struct {
380+ *mock.Call
381+}
382+
383+// objProps is a helper method to define mock.On call
384+// - obj uint32
385+// - typ uint32
386+func (_e *mockkms_Expecter) objProps(obj any, typ any) *mockkms_objProps_Call {
387+ return &mockkms_objProps_Call{Call: _e.mock.On("objProps", obj, typ)}
388+}
389+
390+func (_c *mockkms_objProps_Call) Run(run func(obj uint32, typ uint32)) *mockkms_objProps_Call {
391+ _c.Call.Run(func(args mock.Arguments) {
392+ var arg0 uint32
393+ if args[0] != nil {
394+ arg0 = args[0].(uint32)
395+ }
396+ var arg1 uint32
397+ if args[1] != nil {
398+ arg1 = args[1].(uint32)
399+ }
400+ run(
401+ arg0,
402+ arg1,
403+ )
404+ })
405+ return _c
406+}
407+
408+func (_c *mockkms_objProps_Call) Return(stringToUint64s map[string][2]uint64, err error) *mockkms_objProps_Call {
409+ _c.Call.Return(stringToUint64s, err)
410+ return _c
411+}
412+
413+func (_c *mockkms_objProps_Call) RunAndReturn(run func(obj uint32, typ uint32) (map[string][2]uint64, error)) *mockkms_objProps_Call {
414+ _c.Call.Return(run)
415+ return _c
416+}
417+
418+// planes provides a mock function for the type mockkms
419+func (_mock *mockkms) planes() ([]planeRes, error) {
420+ ret := _mock.Called()
421+
422+ if len(ret) == 0 {
423+ panic("no return value specified for planes")
424+ }
425+
426+ var r0 []planeRes
427+ var r1 error
428+ if returnFunc, ok := ret.Get(0).(func() ([]planeRes, error)); ok {
429+ return returnFunc()
430+ }
431+ if returnFunc, ok := ret.Get(0).(func() []planeRes); ok {
432+ r0 = returnFunc()
433+ } else {
434+ if ret.Get(0) != nil {
435+ r0 = ret.Get(0).([]planeRes)
436+ }
437+ }
438+ if returnFunc, ok := ret.Get(1).(func() error); ok {
439+ r1 = returnFunc()
440+ } else {
441+ r1 = ret.Error(1)
442+ }
443+ return r0, r1
444+}
445+
446+// mockkms_planes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'planes'
447+type mockkms_planes_Call struct {
448+ *mock.Call
449+}
450+
451+// planes is a helper method to define mock.On call
452+func (_e *mockkms_Expecter) planes() *mockkms_planes_Call {
453+ return &mockkms_planes_Call{Call: _e.mock.On("planes")}
454+}
455+
456+func (_c *mockkms_planes_Call) Run(run func()) *mockkms_planes_Call {
457+ _c.Call.Run(func(args mock.Arguments) {
458+ run()
459+ })
460+ return _c
461+}
462+
463+func (_c *mockkms_planes_Call) Return(planeResMoqParams []planeRes, err error) *mockkms_planes_Call {
464+ _c.Call.Return(planeResMoqParams, err)
465+ return _c
466+}
467+
468+func (_c *mockkms_planes_Call) RunAndReturn(run func() ([]planeRes, error)) *mockkms_planes_Call {
469+ _c.Call.Return(run)
470+ return _c
471+}
472+
473+// rmFB provides a mock function for the type mockkms
474+func (_mock *mockkms) rmFB(id uint32) error {
475+ ret := _mock.Called(id)
476+
477+ if len(ret) == 0 {
478+ panic("no return value specified for rmFB")
479+ }
480+
481+ var r0 error
482+ if returnFunc, ok := ret.Get(0).(func(uint32) error); ok {
483+ r0 = returnFunc(id)
484+ } else {
485+ r0 = ret.Error(0)
486+ }
487+ return r0
488+}
489+
490+// mockkms_rmFB_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'rmFB'
491+type mockkms_rmFB_Call struct {
492+ *mock.Call
493+}
494+
495+// rmFB is a helper method to define mock.On call
496+// - id uint32
497+func (_e *mockkms_Expecter) rmFB(id any) *mockkms_rmFB_Call {
498+ return &mockkms_rmFB_Call{Call: _e.mock.On("rmFB", id)}
499+}
500+
501+func (_c *mockkms_rmFB_Call) Run(run func(id uint32)) *mockkms_rmFB_Call {
502+ _c.Call.Run(func(args mock.Arguments) {
503+ var arg0 uint32
504+ if args[0] != nil {
505+ arg0 = args[0].(uint32)
506+ }
507+ run(
508+ arg0,
509+ )
510+ })
511+ return _c
512+}
513+
514+func (_c *mockkms_rmFB_Call) Return(err error) *mockkms_rmFB_Call {
515+ _c.Call.Return(err)
516+ return _c
517+}
518+
519+func (_c *mockkms_rmFB_Call) RunAndReturn(run func(id uint32) error) *mockkms_rmFB_Call {
520+ _c.Call.Return(run)
521+ return _c
522+}
M internal/adapters/drm/kms_test.go +55 -15
@@ -2,8 +2,12 @@ package drm
22
33 import (
44 "encoding/binary"
5+ "slices"
56 "testing"
7+ "time"
68 "unsafe"
9+
10+ "github.com/bnema/nefertty/internal/ports"
711 )
812
913 func TestStructSizesMatchIoctls(t *testing.T) {
@@ -13,19 +17,21 @@ func TestStructSizesMatchIoctls(t *testing.T) {
1317 req uintptr
1418 }{
1519 {"card_res", unsafe.Sizeof(cardRes{}), ioctlGetResources},
16- {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlSetCrtc},
1720 {"get_encoder", unsafe.Sizeof(getEncoder{}), ioctlGetEncoder},
1821 {"get_connector", unsafe.Sizeof(getConnector{}), ioctlGetConnector},
19- {"fb_cmd", unsafe.Sizeof(fbCmd{}), ioctlAddFB},
20- {"page_flip", unsafe.Sizeof(pageFlip{}), ioctlPageFlip},
21- {"create_dumb", unsafe.Sizeof(createDumb{}), ioctlCreateDumb},
22- {"map_dumb", unsafe.Sizeof(mapDumb{}), ioctlMapDumb},
2322 {"fb_cmd2", unsafe.Sizeof(fbCmd2{}), ioctlAddFB2},
2423 {"prime_handle", unsafe.Sizeof(primeHandle{}), ioctlPrimeFDToHandle},
2524 {"gem_close", unsafe.Sizeof(gemClose{}), ioctlGemClose},
2625 {"obj_get_props", unsafe.Sizeof(objGetProps{}), ioctlObjGetProps},
2726 {"get_prop", unsafe.Sizeof(getProp{}), ioctlGetProp},
28- {"obj_set_prop", unsafe.Sizeof(objSetProp{}), ioctlObjSetProp},
27+ {"mode_crtc", unsafe.Sizeof(modeCrtc{}), ioctlGetCrtc},
28+ {"set_client_cap", unsafe.Sizeof(setClientCap{}), ioctlSetClientCap},
29+ {"get_plane_res", unsafe.Sizeof(getPlaneRes{}), ioctlGetPlaneRes},
30+ {"get_plane", unsafe.Sizeof(getPlane{}), ioctlGetPlane},
31+ {"get_blob", unsafe.Sizeof(getBlob{}), ioctlGetPropBlob},
32+ {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},
33+ {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},
34+ {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},
2935 } {
3036 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
3137 t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
@@ -88,19 +94,53 @@ func TestParseEDID(t *testing.T) {
8894 }
8995 }
9096
91-func TestFlipCrtcs(t *testing.T) {
92- ev := func(typ, crtc uint32) []byte {
97+func TestParseFlips(t *testing.T) {
98+ ev := func(typ, crtc uint32, user uint64, sec, usec, seq uint32) []byte {
9399 b := make([]byte, 32)
94- binary.LittleEndian.PutUint32(b, typ)
95- binary.LittleEndian.PutUint32(b[4:], 32)
96- binary.LittleEndian.PutUint32(b[28:], crtc)
100+ le := binary.LittleEndian
101+ le.PutUint32(b, typ)
102+ le.PutUint32(b[4:], 32)
103+ le.PutUint64(b[8:], user)
104+ le.PutUint32(b[16:], sec)
105+ le.PutUint32(b[20:], usec)
106+ le.PutUint32(b[24:], seq)
107+ le.PutUint32(b[28:], crtc)
97108 return b
98109 }
99- buf := append(append(ev(eventFlipDone, 41), ev(1, 7)...), ev(eventFlipDone, 42)...)
100- if got := flipCrtcs(buf); len(got) != 2 || got[0] != 41 || got[1] != 42 {
101- t.Fatalf("got %v", got)
110+ buf := append(append(ev(eventFlipDone, 41, userFrame, 3, 250, 7), ev(1, 7, 0, 0, 0, 0)...), ev(eventFlipDone, 42, userState, 0, 1, 8)...)
111+ got := parseFlips(buf)
112+ want := []flipEvent{{crtc: 41, user: userFrame, when: 3*time.Second + 250*time.Microsecond, seq: 7}, {crtc: 42, user: userState, when: time.Microsecond, seq: 8}}
113+ if !slices.Equal(got, want) {
114+ t.Fatalf("got %+v, want %+v", got, want)
102115 }
103- if got := flipCrtcs(buf[:20]); len(got) != 0 {
116+ if got := parseFlips(buf[:20]); len(got) != 0 {
104117 t.Fatalf("truncated: got %v", got)
105118 }
106119 }
120+
121+func TestParseInFormats(t *testing.T) {
122+ le := binary.LittleEndian
123+ // 3 formats at 24, 2 modifiers at 40.
124+ b := make([]byte, 40+2*24)
125+ le.PutUint32(b, 1)
126+ le.PutUint32(b[8:], 3)
127+ le.PutUint32(b[12:], 24)
128+ le.PutUint32(b[16:], 2)
129+ le.PutUint32(b[20:], 40)
130+ for i, f := range []uint32{fourccXRGB, fourccARGB, 0x3432564e} {
131+ le.PutUint32(b[24+i*4:], f)
132+ }
133+ // linear on formats 0 and 2, a tiled modifier on format 1.
134+ le.PutUint64(b[40:], 0b101)
135+ le.PutUint64(b[56:], 0)
136+ le.PutUint64(b[64:], 0b010)
137+ le.PutUint64(b[80:], 0x0200000000000001)
138+ got := parseInFormats(b)
139+ want := []ports.DMABufFormat{{Format: fourccXRGB}, {Format: 0x3432564e}, {Format: fourccARGB, Modifier: 0x0200000000000001}}
140+ if !slices.Equal(got, want) {
141+ t.Fatalf("got %+v, want %+v", got, want)
142+ }
143+ if parseInFormats(b[:30]) != nil {
144+ t.Fatal("truncated blob parsed")
145+ }
146+}
M internal/adapters/drm/output.go +570 -227
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "maps"
8+ "os"
89 "slices"
910 "time"
1011 "unsafe"
@@ -14,73 +15,179 @@ import (
1415 "golang.org/x/sys/unix"
1516 )
1617
17-// Output is one KMS connector flipping between two images. One goroutine
18-// runs it (Run) and closes it.
18+// Output is one KMS connector on a CRTC, driven by atomic commits (ADR
19+// 015). One goroutine runs it (Run) and closes it. Every commit that
20+// changes the screen asks for an event and leaves the output pending until
21+// it arrives: the next commit waits for it, so none fails with EBUSY.
1922 type Output struct {
20- fd int
21- flipped <-chan int // page flips of this CRTC, from Card.ReadEvents
23+ k kms
24+ flipped <-chan flipEvent // commit events of this CRTC, from Card.ReadEvents
2225 crtc uint32
2326 conn connector
2427 mode modeInfo
2528 saved modeCrtc
26- // fbs are the two images frames alternate between, back the one the
27- // next frame draws into. They are renderer images exported as dmabufs
28- // (ADR 014: zero copy), or dumb buffers filled by the CPU when the GPU
29- // cannot export a scanout-capable image (logged fallback).
30- fbs [2]uint32
31- bufs [2]*dumbBuffer // fallback only
32- back int
33- pending bool
34- flipStart time.Time
35- flips int
36- monitor Monitor
37- cursor *Cursor
38- log zerowrap.Logger
39- // Direct scanout: client framebuffers by DMABuf ID, GEM handle counts,
40- // and the buffer on screen and queued (0: the composed image).
41- card *Card
29+ monitor Monitor
30+ log zerowrap.Logger
31+ // Properties: CRTC and connector property IDs by name.
32+ crtcProps map[string]uint32
33+ connCrtc uint32 // the connector's CRTC_ID property
34+ modeBlob uint32
35+ // primary shows frames; cursor is the cursor plane (nil: none);
36+ // stray are other planes that can show on this CRTC, turned off at
37+ // every modeset when a previous master left them on it.
38+ primary *plane
39+ cursor *Cursor
40+ stray []*plane
41+ // fbs are the two renderer images frames alternate between, back the
42+ // one the next frame draws into (ADR 014: zero copy).
43+ fbs [2]uint32
44+ back int
45+ // pending: a commit waits for its event; pendingFrame when it flips a
46+ // new frame (else it only changed the cursor or VRR).
47+ pending bool
48+ pendingFrame pendingFrame
49+ flipStart time.Time
50+ flips int
51+ // Direct scanout: client framebuffers by DMABuf ID and the buffer on
52+ // screen and queued (0: the composed image).
4253 scanout bool
4354 clientFBs map[uint64]*clientFB
4455 shown, queued uint64
4556 reason string // why the last frame was composed ("" = scanout)
46- unsent ports.OutputPresented
47- // Tearing (ADR 006): async flips of scanned-out buffers that ask for
48- // them, while the card accepts them. vrrProp is the CRTC's VRR_ENABLED
49- // property (0: no VRR), turned on while a buffer is scanned out.
50- tearing bool
51- vrrProp uint32
52- vrrOn, async bool
53- // composedSince is when composition took over from scanout.
57+ unsent []ports.OutputPresented
58+ // Tearing (ADR 006): async commits of scanned-out buffers that ask for
59+ // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence
60+ // is set when an async commit may carry IN_FENCE_FD (probed once).
61+ tearing bool
62+ asyncFence bool
63+ asyncProbed bool
64+ async bool
65+ // VRR: the CRTC's VRR_ENABLED property (0: none), on while a buffer is
66+ // scanned out, set inside frame commits.
67+ vrrProp uint32
68+ vrrOn bool
5469 composedSince time.Time
5570 // kind is how the images were made; validated once a modeset took them.
5671 kind imageKind
5772 validated bool
5873 }
5974
75+// pendingFrame is what the pending frame commit shows.
76+type pendingFrame struct {
77+ frame bool
78+ queued uint64
79+ zeroCopy, async bool
80+ shows map[ports.WindowID]uint64
81+}
82+
83+// Commit event userData.
84+const (
85+ userFrame = 1
86+ userState = 2
87+)
88+
6089 // CursorLoader returns the image of a cursor at an output scale, at most
6190 // limit pixels on a side; an empty image hides the cursor.
6291 type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
6392
64-// newOutput prepares the cursor and saves the CRTC for a connector on crtc.
93+// newOutput reads the CRTC's planes and properties for a connector on crtc.
6594 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
6695 log := card.log
67- o := &Output{fd: card.fd, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), card: card, scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}
96+ pipe := slices.Index(card.crtcs, crtc)
97+ o := &Output{k: card.k, flipped: card.flips[crtc], crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}
6898 var err error
6999 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
70100 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
71101 }
102+ if err := o.readProps(pipe, card.taken, cursorSize(card.fd)); err != nil {
103+ return nil, err
104+ }
72105 o.tearing = card.async && !card.want.NoTearing
73106 if !card.want.NoVRR {
74- o.vrrProp = vrrProperty(card.fd, c.id, crtc)
107+ o.vrrProp = vrrProperty(o.k, c.id, crtc)
75108 }
76- if o.cursor, err = newCursor(card.fd, crtc, slices.Index(card.crtcs, crtc)); err != nil {
77- log.Warn().Err(err).Str("connector", c.name).Msg("no hardware cursor")
78- }
79- log.Info().Str("card", card.path).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Bool("tearing", o.tearing).Bool("vrr", o.vrrProp != 0).Msg("output")
109+ log.Info().Str("card", card.path).Str("connector", c.name).Str("mode", mode.String()).Str("make", o.monitor.Make).Str("model", o.monitor.Model).Uint32("crtc", crtc).Uint32("primary", o.primary.id).Bool("cursor", o.cursor != nil).Bool("tearing", o.tearing).Bool("vrr", o.vrrProp != 0).Msg("output")
80110 return o, nil
81111 }
82112
83-// Cursor is the hardware cursor, or nil when the driver has none.
113+// readProps finds the output's planes and property IDs. taken are planes
114+// other outputs drive.
115+func (o *Output) readProps(pipe int, taken map[uint32]bool, cursorSide int) error {
116+ cp, err := o.k.objProps(o.crtc, objCrtc)
117+ if err != nil {
118+ return fmt.Errorf("crtc properties: %w", err)
119+ }
120+ o.crtcProps = map[string]uint32{}
121+ for name, v := range cp {
122+ o.crtcProps[name] = uint32(v[0])
123+ }
124+ np, err := o.k.objProps(o.conn.id, objConnector)
125+ if err != nil {
126+ return fmt.Errorf("connector properties: %w", err)
127+ }
128+ o.connCrtc = uint32(np["CRTC_ID"][0])
129+ if o.crtcProps["MODE_ID"] == 0 || o.crtcProps["ACTIVE"] == 0 || o.connCrtc == 0 {
130+ return errors.New("crtc or connector lacks atomic properties")
131+ }
132+ planes, err := readPlanes(o.k, pipe)
133+ if err != nil {
134+ return fmt.Errorf("planes: %w", err)
135+ }
136+ pick := func(typ uint32) *plane {
137+ var best *plane
138+ for _, p := range planes {
139+ if p.typ != typ || taken[p.id] {
140+ continue
141+ }
142+ // Prefer the plane already on this CRTC.
143+ if best == nil || p.crtc == o.crtc && best.crtc != o.crtc {
144+ best = p
145+ }
146+ }
147+ return best
148+ }
149+ if o.primary = pick(planePrimary); o.primary == nil {
150+ return errors.New("no free primary plane")
151+ }
152+ if p := pick(planeCursor); p != nil {
153+ o.cursor = newCursor(p, cursorSide)
154+ }
155+ for _, p := range planes {
156+ if p != o.primary && (o.cursor == nil || p != o.cursor.plane) && !taken[p.id] {
157+ o.stray = append(o.stray, p)
158+ }
159+ }
160+ return nil
161+}
162+
163+// owned are the planes this output drives.
164+func (o *Output) owned() []*plane {
165+ out := []*plane{o.primary}
166+ if o.cursor != nil {
167+ out = append(out, o.cursor.plane)
168+ }
169+ return out
170+}
171+
172+// cursorSize is the cursor plane side: the smaller of the driver's width
173+// and height caps, 64 when it does not say.
174+func cursorSize(fd int) int {
175+ size := 0
176+ for _, c := range []uint64{capCursorW, capCursorH} {
177+ v := getCap{capability: c}
178+ if ioctl(fd, ioctlGetCap, unsafe.Pointer(&v)) == nil && v.value >= 16 && v.value <= 512 {
179+ if size == 0 || int(v.value) < size {
180+ size = int(v.value)
181+ }
182+ }
183+ }
184+ if size == 0 {
185+ size = 64
186+ }
187+ return size
188+}
189+
190+// Cursor is the hardware cursor, or nil when the CRTC has no cursor plane.
84191 func (o *Output) Cursor() *Cursor { return o.cursor }
85192
86193 // Info describes the output for core and clients. Unknown EDID fields are empty.
@@ -97,52 +204,215 @@ func (o *Output) Info() ports.OutputInfo {
97204 func (o *Output) Width() int { return int(o.mode.HDisplay) }
98205 func (o *Output) Height() int { return int(o.mode.VDisplay) }
99206
100-// modeset shows the front buffer; needed at start and after every VT resume.
207+// primaryProps shows fb full screen on the primary plane.
208+func (o *Output) primaryProps(req *atomicReq, fb uint32) {
209+ p := o.primary
210+ w, h := uint64(o.Width()), uint64(o.Height())
211+ req.set(p.id, p.prop("FB_ID"), uint64(fb))
212+ req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
213+ req.set(p.id, p.prop("SRC_X"), 0)
214+ req.set(p.id, p.prop("SRC_Y"), 0)
215+ req.set(p.id, p.prop("SRC_W"), w<<16)
216+ req.set(p.id, p.prop("SRC_H"), h<<16)
217+ req.set(p.id, p.prop("CRTC_X"), 0)
218+ req.set(p.id, p.prop("CRTC_Y"), 0)
219+ req.set(p.id, p.prop("CRTC_W"), w)
220+ req.set(p.id, p.prop("CRTC_H"), h)
221+}
222+
223+// modeset shows the front image with the output's mode, turning off every
224+// other plane on the CRTC; needed at start and after every VT resume.
225+// It blocks until the kernel applied it.
101226 func (o *Output) modeset() error {
102227 o.pending = false
228+ o.pendingFrame = pendingFrame{}
103229 o.shown, o.queued = 0, 0
104- if err := setCrtc(o.fd, o.crtc, o.conn.id, o.fbs[1-o.back], &o.mode); err != nil {
105- return fmt.Errorf("set crtc: %w", err)
230+ mode := o.mode
231+ blob, err := o.k.createBlob(unsafe.Slice((*byte)(unsafe.Pointer(&mode)), unsafe.Sizeof(mode)))
232+ if err != nil {
233+ return fmt.Errorf("mode blob: %w", err)
234+ }
235+ req := &atomicReq{}
236+ req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
237+ req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
238+ req.set(o.crtc, o.vrrProp, 0)
239+ req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
240+ o.primaryProps(req, o.fbs[1-o.back])
241+ if o.cursor != nil {
242+ o.cursor.props(req, o.crtc, cursorState{})
243+ }
244+ for _, p := range o.stray {
245+ // Only planes a previous master left on this CRTC.
246+ props, err := o.k.objProps(p.id, objPlane)
247+ if err != nil || uint32(props["CRTC_ID"][1]) != o.crtc {
248+ continue
249+ }
250+ req.set(p.id, p.prop("FB_ID"), 0)
251+ req.set(p.id, p.prop("CRTC_ID"), 0)
252+ }
253+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
254+ _ = o.k.destroyBlob(blob)
255+ return fmt.Errorf("modeset: %w", err)
256+ }
257+ if o.modeBlob != 0 {
258+ _ = o.k.destroyBlob(o.modeBlob)
259+ }
260+ o.modeBlob = blob
261+ o.vrrOn = false
262+ if o.cursor != nil {
263+ o.cursor.applied = cursorState{}
106264 }
107265 o.log.Info().Str("connector", o.conn.name).Msg("modeset")
108- // Another master may have left VRR on: start from off.
109- o.vrrOn = true
110- o.setVRR(false)
111266 return nil
112267 }
113268
114-// present queues a page flip to the frame just drawn into the back image
115-// (copied into it by the CPU on the dumb-buffer fallback).
116-func (o *Output) present(r ports.Renderer) error {
117- if b := o.bufs[o.back]; b != nil {
118- r.CopyBGRX(b.mem, int(b.pitch))
269+// probeAsync checks once whether async commits may carry IN_FENCE_FD.
270+func (o *Output) probeAsync(r ports.Renderer) {
271+ if o.asyncProbed || !o.tearing {
272+ return
273+ }
274+ o.asyncProbed = true
275+ r.UseTarget(o.back)
276+ done, err := r.Render(ports.Scene{Background: "#000000"}, nil)
277+ if err != nil {
278+ return
279+ }
280+ req := &atomicReq{}
281+ req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(o.fbs[o.back]))
282+ if done != nil {
283+ defer done.Close()
284+ req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(done.Fd()))
285+ }
286+ err = o.k.commit(req, atomicTestOnly|flipAsyncFlag, 0)
287+ o.asyncFence = err == nil && done != nil
288+ o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe")
289+}
290+
291+// wantVRR is the VRR state the next frame commit sets: on while a buffer
292+// is scanned out, off after composing for vrrHold (each toggle may
293+// flicker, so short compositions keep it).
294+func (o *Output) wantVRR(direct bool) bool {
295+ if o.vrrProp == 0 {
296+ return false
297+ }
298+ if direct {
299+ o.composedSince = time.Time{}
300+ return true
119301 }
120- // VRR goes off only after composing for vrrHold: each toggle is a
121- // blocking commit and may flicker, so short compositions keep it.
122302 if o.composedSince.IsZero() {
123303 o.composedSince = time.Now()
124- } else if time.Since(o.composedSince) > vrrHold {
125- o.setVRR(false)
126304 }
127- o.setAsync(false)
128- if err := flip(o.fd, o.crtc, o.fbs[o.back], false); err != nil {
305+ return o.vrrOn && time.Since(o.composedSince) <= vrrHold
306+}
307+
308+// commitFrame flips fb in one commit with the cursor and VRR. An async
309+// commit carries only the primary plane (kernel rule): a frame that must
310+// also move the cursor or change VRR flips at vblank. fence is the
311+// frame's GPU fence (nil: none); the caller keeps and closes it.
312+func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f pendingFrame) error {
313+ cur := cursorState{}
314+ if o.cursor != nil {
315+ cur = o.cursor.desired()
316+ }
317+ if async && (vrr != o.vrrOn || o.cursor != nil && cur != o.cursor.applied || fence != nil && !o.asyncFence) {
318+ async = false
319+ }
320+ req := &atomicReq{}
321+ flags := uint32(atomicNonblock | flipEventFlag)
322+ if async {
323+ flags |= flipAsyncFlag
324+ req.set(o.primary.id, o.primary.prop("FB_ID"), uint64(fb))
325+ } else {
326+ o.primaryProps(req, fb)
327+ req.set(o.crtc, o.vrrProp, boolValue(vrr))
328+ if o.cursor != nil {
329+ o.cursor.props(req, o.crtc, cur)
330+ }
331+ }
332+ if fence != nil {
333+ req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
334+ }
335+ // The kernel takes its own reference on the fence.
336+ if err := o.k.commit(req, flags, userFrame); err != nil {
337+ if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
338+ // Retry without turning VRR on: if that passes, the driver
339+ // refuses VRR on this output.
340+ if o.commitFrame(fb, fence, false, false, f) == nil {
341+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled")
342+ o.vrrProp = 0
343+ return nil
344+ }
345+ }
346+ return err
347+ }
348+ if !async {
349+ if o.vrrOn != vrr {
350+ o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
351+ }
352+ o.vrrOn = vrr
353+ if o.cursor != nil {
354+ if cur != o.cursor.applied {
355+ o.cursor.mu.Lock()
356+ o.cursor.commits++
357+ o.cursor.mu.Unlock()
358+ }
359+ o.cursor.applied = cur
360+ }
361+ }
362+ f.frame, f.async = true, async
363+ o.setAsync(async)
364+ o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
365+ return nil
366+}
367+
368+// commitState commits a cursor or VRR change without a new frame.
369+func (o *Output) commitState(vrr bool) error {
370+ req := &atomicReq{}
371+ cur := cursorState{}
372+ if o.cursor != nil {
373+ cur = o.cursor.desired()
374+ if cur != o.cursor.applied {
375+ o.cursor.props(req, o.crtc, cur)
376+ }
377+ }
378+ if vrr != o.vrrOn {
379+ req.set(o.crtc, o.vrrProp, boolValue(vrr))
380+ }
381+ if len(req.objs) == 0 {
382+ return nil
383+ }
384+ if err := o.k.commit(req, atomicNonblock|flipEventFlag, userState); err != nil {
129385 return err
130386 }
131- o.pending, o.flipStart = true, time.Now()
132- o.queued = 0
133- o.back = 1 - o.back
387+ if o.cursor != nil && cur != o.cursor.applied {
388+ o.cursor.applied = cur
389+ o.cursor.mu.Lock()
390+ o.cursor.commits++
391+ o.cursor.mu.Unlock()
392+ }
393+ if vrr != o.vrrOn {
394+ o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
395+ }
396+ o.vrrOn = vrr
397+ o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
134398 return nil
135399 }
136400
137-// tryScanout flips a fullscreen client buffer directly. It reports false,
138-// with the reason logged on change, when the frame must be composed.
139-func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (bool, error) {
401+func boolValue(b bool) uint64 {
402+ if b {
403+ return 1
404+ }
405+ return 0
406+}
407+
408+// scanoutFrame picks a fullscreen client buffer to flip directly. It
409+// returns fb 0, with the reason logged on change, when the frame must be
410+// composed.
411+func (o *Output) scanoutFrame(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) (fb uint32, c ports.SurfaceContent) {
140412 reason := "disabled"
141- var c ports.SurfaceContent
142413 if o.scanout {
143414 c, reason = scanoutCandidate(scene, surfaces, o.Width(), o.Height())
144415 }
145- var fb uint32
146416 if reason == "" {
147417 fb, reason = o.scanoutFB(c.DMABuf, time.Now())
148418 }
@@ -151,71 +421,44 @@ func (o *Output) tryScanout(scene ports.Scene, surfaces map[ports.WindowID]ports
151421 o.reason = reason
152422 }
153423 if reason != "" {
154- return false, nil
155- }
156- if o.shown == c.DMABuf.ID && o.queued == 0 {
157- // Already on screen: nothing to flip.
158- return true, nil
424+ return 0, c
159425 }
426+ return fb, c
427+}
428+
429+// commitScanout flips a client buffer. It reports false when KMS refused
430+// the buffer: the frame must be composed.
431+func (o *Output) commitScanout(fb uint32, c ports.SurfaceContent, f pendingFrame) (bool, error) {
432+ cfb := o.clientFBs[c.DMABuf.ID]
160433 // Drivers refuse async flips that change the format or modifier: the
161434 // flip from the composed image into scanout waits for vblank.
162- cfb := o.clientFBs[c.DMABuf.ID]
163435 async := c.Async && o.tearing && o.shown != 0 && !cfb.noAsync
164- err := flip(o.fd, o.crtc, fb, async)
436+ vrr := o.wantVRR(true)
437+ f.queued, f.zeroCopy = c.DMABuf.ID, true
438+ err := o.commitFrame(fb, nil, async, vrr, f)
165439 if err != nil && async && errors.Is(err, unix.EINVAL) {
166- // Refused (e.g. a layout change, or not a fast update): this
167- // buffer flips at vblank from now on.
440+ // Refused (e.g. not a fast update): this buffer flips at vblank
441+ // from now on.
168442 o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("async flip refused")
169- cfb.noAsync, async = true, false
170- err = flip(o.fd, o.crtc, fb, false)
443+ cfb.noAsync = true
444+ err = o.commitFrame(fb, nil, false, vrr, f)
445+ }
446+ if err != nil && (errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)) {
447+ // KMS refuses this buffer on the plane: compose it instead.
448+ o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
449+ cfb.failed, o.reason = "flip_refused", "flip_refused"
450+ o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
451+ return false, nil
171452 }
172453 if err == nil {
173- o.setAsync(async)
174- o.setVRR(true)
175- o.composedSince = time.Time{}
454+ o.queued = c.DMABuf.ID
176455 }
177- if err != nil {
178- if errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE) {
179- // KMS refuses this buffer on the plane: compose it instead.
180- o.log.Info().Err(err).Uint32("format", c.DMABuf.Format).Uint64("modifier", c.DMABuf.Modifier).Msg("scanout flip refused")
181- cfb.failed = "flip_refused"
182- o.reason = "flip_refused"
183- o.log.Info().Bool("direct_scanout", false).Str("reason", o.reason).Str("connector", o.conn.name).Msg("scanout")
184- return false, nil
185- }
186- return true, err
187- }
188- o.pending, o.flipStart = true, time.Now()
189- o.queued = c.DMABuf.ID
190- return true, nil
456+ return true, err
191457 }
192458
193459 // vrrHold is how long composition runs before VRR turns off.
194460 const vrrHold = 500 * time.Millisecond
195461
196-// setVRR turns variable refresh on or off, when the output has it.
197-func (o *Output) setVRR(on bool) {
198- if o.vrrProp == 0 || on == o.vrrOn {
199- return
200- }
201- v := uint64(0)
202- if on {
203- v = 1
204- }
205- // SETPROPERTY is a blocking atomic commit (about one refresh).
206- start := time.Now()
207- if err := setProp(o.fd, o.crtc, objCrtc, o.vrrProp, v); err != nil {
208- if lostMaster(err) {
209- return // switched away: retried on the next change
210- }
211- o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr; disabled")
212- o.vrrProp, o.vrrOn = 0, false
213- return
214- }
215- o.vrrOn = on
216- o.log.Info().Bool("vrr", on).Str("connector", o.conn.name).Dur("took", time.Since(start)).Msg("vrr")
217-}
218-
219462 // setAsync logs when the output starts or stops tearing.
220463 func (o *Output) setAsync(on bool) {
221464 if on != o.async {
@@ -227,30 +470,62 @@ func (o *Output) setAsync(on bool) {
227470 // Close restores the CRTC state found at startup, when it had a mode, and
228471 // frees buffers.
229472 func (o *Output) Close() {
473+ req := &atomicReq{}
230474 if o.cursor != nil {
231- o.cursor.close()
232- }
233- o.setVRR(false)
234- if o.saved.crtcID != 0 {
235- ids := []uint32{o.conn.id}
236- s := o.saved
237- if s.modeValid != 0 {
238- _ = setCrtc(o.fd, s.crtcID, ids[0], s.fbID, &s.mode)
475+ o.cursor.props(req, o.crtc, cursorState{})
476+ }
477+ req.set(o.crtc, o.vrrProp, 0)
478+ s := o.saved
479+ var blob uint32
480+ if s.crtcID != 0 && s.modeValid != 0 && s.fbID != 0 {
481+ mode := s.mode
482+ if b, err := o.k.createBlob(unsafe.Slice((*byte)(unsafe.Pointer(&mode)), unsafe.Sizeof(mode))); err == nil {
483+ blob = b
484+ req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b))
485+ req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
486+ p := o.primary
487+ req.set(p.id, p.prop("FB_ID"), uint64(s.fbID))
488+ req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
489+ req.set(p.id, p.prop("SRC_X"), 0)
490+ req.set(p.id, p.prop("SRC_Y"), 0)
491+ req.set(p.id, p.prop("SRC_W"), uint64(s.mode.HDisplay)<<16)
492+ req.set(p.id, p.prop("SRC_H"), uint64(s.mode.VDisplay)<<16)
493+ req.set(p.id, p.prop("CRTC_X"), 0)
494+ req.set(p.id, p.prop("CRTC_Y"), 0)
495+ req.set(p.id, p.prop("CRTC_W"), uint64(s.mode.HDisplay))
496+ req.set(p.id, p.prop("CRTC_H"), uint64(s.mode.VDisplay))
239497 }
240498 }
499+ // Best effort: at exit the card may already belong to another session.
500+ _ = o.k.commit(req, atomicAllowModes, 0)
501+ if blob != 0 {
502+ _ = o.k.destroyBlob(blob)
503+ }
504+ if o.modeBlob != 0 {
505+ _ = o.k.destroyBlob(o.modeBlob)
506+ o.modeBlob = 0
507+ }
508+ if o.cursor != nil {
509+ o.cursor.free(o.k)
510+ }
241511 o.freeImages()
242512 o.shown, o.queued = 0, 0
243513 o.dropClientFBs(time.Now(), true)
244514 }
245515
246-// Run renders scenes and flips until ctx ends. active reports seat enable/disable.
247-// Each completed page flip is reported on presented (dropped when full).
516+// Run renders scenes and commits them until ctx ends. active reports seat
517+// enable/disable. What the output shows and read is reported on presented.
248518 func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Renderer, error), loadCursor CursorLoader, active <-chan bool, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, presented chan<- ports.OutputPresented) error {
249519 r, err := newRenderer(o.Width(), o.Height())
250520 if err != nil {
251521 return fmt.Errorf("create renderer: %w", err)
252522 }
253523 defer r.Close()
524+ if o.cursor != nil {
525+ if err := o.cursor.setup(o.k, r); err != nil {
526+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("no hardware cursor images")
527+ }
528+ }
254529 // The seat sends its state first; while switched away (e.g. a monitor
255530 // plugged in on another VT) the modeset waits for the enable.
256531 enabled := true
@@ -258,8 +533,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
258533 case enabled = <-active:
259534 default:
260535 }
261- // Output images, from the best to the fallback (ADR 014). While
262- // switched away they are validated by the first modeset on enable.
536+ // Output images, from the best to the linear fallback (ADR 014).
537+ // While switched away they are validated by the first modeset on enable.
263538 if enabled {
264539 err = o.showImages(r, imagesDriver, nil)
265540 } else {
@@ -268,6 +543,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
268543 if err != nil {
269544 return err
270545 }
546+ if enabled {
547+ o.probeAsync(r)
548+ }
271549 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
272550 var scene ports.Scene
273551 haveScene, dirty := false, false
@@ -276,36 +554,39 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
276554 frame := 0
277555 stats := time.NewTicker(10 * time.Second)
278556 defer stats.Stop()
279- // seen is the latest content Seq per window, reported to wayland with
280- // what the output scans out, after each frame decision.
557+ // seen is the latest content Seq per window. It is reported only while
558+ // no frame is in flight: then the GPU has finished every frame that
559+ // read older buffers (the kernel waited for their fence to flip).
281560 seen := map[ports.WindowID]uint64{}
282561 reportDirty := false
283- flipped := false
562+ var cursorWake <-chan struct{}
563+ if o.cursor != nil {
564+ cursorWake = o.cursor.wake
565+ }
284566 for {
285- if flipped || reportDirty {
286- o.report(presented, flipped, seen)
287- flipped, reportDirty = false, false
288- } else {
289- o.flushReport(presented)
567+ if reportDirty && !o.pending {
568+ o.report(nil, seen)
569+ reportDirty = false
290570 }
571+ o.flushReport(presented)
291572 // An unsent report is retried soon, not only on the next event.
292573 var retry <-chan time.Time
293- if o.unsent.Output != "" {
574+ if len(o.unsent) > 0 {
294575 retry = time.After(time.Millisecond)
295576 }
296577 // A composed screen that stopped changing still drops VRR on time.
297578 var vrrOff <-chan time.Time
298- if enabled && o.vrrOn && !o.composedSince.IsZero() {
579+ if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() {
299580 vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince)))
300581 }
582+ stateDirty := false
301583 select {
302584 case <-retry:
303585 continue
304586 case <-vrrOff:
305- if o.setVRR(false); o.vrrOn {
306- o.composedSince = time.Now() // failed: retry later, not in a loop
307- }
308- continue
587+ stateDirty = true
588+ case <-cursorWake:
589+ stateDirty = true
309590 case <-ctx.Done():
310591 return nil
311592 case on := <-active:
@@ -317,7 +598,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
317598 o.log.Warn().Err(err).Msg("resume")
318599 continue
319600 }
320- if refused(err) && !o.validated && o.kind < imagesDumb {
601+ if refused(err) && !o.validated && o.kind < imagesLinear {
321602 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(o.kind)).Msg("modeset refused the output images")
322603 o.freeImages()
323604 err = o.showImages(r, o.kind+1, err)
@@ -327,40 +608,33 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
327608 return err
328609 }
329610 o.validated = true
611+ o.probeAsync(r)
330612 dirty = haveScene
331- if o.cursor != nil {
332- if err := o.cursor.Reapply(); err != nil {
333- o.log.Warn().Err(err).Msg("cursor")
334- }
335- }
613+ stateDirty = true
336614 }
337615 enabled = on
338616 o.log.Info().Bool("enabled", on).Msg("output")
339- case n := <-o.flipped:
617+ case ev := <-o.flipped:
340618 if !o.pending {
341619 continue // stale event from before a modeset
342620 }
343- o.pending = false
344- o.flips += n
345- o.shown = o.queued
346- o.queued = 0
347- flipped = true
348- if d := time.Since(o.flipStart); d > 20*time.Millisecond {
349- o.log.Info().Dur("flip_ms", d).Msg("slow flip")
350- }
621+ o.completed(ev, seen)
622+ reportDirty = false
623+ stateDirty = true
351624 case s := <-scenes:
352625 if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
353626 cursorScale = s.Scale
354- o.setCursor(loadCursor, want, s.Scale)
627+ o.setCursor(r, loadCursor, want, s.Scale)
628+ stateDirty = true
355629 }
356630 scene, haveScene, dirty = s, true, true
357631 case c := <-cursor:
358632 want = c
359633 // Before the first scene the scale is unknown: loaded then.
360634 if o.cursor != nil && loadCursor != nil && cursorScale > 0 {
361- o.setCursor(loadCursor, want, cursorScale)
635+ o.setCursor(r, loadCursor, want, cursorScale)
636+ stateDirty = true
362637 }
363- continue
364638 case c := <-contents:
365639 if c.Empty() {
366640 delete(surfaces, c.ID)
@@ -378,50 +652,100 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
378652 ev := o.log.Info().Int("frames", frame).Int("flips", o.flips)
379653 if o.cursor != nil {
380654 cs := o.cursor.TakeStats()
381- ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_ioctls", cs.Ioctls).Dur("cursor_max_ioctl_ms", cs.MaxIoctl)
655+ ev = ev.Int("cursor_moves", cs.Moves).Int("cursor_commits", cs.Commits)
382656 }
383657 ev.Msg("stats")
384658 }
385- if !dirty || !haveScene || !enabled || o.pending {
659+ if !enabled || o.pending {
660+ continue
661+ }
662+ if !dirty || !haveScene {
663+ if stateDirty {
664+ if err := o.commitState(o.wantVRR(o.shown != 0)); err != nil && !o.commitFailed(err, &enabled) {
665+ return fmt.Errorf("commit: %w", err)
666+ }
667+ }
386668 continue
387669 }
388670 start := time.Now()
389- direct, err := o.tryScanout(scene, surfaces)
671+ f := pendingFrame{shows: maps.Clone(seen)}
672+ fb, c := o.scanoutFrame(scene, surfaces)
673+ direct := false
674+ if fb != 0 {
675+ direct, err = o.commitScanout(fb, c, f)
676+ }
390677 if !direct {
391678 r.UseTarget(o.back)
392- if err := r.Render(scene, surfaces); err != nil {
393- return fmt.Errorf("render frame: %w", err)
679+ done, rerr := r.Render(scene, surfaces)
680+ if rerr != nil {
681+ return fmt.Errorf("render frame: %w", rerr)
682+ }
683+ err = o.commitFrame(o.fbs[o.back], done, false, o.wantVRR(false), f)
684+ if done != nil {
685+ done.Close()
686+ }
687+ if err == nil {
688+ o.queued = 0
689+ o.back = 1 - o.back
394690 }
395- err = o.present(r)
396691 }
397- copyStart := time.Now()
398692 if err != nil {
399- if errors.Is(err, unix.EBUSY) {
400- // A flip is still in flight; retry after its event.
401- o.pending, o.flipStart = true, time.Now()
402- continue // dirty stays true
693+ if !o.commitFailed(err, &enabled) {
694+ return fmt.Errorf("page flip: %w", err)
403695 }
404- if errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM) {
405- // Lost DRM master mid-switch; the seat disable follows.
406- o.log.Warn().Err(err).Msg("flip refused")
407- enabled = false
408- continue
409- }
410- o.log.Error().Err(err).Msg("page flip")
411- return fmt.Errorf("page flip: %w", err)
696+ continue // dirty stays true
412697 }
413698 dirty = false
414- reportDirty = true
415699 frame++
416- o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("render", time.Since(start)).Dur("copy", time.Since(copyStart)).Msg("frame")
700+ o.log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Bool("direct", direct).Dur("took", time.Since(start)).Msg("frame")
701+ }
702+}
703+
704+// commitFailed handles a commit error that does not stop the output: a
705+// commit still in flight (EBUSY, retried after its event) or DRM master
706+// lost mid-switch (the seat disable follows).
707+func (o *Output) commitFailed(err error, enabled *bool) bool {
708+ switch {
709+ case errors.Is(err, unix.EBUSY):
710+ o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
711+ return true
712+ case lostMaster(err):
713+ o.log.Warn().Err(err).Msg("commit refused")
714+ *enabled = false
715+ return true
716+ }
717+ o.log.Error().Err(err).Msg("commit")
718+ return false
719+}
720+
721+// completed handles the event of the pending commit: a flipped frame is
722+// reported with its kernel timestamp.
723+func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) {
724+ f := o.pendingFrame
725+ o.pending, o.pendingFrame = false, pendingFrame{}
726+ if d := time.Since(o.flipStart); d > 20*time.Millisecond {
727+ o.log.Info().Dur("flip_ms", d).Msg("slow flip")
728+ }
729+ if !f.frame {
730+ return
731+ }
732+ o.flips++
733+ o.shown = f.queued
734+ if o.queued == f.queued {
735+ o.queued = 0
736+ }
737+ refresh := time.Duration(0)
738+ if !o.vrrOn {
739+ refresh = time.Duration(int64(time.Second) * 1000 / int64(max(1, o.mode.refreshMilli())))
417740 }
741+ o.report(&ports.FlipInfo{When: ev.when, Seq: uint64(ev.seq), Refresh: refresh, ZeroCopy: f.zeroCopy, Async: f.async, HardwareClock: true, Shows: f.shows}, seen)
418742 }
419743
420-// setCursor loads a cursor for scale onto the cursor plane.
421-func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float64) {
744+// setCursor loads a cursor for scale into the cursor images.
745+func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) {
422746 img, err := load(c, scale, o.cursor.Limit())
423747 if err == nil {
424- err = o.cursor.SetImage(img.Pixels, img.W, img.H, img.HotX, img.HotY)
748+ err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)
425749 }
426750 if err != nil {
427751 o.log.Warn().Err(err).Msg("cursor")
@@ -430,26 +754,47 @@ func (o *Output) setCursor(load CursorLoader, c ports.CursorChange, scale float6
430754 o.log.Debug().Float64("scale", scale).Str("shape", c.Shape).Bool("client", c.Image != nil).Int("w", img.W).Int("h", img.H).Msg("cursor image")
431755 }
432756
433-// report tells wayland what the output shows and has read, so replaced
434-// client buffers can be released. It must follow the frame decision: a
435-// buffer is safe once a later content is seen and it is neither shown nor
436-// queued. A report the channel cannot take waits in unsent, replaced by
437-// newer ones (Flip kept), and is retried on the next loop.
438-func (o *Output) report(presented chan<- ports.OutputPresented, flip bool, seen map[ports.WindowID]uint64) {
439- r := ports.OutputPresented{Output: o.conn.name, Flip: flip || o.unsent.Flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}
440- o.unsent = r
441- o.flushReport(presented)
442-}
757+// maxUnsent bounds reports waiting for wayland; past it the oldest flip is
758+// folded into the next one.
759+const maxUnsent = 16
443760
444-// flushReport sends the unsent report, if any, without blocking.
445-func (o *Output) flushReport(presented chan<- ports.OutputPresented) {
446- if o.unsent.Output == "" {
761+// report queues what the output shows and has read, so replaced client
762+// buffers can be released. A flip-less report merges into the newest
763+// queued one; flips are never merged unless maxUnsent is reached.
764+func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
765+ r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)}
766+ if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) {
767+ if flip == nil {
768+ r.Flip = o.unsent[n-1].Flip
769+ }
770+ o.unsent[n-1] = r
447771 return
448772 }
449- select {
450- case presented <- o.unsent:
451- o.unsent = ports.OutputPresented{}
452- default:
773+ o.unsent = append(o.unsent, r)
774+ if len(o.unsent) > maxUnsent {
775+ old, next := o.unsent[0].Flip, o.unsent[1].Flip
776+ if next != nil && old != nil {
777+ next.Merged += 1 + old.Merged
778+ for id, seq := range old.Shows {
779+ if _, ok := next.Shows[id]; !ok {
780+ next.Shows[id] = seq
781+ }
782+ }
783+ }
784+ o.unsent = slices.Delete(o.unsent, 0, 1)
785+ o.log.Warn().Str("connector", o.conn.name).Msg("wayland is not reading output reports; flips merged")
786+ }
787+}
788+
789+// flushReport sends queued reports without blocking.
790+func (o *Output) flushReport(presented chan<- ports.OutputPresented) {
791+ for len(o.unsent) > 0 {
792+ select {
793+ case presented <- o.unsent[0]:
794+ o.unsent = slices.Delete(o.unsent, 0, 1)
795+ default:
796+ return
797+ }
453798 }
454799 }
455800
@@ -459,7 +804,6 @@ type imageKind int
459804 const (
460805 imagesDriver imageKind = iota // exported, modifier chosen by the driver
461806 imagesLinear // exported, linear
462- imagesDumb // dumb buffers filled by CPU copies
463807 )
464808
465809 // showImages sets up images from kind on and modesets them. KMS may refuse
@@ -476,14 +820,11 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
476820 o.validated = true
477821 return nil
478822 }
479- if !refused(err) {
823+ if !refused(err) || got == imagesLinear {
480824 return err
481825 }
482826 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
483827 o.freeImages()
484- if got == imagesDumb {
485- return err
486- }
487828 kind, cause = got+1, err
488829 }
489830 }
@@ -493,17 +834,17 @@ func refused(err error) bool {
493834 return errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ERANGE)
494835 }
495836
496-// lostMaster reports a modeset error meaning the seat is switched away.
837+// lostMaster reports a commit error meaning the seat is switched away.
497838 func lostMaster(err error) bool {
498839 return errors.Is(err, unix.EACCES) || errors.Is(err, unix.EPERM)
499840 }
500841
501-// setupImages gives the output the two images it flips between: renderer
502-// targets exported as dmabufs, or dumb buffers as a logged fallback. A
503-// kind that fails falls through to the next one.
842+// setupImages gives the output the two renderer images it flips between,
843+// exported as dmabufs. There is no CPU fallback (ADR 014): a GPU that
844+// cannot export a scanout-capable image cannot drive the output.
504845 func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (imageKind, error) {
505846 err := cause
506- for ; kind < imagesDumb; kind++ {
847+ for ; kind <= imagesLinear; kind++ {
507848 mods := []uint64(nil)
508849 if kind == imagesLinear {
509850 mods = []uint64{0}
@@ -513,17 +854,7 @@ func (o *Output) setupImages(r ports.Renderer, kind imageKind, cause error) (ima
513854 }
514855 o.log.Info().Err(err).Str("connector", o.conn.name).Int("kind", int(kind)).Msg("output image export")
515856 }
516- o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("output falls back to CPU copies (ADR 014)")
517- // The renderer draws into its own image again, read back by CopyBGRX.
518- _, _ = r.ExportTargets(0, nil)
519- for i := range o.bufs {
520- if o.bufs[i], err = newDumb(o.fd, o.Width(), o.Height()); err != nil {
521- o.freeImages()
522- return imagesDumb, err
523- }
524- o.fbs[i] = o.bufs[i].fbID
525- }
526- return imagesDumb, nil
857+ return imagesLinear, fmt.Errorf("%s: GPU cannot export scanout images (ADR 014): %w", o.conn.name, err)
527858 }
528859
529860 // exportImages makes the renderer's exported targets the output images.
@@ -534,7 +865,7 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
534865 }
535866 for i := range bufs {
536867 if err == nil {
537- o.fbs[i], err = o.card.addFB(&bufs[i])
868+ o.fbs[i], err = o.k.addFB(&bufs[i], fourccXRGB)
538869 }
539870 bufs[i].Planes[0].File.Close()
540871 }
@@ -545,7 +876,12 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
545876 break
546877 }
547878 r.UseTarget(i)
548- err = r.Render(ports.Scene{Background: "#000000"}, nil)
879+ var done *os.File
880+ if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
881+ // The modeset is a blocking commit: wait for the clear.
882+ err = errors.Join(err, waitFence(done))
883+ done.Close()
884+ }
549885 }
550886 if err != nil {
551887 o.freeImages()
@@ -556,15 +892,22 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
556892 return nil
557893 }
558894
559-// freeImages removes the output images; dumb buffers own their fb.
895+// waitFence blocks until a sync file signals (setup only, never per frame).
896+func waitFence(f *os.File) error {
897+ fds := []unix.PollFd{{Fd: int32(f.Fd()), Events: unix.POLLIN}}
898+ for {
899+ _, err := unix.Poll(fds, -1)
900+ if !errors.Is(err, unix.EINTR) {
901+ return err
902+ }
903+ }
904+}
905+
906+// freeImages removes the output images' framebuffers.
560907 func (o *Output) freeImages() {
561908 for i, fb := range o.fbs {
562- if b := o.bufs[i]; b != nil {
563- b.destroy(o.fd)
564- o.bufs[i] = nil
565- } else if fb != 0 {
566- v := fb
567- _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&v))
909+ if fb != 0 {
910+ _ = o.k.rmFB(fb)
568911 }
569912 o.fbs[i] = 0
570913 }
A internal/adapters/drm/output_test.go +436 -0
@@ -0,0 +1,436 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ portsmocks "github.com/bnema/nefertty/internal/mocks/ports"
11+ "github.com/bnema/nefertty/internal/ports"
12+ "github.com/bnema/zerowrap"
13+ "github.com/stretchr/testify/mock"
14+ "golang.org/x/sys/unix"
15+)
16+
17+// Object and property IDs of the test output.
18+const (
19+ tCrtc, tConn, tPrimary, tCursor, tOverlay = 30, 40, 50, 51, 52
20+ pFB, pCrtcID, pFence, pMode, pActive = 1, 2, 3, 4, 5
21+ pVRR, pConnCrtc = 6, 7
22+)
23+
24+var planeProps = map[string]uint32{"FB_ID": pFB, "CRTC_ID": pCrtcID, "IN_FENCE_FD": pFence, "SRC_X": 10, "SRC_Y": 11, "SRC_W": 12, "SRC_H": 13, "CRTC_X": 14, "CRTC_Y": 15, "CRTC_W": 16, "CRTC_H": 17}
25+
26+// commitMu guards recorded commits (Run commits on its goroutine).
27+var commitMu sync.Mutex
28+
29+// commitRec is one recorded atomic commit.
30+type commitRec struct {
31+ req atomicReq
32+ flags uint32
33+ user uint64
34+}
35+
36+// testOutput returns an output on a mocked kms that records commits;
37+// commit returns the next error of errs (nil when empty).
38+func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {
39+ k := newMockkms(t)
40+ var commits []commitRec
41+ k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, user uint64) error {
42+ commitMu.Lock()
43+ defer commitMu.Unlock()
44+ c := commitRec{flags: flags, user: user}
45+ c.req.objs = append([]uint32(nil), r.objs...)
46+ for _, ps := range r.props {
47+ c.req.props = append(c.req.props, append([]propValue(nil), ps...))
48+ }
49+ commits = append(commits, c)
50+ if len(errs) > 0 {
51+ err := errs[0]
52+ errs = errs[1:]
53+ return err
54+ }
55+ return nil
56+ }).Maybe()
57+ primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps}
58+ cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64)
59+ cur.fbs = [2]uint32{90, 91}
60+ o := &Output{k: k, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
61+ crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
62+ primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}
63+ return o, k, &commits
64+}
65+
66+func objCount(c commitRec) int { return len(c.req.objs) }
67+
68+func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
69+ o, _, commits := testOutput(t)
70+ o.cursor.image = true
71+ o.cursor.Move(12, 34)
72+ fence, w, err := os.Pipe()
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ defer fence.Close()
77+ defer w.Close()
78+ if err := o.commitFrame(70, fence, false, true, pendingFrame{}); err != nil {
79+ t.Fatal(err)
80+ }
81+ c := (*commits)[0]
82+ if c.flags != atomicNonblock|flipEventFlag || c.user != userFrame {
83+ t.Fatalf("flags %#x user %d", c.flags, c.user)
84+ }
85+ if v, _ := c.req.value(tPrimary, pFB); v != 70 {
86+ t.Fatalf("primary fb %d", v)
87+ }
88+ if v, ok := c.req.value(tPrimary, pFence); !ok || v != uint64(fence.Fd()) {
89+ t.Fatalf("fence %d %v", v, ok)
90+ }
91+ if v, _ := c.req.value(tCursor, 14); v != 12 {
92+ t.Fatalf("cursor x %d", v)
93+ }
94+ if v, _ := c.req.value(tCrtc, pVRR); v != 1 {
95+ t.Fatalf("vrr %d", v)
96+ }
97+ if !o.pending || !o.vrrOn || o.cursor.applied.x != 12 {
98+ t.Fatalf("state pending=%v vrr=%v cursor=%+v", o.pending, o.vrrOn, o.cursor.applied)
99+ }
100+}
101+
102+func TestAsyncCommitHasOnlyPrimaryFBAndFence(t *testing.T) {
103+ o, _, commits := testOutput(t)
104+ fence, w, _ := os.Pipe()
105+ defer fence.Close()
106+ defer w.Close()
107+ if err := o.commitFrame(70, fence, true, false, pendingFrame{}); err != nil {
108+ t.Fatal(err)
109+ }
110+ c := (*commits)[0]
111+ if c.flags&flipAsyncFlag == 0 {
112+ t.Fatal("not async")
113+ }
114+ if objCount(c) != 1 || c.req.objs[0] != tPrimary || len(c.req.props[0]) != 2 {
115+ t.Fatalf("async commit %+v", c.req)
116+ }
117+}
118+
119+func TestAsyncFallsBackToSyncWhenCursorMoves(t *testing.T) {
120+ o, _, commits := testOutput(t)
121+ o.cursor.image = true
122+ o.cursor.Move(5, 5)
123+ if err := o.commitFrame(70, nil, true, false, pendingFrame{}); err != nil {
124+ t.Fatal(err)
125+ }
126+ if (*commits)[0].flags&flipAsyncFlag != 0 {
127+ t.Fatal("async commit moved the cursor")
128+ }
129+}
130+
131+func TestAsyncWithoutFenceSupportStaysSync(t *testing.T) {
132+ o, _, commits := testOutput(t)
133+ o.asyncFence = false
134+ fence, w, _ := os.Pipe()
135+ defer fence.Close()
136+ defer w.Close()
137+ if err := o.commitFrame(70, fence, true, false, pendingFrame{}); err != nil {
138+ t.Fatal(err)
139+ }
140+ if (*commits)[0].flags&flipAsyncFlag != 0 {
141+ t.Fatal("async commit with a fence the driver refuses")
142+ }
143+}
144+
145+func TestScanoutAsyncRefusedRetriesSync(t *testing.T) {
146+ o, _, commits := testOutput(t, unix.EINVAL)
147+ o.shown, o.vrrOn = 1, true
148+ buf := &ports.DMABuf{ID: 9}
149+ o.clientFBs[9] = &clientFB{fbID: 80}
150+ ok, err := o.commitScanout(80, ports.SurfaceContent{DMABuf: buf, Async: true}, pendingFrame{})
151+ if !ok || err != nil {
152+ t.Fatalf("ok=%v err=%v", ok, err)
153+ }
154+ if len(*commits) != 2 || (*commits)[0].flags&flipAsyncFlag == 0 || (*commits)[1].flags&flipAsyncFlag != 0 {
155+ t.Fatalf("commits %+v", *commits)
156+ }
157+ if !o.clientFBs[9].noAsync || o.queued != 9 {
158+ t.Fatalf("noAsync=%v queued=%d", o.clientFBs[9].noAsync, o.queued)
159+ }
160+}
161+
162+func TestCursorCommitWaitsForEvent(t *testing.T) {
163+ o, _, commits := testOutput(t)
164+ o.cursor.image = true
165+ o.cursor.Move(1, 1)
166+ if err := o.commitState(false); err != nil {
167+ t.Fatal(err)
168+ }
169+ c := (*commits)[0]
170+ if c.flags != atomicNonblock|flipEventFlag || c.user != userState || !o.pending {
171+ t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.pending)
172+ }
173+ // Its event is not a frame: nothing is reported.
174+ seen := map[ports.WindowID]uint64{}
175+ o.completed(flipEvent{user: userState}, seen)
176+ if o.pending || len(o.unsent) != 0 || o.flips != 0 {
177+ t.Fatalf("pending=%v unsent=%d flips=%d", o.pending, len(o.unsent), o.flips)
178+ }
179+ // Nothing changed: no commit.
180+ if err := o.commitState(false); err != nil || len(*commits) != 1 {
181+ t.Fatalf("idle commit: %v, %d commits", err, len(*commits))
182+ }
183+}
184+
185+func TestBusyCommitStaysPending(t *testing.T) {
186+ o, _, _ := testOutput(t, unix.EBUSY)
187+ err := o.commitFrame(70, nil, false, false, pendingFrame{})
188+ enabled := true
189+ if !o.commitFailed(err, &enabled) || !o.pending || !enabled {
190+ t.Fatalf("EBUSY: pending=%v enabled=%v", o.pending, enabled)
191+ }
192+}
193+
194+func TestFlipReportsTimestampAndShownFrame(t *testing.T) {
195+ o, _, _ := testOutput(t)
196+ o.vrrOn = false
197+ o.mode.Clock, o.mode.HTotal, o.mode.VTotal = 0, 0, 0
198+ if err := o.commitFrame(70, nil, false, false, pendingFrame{shows: map[ports.WindowID]uint64{1: 4}}); err != nil {
199+ t.Fatal(err)
200+ }
201+ o.completed(flipEvent{user: userFrame, when: 5 * time.Second, seq: 11}, map[ports.WindowID]uint64{1: 4})
202+ if len(o.unsent) != 1 {
203+ t.Fatalf("reports %d", len(o.unsent))
204+ }
205+ f := o.unsent[0].Flip
206+ if f == nil || f.When != 5*time.Second || f.Seq != 11 || f.Refresh != time.Second/60 || f.Shows[1] != 4 || !f.HardwareClock {
207+ t.Fatalf("flip %+v", f)
208+ }
209+}
210+
211+func TestReportsKeepEveryFlip(t *testing.T) {
212+ o, _, _ := testOutput(t)
213+ seen := map[ports.WindowID]uint64{1: 1}
214+ o.report(&ports.FlipInfo{Seq: 1}, seen)
215+ o.report(nil, seen)
216+ o.report(&ports.FlipInfo{Seq: 2}, seen)
217+ o.report(nil, map[ports.WindowID]uint64{1: 3})
218+ if len(o.unsent) != 2 || o.unsent[0].Flip.Seq != 1 || o.unsent[1].Flip.Seq != 2 || o.unsent[1].Seen[1] != 3 {
219+ t.Fatalf("unsent %+v", o.unsent)
220+ }
221+ for i := range maxUnsent + 3 {
222+ o.report(&ports.FlipInfo{Seq: uint64(10 + i), Shows: map[ports.WindowID]uint64{}}, seen)
223+ }
224+ if len(o.unsent) != maxUnsent || o.unsent[0].Flip.Merged == 0 {
225+ t.Fatalf("cap: %d reports, first merged %d", len(o.unsent), o.unsent[0].Flip.Merged)
226+ }
227+}
228+
229+func TestModesetDisablesCursorAndStrayPlanes(t *testing.T) {
230+ o, k, commits := testOutput(t)
231+ stray := &plane{id: tOverlay, typ: planeOverlay, props: planeProps}
232+ o.stray = []*plane{stray}
233+ k.EXPECT().createBlob(mock.Anything).Return(99, nil).Once()
234+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{"CRTC_ID": {pCrtcID, tCrtc}}, nil).Once()
235+ if err := o.modeset(); err != nil {
236+ t.Fatal(err)
237+ }
238+ c := (*commits)[0]
239+ if c.flags != atomicAllowModes {
240+ t.Fatalf("flags %#x", c.flags)
241+ }
242+ if v, _ := c.req.value(tCrtc, pMode); v != 99 {
243+ t.Fatalf("mode blob %d", v)
244+ }
245+ for _, p := range []uint32{tCursor, tOverlay} {
246+ if v, ok := c.req.value(p, pFB); !ok || v != 0 {
247+ t.Fatalf("plane %d fb %d %v", p, v, ok)
248+ }
249+ }
250+ if v, _ := c.req.value(tConn, pConnCrtc); v != tCrtc {
251+ t.Fatalf("connector crtc %d", v)
252+ }
253+ // The next modeset replaces the blob.
254+ k.EXPECT().createBlob(mock.Anything).Return(100, nil).Once()
255+ k.EXPECT().objProps(uint32(tOverlay), uint32(objPlane)).Return(map[string][2]uint64{}, nil).Once()
256+ k.EXPECT().destroyBlob(uint32(99)).Return(nil).Once()
257+ if err := o.modeset(); err != nil {
258+ t.Fatal(err)
259+ }
260+ if _, ok := (*commits)[1].req.value(tOverlay, pFB); ok {
261+ t.Fatal("plane on another CRTC turned off")
262+ }
263+}
264+
265+func TestAsyncProbeAfterModeset(t *testing.T) {
266+ o, _, commits := testOutput(t, unix.EINVAL)
267+ o.asyncFence = true
268+ r := portsmocks.NewMockRenderer(t)
269+ fence, w, _ := os.Pipe()
270+ defer w.Close()
271+ r.EXPECT().UseTarget(mock.Anything).Return().Once()
272+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(fence, nil).Once()
273+ o.probeAsync(r)
274+ c := (*commits)[0]
275+ if c.flags != atomicTestOnly|flipAsyncFlag {
276+ t.Fatalf("flags %#x", c.flags)
277+ }
278+ if _, ok := c.req.value(tPrimary, pFence); !ok {
279+ t.Fatal("probe without a fence")
280+ }
281+ if o.asyncFence {
282+ t.Fatal("refused probe kept asyncFence")
283+ }
284+ if _, err := fence.Stat(); err == nil {
285+ t.Fatal("probe fence not closed")
286+ }
287+ o.probeAsync(r) // once only
288+}
289+
290+func TestVRRToggleOnlyInsideFrames(t *testing.T) {
291+ o, _, commits := testOutput(t)
292+ o.vrrOn = true
293+ o.composedSince = time.Now().Add(-time.Second)
294+ if o.wantVRR(false) {
295+ t.Fatal("VRR kept after vrrHold of composition")
296+ }
297+ o.composedSince = time.Now()
298+ if !o.wantVRR(false) {
299+ t.Fatal("VRR dropped before vrrHold")
300+ }
301+ if err := o.commitState(false); err != nil {
302+ t.Fatal(err)
303+ }
304+ if v, ok := (*commits)[0].req.value(tCrtc, pVRR); !ok || v != 0 || (*commits)[0].flags&flipEventFlag == 0 {
305+ t.Fatalf("vrr off commit %+v", (*commits)[0])
306+ }
307+}
308+
309+func TestReadPlanes(t *testing.T) {
310+ k := newMockkms(t)
311+ k.EXPECT().planes().Return([]planeRes{{id: 1, possible: 1}, {id: 2, possible: 2}, {id: 3, possible: 3}}, nil)
312+ k.EXPECT().objProps(uint32(1), uint32(objPlane)).Return(map[string][2]uint64{"type": {20, planePrimary}, "FB_ID": {21, 0}, "IN_FORMATS": {22, 5}}, nil)
313+ k.EXPECT().objProps(uint32(3), uint32(objPlane)).Return(map[string][2]uint64{"type": {20, planeCursor}}, nil)
314+ k.EXPECT().getBlob(uint32(5)).Return(nil, nil)
315+ ps, err := readPlanes(k, 0)
316+ if err != nil || len(ps) != 2 || ps[0].typ != planePrimary || ps[0].prop("FB_ID") != 21 || ps[1].typ != planeCursor {
317+ t.Fatalf("planes %+v %v", ps, err)
318+ }
319+}
320+
321+func TestVRRRefusedIsDisabled(t *testing.T) {
322+ o, _, commits := testOutput(t, unix.EINVAL)
323+ if err := o.commitFrame(70, nil, false, true, pendingFrame{}); err != nil {
324+ t.Fatal(err)
325+ }
326+ if len(*commits) != 2 || o.vrrProp != 0 || !o.pending {
327+ t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp)
328+ }
329+ if v, _ := (*commits)[1].req.value(tCrtc, pVRR); v != 0 {
330+ t.Fatal("retry kept VRR on")
331+ }
332+}
333+
334+func TestBufferRefusedIsNotBlamedOnVRR(t *testing.T) {
335+ o, _, _ := testOutput(t, unix.EINVAL, unix.EINVAL)
336+ o.clientFBs[9] = &clientFB{fbID: 80}
337+ ok, err := o.commitScanout(80, ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 9}}, pendingFrame{})
338+ if ok || err != nil || o.vrrProp == 0 || o.clientFBs[9].failed != "flip_refused" {
339+ t.Fatalf("ok=%v err=%v vrrProp=%d failed=%q", ok, err, o.vrrProp, o.clientFBs[9].failed)
340+ }
341+}
342+
343+// Run: while a frame is in flight its buffers may still be read by the
344+// GPU, so a newer content is reported only after the flip event; every
345+// frame fence is closed.
346+func TestRunReportsSeenAfterFlip(t *testing.T) {
347+ o, k, commits := testOutput(t)
348+ o.cursor, o.tearing = nil, false
349+ flips := make(chan flipEvent, 1)
350+ o.flipped = flips
351+ r := portsmocks.NewMockRenderer(t)
352+ var mu sync.Mutex
353+ var fences []*os.File
354+ pipeBuf := func() ports.DMABuf {
355+ f, w, _ := os.Pipe()
356+ w.Close()
357+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
358+ }
359+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once()
360+ r.EXPECT().UseTarget(mock.Anything).Return()
361+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
362+ f, w, _ := os.Pipe()
363+ w.Close() // readable: signalled
364+ mu.Lock()
365+ fences = append(fences, f)
366+ mu.Unlock()
367+ return f, nil
368+ })
369+ r.EXPECT().Close().Return().Once()
370+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
371+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
372+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
373+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
374+ frameCommits := func() int {
375+ commitMu.Lock()
376+ defer commitMu.Unlock()
377+ n := 0
378+ for _, c := range *commits {
379+ if c.user == userFrame {
380+ n++
381+ }
382+ }
383+ return n
384+ }
385+ scenes := make(chan ports.Scene, 1)
386+ contents := make(chan ports.SurfaceContent, 1)
387+ presented := make(chan ports.OutputPresented, 8)
388+ ctx, cancel := context.WithCancel(context.Background())
389+ done := make(chan error, 1)
390+ go func() {
391+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, contents, nil, presented)
392+ }()
393+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 10, H: 10}}}}
394+ waitFor(t, func() bool { return frameCommits() == 1 })
395+ // Frame 1 is in flight: content 3 replaces what it may be reading.
396+ contents <- ports.SurfaceContent{ID: 1, Seq: 3, SHM: &ports.SHMBuffer{}}
397+ select {
398+ case p := <-presented:
399+ t.Fatalf("report while a frame is in flight: %+v", p)
400+ case <-time.After(30 * time.Millisecond):
401+ }
402+ flips <- flipEvent{crtc: tCrtc, user: userFrame, when: time.Second, seq: 1}
403+ select {
404+ case p := <-presented:
405+ if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 {
406+ t.Fatalf("report %+v", p)
407+ }
408+ case <-time.After(2 * time.Second):
409+ t.Fatal("no report after the flip")
410+ }
411+ cancel()
412+ if err := <-done; err != nil {
413+ t.Fatal(err)
414+ }
415+ mu.Lock()
416+ defer mu.Unlock()
417+ if len(fences) < 3 {
418+ t.Fatalf("%d renders", len(fences))
419+ }
420+ for _, f := range fences {
421+ if _, err := f.Stat(); err == nil {
422+ t.Fatal("frame fence left open")
423+ }
424+ }
425+}
426+
427+func waitFor(t *testing.T, cond func() bool) {
428+ t.Helper()
429+ deadline := time.Now().Add(2 * time.Second)
430+ for !cond() {
431+ if time.Now().After(deadline) {
432+ t.Fatal("timed out")
433+ }
434+ time.Sleep(2 * time.Millisecond)
435+ }
436+}
M internal/adapters/drm/props.go +4 -16
@@ -6,12 +6,11 @@ import (
66 "golang.org/x/sys/unix"
77 )
88
9-// KMS object properties, read and set with the legacy property ioctls.
9+// KMS object properties, read by name; atomic commits set them.
1010
1111 const (
1212 ioctlObjGetProps = 0xC02064B9 // DRM_IOWR('d', 0xB9, struct drm_mode_obj_get_properties)
1313 ioctlGetProp = 0xC04064AA // DRM_IOWR('d', 0xAA, struct drm_mode_get_property)
14- ioctlObjSetProp = 0xC01864BA // DRM_IOWR('d', 0xBA, struct drm_mode_obj_set_property)
1514
1615 objCrtc = 0xcccccccc
1716 objConnector = 0xc0c0c0c0
@@ -30,12 +29,6 @@ type getProp struct {
3029 countValues, countEn uint32
3130 }
3231
33-type objSetProp struct {
34- value uint64
35- propID, objID, objType uint32
36- _ uint32
37-}
38-
3932 // objProps returns an object's property IDs and values by name.
4033 func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
4134 q := objGetProps{objID: obj, objType: typ}
@@ -64,19 +57,14 @@ func objProps(fd int, obj, typ uint32) (map[string][2]uint64, error) {
6457
6558 // vrrProperty returns the CRTC's VRR_ENABLED property when the connector
6659 // is VRR capable, else 0.
67-func vrrProperty(fd int, conn, crtc uint32) uint32 {
68- cp, err := objProps(fd, conn, objConnector)
60+func vrrProperty(k kms, conn, crtc uint32) uint32 {
61+ cp, err := k.objProps(conn, objConnector)
6962 if err != nil || cp["vrr_capable"][1] != 1 {
7063 return 0
7164 }
72- rp, err := objProps(fd, crtc, objCrtc)
65+ rp, err := k.objProps(crtc, objCrtc)
7366 if err != nil {
7467 return 0
7568 }
7669 return uint32(rp["VRR_ENABLED"][0])
7770 }
78-
79-func setProp(fd int, obj, typ, prop uint32, v uint64) error {
80- s := objSetProp{value: v, propID: prop, objID: obj, objType: typ}
81- return ioctl(fd, ioctlObjSetProp, unsafe.Pointer(&s))
82-}
M internal/adapters/drm/scanout.go +19 -21
@@ -23,7 +23,6 @@ const (
2323
2424 fbModifiers = 1 << 1
2525 capAddFB2Modifiers = 0x10
26- capAsyncPageFlip = 0x7
2726 modInvalid = 0x00ffffffffffffff
2827 scanoutIdleTTL = 5 * time.Second // a cached framebuffer survives unused
2928 )
@@ -103,7 +102,12 @@ func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {
103102 }
104103 fb := &clientFB{last: now}
105104 o.clientFBs[b.ID] = fb
106- id, err := o.card.addFB(b)
105+ format, ok := scanoutFormat(b.Format)
106+ if !ok {
107+ fb.failed = "format"
108+ return 0, fb.failed
109+ }
110+ id, err := o.k.addFB(b, format)
107111 if err != nil {
108112 o.log.Info().Err(err).Uint32("format", b.Format).Uint64("modifier", b.Modifier).Msg("scanout import failed")
109113 fb.failed = "import_failed"
@@ -114,9 +118,8 @@ func (o *Output) scanoutFB(b *ports.DMABuf, now time.Time) (uint32, string) {
114118 }
115119
116120 // scanoutFormat is the framebuffer format for a client format. The primary
117-// plane shows no alpha, and a legacy page flip keeps the format of the
118-// composed image (XRGB8888): alpha formats are scanned out as their X
119-// variant, others are refused.
121+// plane shows no alpha: alpha formats are scanned out as their X variant,
122+// others are refused.
120123 func scanoutFormat(f uint32) (uint32, bool) {
121124 switch f {
122125 case fourccXRGB, fourccARGB:
@@ -130,27 +133,23 @@ const (
130133 fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
131134 )
132135
133-// addFB imports a dmabuf as a framebuffer: client buffers and the
134-// renderer's exported output images. The GEM handle belongs to the card fd
136+// addFB imports a dmabuf as a framebuffer of the given fourcc: client buffers, the renderer's
137+// output images and cursor images. The GEM handle belongs to the card fd
135138 // and is shared by every import of the same buffer, so import, ADDFB2 and
136-// close run under the card lock and the handle is closed right away: the
139+// close run under the lock and the handle is closed right away: the
137140 // framebuffer keeps its own reference to the buffer.
138-func (c *Card) addFB(b *ports.DMABuf) (uint32, error) {
139- format, ok := scanoutFormat(b.Format)
140- if !ok {
141- return 0, errors.New("format not scanned out")
142- }
141+func (k kmsDevice) addFB(b *ports.DMABuf, format uint32) (uint32, error) {
143142 raw, err := b.Planes[0].File.SyscallConn()
144143 if err != nil {
145144 return 0, err
146145 }
147- c.gemMu.Lock()
148- defer c.gemMu.Unlock()
146+ k.gemMu.Lock()
147+ defer k.gemMu.Unlock()
149148 var p primeHandle
150149 var ioErr error
151150 if err := raw.Control(func(fd uintptr) {
152151 p.fd = int32(fd)
153- ioErr = ioctl(c.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
152+ ioErr = ioctl(k.fd, ioctlPrimeFDToHandle, unsafe.Pointer(&p))
154153 }); err != nil {
155154 return 0, err
156155 }
@@ -159,14 +158,14 @@ func (c *Card) addFB(b *ports.DMABuf) (uint32, error) {
159158 }
160159 defer func() {
161160 g := gemClose{handle: p.handle}
162- _ = ioctl(c.fd, ioctlGemClose, unsafe.Pointer(&g))
161+ _ = ioctl(k.fd, ioctlGemClose, unsafe.Pointer(&g))
163162 }()
164163 cmd := fbCmd2{width: uint32(b.Width), height: uint32(b.Height), format: format}
165164 cmd.handles[0], cmd.pitches[0], cmd.offsets[0] = p.handle, b.Planes[0].Stride, b.Planes[0].Offset
166- if b.Modifier != modInvalid && (b.Modifier != 0 || c.modifiers) {
165+ if b.Modifier != modInvalid && (b.Modifier != 0 || k.modifiers) {
167166 cmd.flags, cmd.modifiers[0] = fbModifiers, b.Modifier
168167 }
169- if err := ioctl(c.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
168+ if err := ioctl(k.fd, ioctlAddFB2, unsafe.Pointer(&cmd)); err != nil {
170169 return 0, errors.Join(errors.New("add fb2"), err)
171170 }
172171 return cmd.fbID, nil
@@ -183,8 +182,7 @@ func (o *Output) dropClientFBs(now time.Time, all bool) {
183182 continue
184183 }
185184 if fb.fbID != 0 {
186- v := fb.fbID
187- _ = ioctl(o.fd, ioctlRmFB, unsafe.Pointer(&v))
185+ _ = o.k.rmFB(fb.fbID)
188186 }
189187 delete(o.clientFBs, id)
190188 }
M internal/adapters/headless/output.go +24 -1
@@ -2,6 +2,7 @@ package headless
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "image"
78 "image/png"
@@ -12,6 +13,7 @@ import (
1213
1314 "github.com/bnema/nefertty/internal/ports"
1415 "github.com/bnema/zerowrap"
16+ "golang.org/x/sys/unix"
1517 )
1618
1719 type Options struct {
@@ -118,9 +120,19 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
118120 }
119121 }
120122 start := time.Now()
121- if err := r.Render(scene, surfaces); err != nil {
123+ done, err := r.Render(scene, surfaces)
124+ if err != nil {
122125 return fmt.Errorf("render frame: %w", err)
123126 }
127+ if done != nil {
128+ // No screen paces headless frames: wait for the GPU before
129+ // reporting that its buffers were read.
130+ err = waitFence(done)
131+ done.Close()
132+ if err != nil {
133+ return fmt.Errorf("frame fence: %w", err)
134+ }
135+ }
124136 frame++
125137 pending = opts.report(pending, seen)
126138 if opts.ScreenshotDir != "" {
@@ -174,3 +186,14 @@ func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
174186 return r
175187 }
176188 }
189+
190+// waitFence blocks until a sync file signals.
191+func waitFence(f *os.File) error {
192+ fds := []unix.PollFd{{Fd: int32(f.Fd()), Events: unix.POLLIN}}
193+ for {
194+ _, err := unix.Poll(fds, -1)
195+ if !errors.Is(err, unix.EINTR) {
196+ return err
197+ }
198+ }
199+}
M internal/adapters/headless/output_test.go +2 -2
@@ -35,12 +35,12 @@ func (f *frames) snapshot() ([]ports.Scene, []map[ports.WindowID]ports.SurfaceCo
3535 func recordingRenderer(t *testing.T, err error) (*portsmocks.MockRenderer, *frames) {
3636 r := portsmocks.NewMockRenderer(t)
3737 f := &frames{}
38- r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) error {
38+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
3939 f.mu.Lock()
4040 defer f.mu.Unlock()
4141 f.scenes = append(f.scenes, s)
4242 f.contents = append(f.contents, maps.Clone(c))
43- return err
43+ return nil, err
4444 }).Maybe()
4545 r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
4646 r.EXPECT().Close().Return().Once()
A internal/adapters/vulkan/cursor.go +141 -0
@@ -0,0 +1,141 @@
1+package vulkan
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "unsafe"
8+
9+ "github.com/bnema/nefertty/internal/ports"
10+ vk "github.com/bnema/purego-vulkan/vulkan"
11+)
12+
13+// Cursor images for the display's cursor plane: two linear B8G8R8A8
14+// images in host-visible memory, exported as dmabufs. The CPU writes a
15+// cursor image only when the cursor changes, never per frame.
16+
17+type cursorImage struct {
18+ image vk.Image
19+ memory vk.DeviceMemory
20+ mapped unsafe.Pointer
21+ pitch int
22+ size int
23+}
24+
25+// fourccARGB is DRM_FORMAT_ARGB8888: B8G8R8A8 in memory.
26+const fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24
27+
28+// CursorBuffers allocates the two cursor images, replacing earlier ones.
29+func (r *Renderer) CursorBuffers(size int) ([2]ports.DMABuf, error) {
30+ var out [2]ports.DMABuf
31+ r.dropCursors()
32+ if r.dd.GetMemoryFdKHR == nil || size <= 0 || size > 512 {
33+ return out, errors.New("device cannot export cursor images")
34+ }
35+ for i := range r.cursors {
36+ c, buf, err := r.cursorImage(size)
37+ if err != nil {
38+ r.dropCursors()
39+ for _, b := range out[:i] {
40+ b.Planes[0].File.Close()
41+ }
42+ return [2]ports.DMABuf{}, err
43+ }
44+ r.cursors[i], out[i] = c, buf
45+ }
46+ return out, nil
47+}
48+
49+func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
50+ d := r.dd
51+ c := &cursorImage{size: size}
52+ ok := false
53+ defer func() {
54+ if !ok {
55+ r.freeCursor(c)
56+ }
57+ }()
58+ external := vk.ExternalMemoryImageCreateInfo{SType: vk.StructureTypeExternalMemoryImageCreateInfo, HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
59+ ii := vk.ImageCreateInfo{SType: vk.StructureTypeImageCreateInfo, Next: unsafe.Pointer(&external), ImageType: vk.ImageType2d, Format: vk.FormatB8g8r8a8Unorm, Extent: vk.Extent3D{Width: uint32(size), Height: uint32(size), Depth: 1}, MipLevels: 1, ArrayLayers: 1, Samples: vk.SampleCount1Bit, Tiling: vk.ImageTilingLinear, Usage: vk.ImageUsageTransferSrcBit, SharingMode: vk.SharingModeExclusive, InitialLayout: vk.ImageLayoutPreinitialized}
60+ if err := checked("vkCreateImage(cursor)", d.CreateImage(r.device, &ii, nil, &c.image)); err != nil {
61+ return nil, ports.DMABuf{}, err
62+ }
63+ var req vk.MemoryRequirements
64+ d.GetImageMemoryRequirements(r.device, c.image, &req)
65+ kind, err := r.findMemoryType(req.MemoryTypeBits, vk.MemoryPropertyHostVisibleBit|vk.MemoryPropertyHostCoherentBit)
66+ if err != nil {
67+ return nil, ports.DMABuf{}, err
68+ }
69+ dedicated := vk.MemoryDedicatedAllocateInfo{SType: vk.StructureTypeMemoryDedicatedAllocateInfo, Image: c.image}
70+ export := vk.ExportMemoryAllocateInfo{SType: vk.StructureTypeExportMemoryAllocateInfo, Next: unsafe.Pointer(&dedicated), HandleTypes: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
71+ alloc := vk.MemoryAllocateInfo{SType: vk.StructureTypeMemoryAllocateInfo, Next: unsafe.Pointer(&export), AllocationSize: req.Size, MemoryTypeIndex: kind}
72+ if err := checked("vkAllocateMemory(cursor)", d.AllocateMemory(r.device, &alloc, nil, &c.memory)); err != nil {
73+ return nil, ports.DMABuf{}, err
74+ }
75+ if err := checked("vkBindImageMemory(cursor)", d.BindImageMemory(r.device, c.image, c.memory, 0)); err != nil {
76+ return nil, ports.DMABuf{}, err
77+ }
78+ var layout vk.SubresourceLayout
79+ sub := vk.ImageSubresource{AspectMask: vk.ImageAspectColorBit}
80+ d.GetImageSubresourceLayout(r.device, c.image, &sub, &layout)
81+ c.pitch = int(layout.RowPitch)
82+ if err := checked("vkMapMemory(cursor)", d.MapMemory(r.device, c.memory, 0, vk.DeviceSize(req.Size), 0, &c.mapped)); err != nil {
83+ return nil, ports.DMABuf{}, err
84+ }
85+ c.mapped = unsafe.Add(c.mapped, layout.Offset)
86+ clear(unsafe.Slice((*byte)(c.mapped), c.pitch*size))
87+ var fd int32
88+ get := vk.MemoryGetFdInfoKHR{SType: vk.StructureTypeMemoryGetFDInfoKHR, Memory: c.memory, HandleType: vk.ExternalMemoryHandleTypeDMABUFBitEXT}
89+ if err := checked("vkGetMemoryFdKHR(cursor)", d.GetMemoryFdKHR(r.device, &get, &fd)); err != nil {
90+ return nil, ports.DMABuf{}, err
91+ }
92+ f := os.NewFile(uintptr(fd), "nefertty-cursor")
93+ if f == nil {
94+ return nil, ports.DMABuf{}, fmt.Errorf("invalid exported fd %d", fd)
95+ }
96+ ok = true
97+ return c, ports.DMABuf{Width: size, Height: size, Format: fourccARGB, Modifier: 0, Planes: []ports.DMABufPlane{{File: f, Offset: uint32(layout.Offset), Stride: uint32(layout.RowPitch)}}}, nil
98+}
99+
100+// WriteCursor copies premultiplied ARGB8888 pixels (w×h, w*4 per row)
101+// into cursor image i at its row pitch, cropped and cleared around.
102+func (r *Renderer) WriteCursor(i int, pixels []byte, w, h int) error {
103+ if i < 0 || i >= len(r.cursors) || r.cursors[i] == nil {
104+ return errors.New("no cursor image")
105+ }
106+ if w < 0 || h < 0 || len(pixels) < w*h*4 {
107+ return errors.New("short cursor image")
108+ }
109+ c := r.cursors[i]
110+ mem := unsafe.Slice((*byte)(c.mapped), c.pitch*c.size)
111+ clear(mem)
112+ cw := min(w, c.size) * 4
113+ for y := range min(h, c.size) {
114+ copy(mem[y*c.pitch:y*c.pitch+cw], pixels[y*w*4:y*w*4+cw])
115+ }
116+ return nil
117+}
118+
119+func (r *Renderer) freeCursor(c *cursorImage) {
120+ if c.mapped != nil {
121+ r.dd.UnmapMemory(r.device, c.memory)
122+ c.mapped = nil
123+ }
124+ if c.image != 0 {
125+ r.dd.DestroyImage(r.device, c.image, nil)
126+ c.image = 0
127+ }
128+ if c.memory != 0 {
129+ r.dd.FreeMemory(r.device, c.memory, nil)
130+ c.memory = 0
131+ }
132+}
133+
134+func (r *Renderer) dropCursors() {
135+ for i, c := range r.cursors {
136+ if c != nil {
137+ r.freeCursor(c)
138+ r.cursors[i] = nil
139+ }
140+ }
141+}
M internal/adapters/vulkan/dmabuf_test.go +2 -2
@@ -81,7 +81,7 @@ func TestRendererImportsDMABuf(t *testing.T) {
8181 buf := &ports.DMABuf{ID: 7, Width: 64, Height: 16, Format: linear.Format, Planes: []ports.DMABufPlane{{File: f, Stride: 256}}}
8282 scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 4, Y: 4, W: 64, H: 16}}}}
8383 contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 64, Height: 16, DMABuf: buf}}
84- if err := r.Render(scene, contents); err != nil {
84+ if _, err := r.Render(scene, contents); err != nil {
8585 t.Fatal(err)
8686 }
8787 px := r.Pixels()
@@ -98,7 +98,7 @@ func TestRendererImportsDMABuf(t *testing.T) {
9898 }
9999 // Frames without the buffer: its import is released after importTTL.
100100 for range importTTL + 1 {
101- if err := r.Render(scene, nil); err != nil {
101+ if _, err := r.Render(scene, nil); err != nil {
102102 t.Fatal(err)
103103 }
104104 }
M internal/adapters/vulkan/renderer.go +11 -16
@@ -30,7 +30,7 @@ type Renderer struct {
3030 current int
3131 renderMods []uint64
3232 // last is the target of the last frame; readback copies it into
33- // buffer only when Pixels or CopyBGRX asks (stale until then).
33+ // buffer only when Pixels asks (stale until then).
3434 last *target
3535 readBack bool
3636 buffer vk.Buffer
@@ -55,6 +55,8 @@ type Renderer struct {
5555 frame uint64
5656 // fillRegions is reused by Render for the bands of solid fills.
5757 fillRegions []vk.BufferImageCopy
58+ // cursors are the exported cursor images (CursorBuffers).
59+ cursors [2]*cursorImage
5860 // blend draws translucent client pixels (blend.go); align is the
5961 // device's storage buffer offset alignment.
6062 blend blendPipeline
@@ -370,7 +372,7 @@ func (r *Renderer) ensureStaging(size int) error {
370372 }
371373
372374 func (r *Renderer) Clear(rgb [3]uint8) error {
373- return r.Render(ports.Scene{Background: fmt.Sprintf("#%02x%02x%02x", rgb[0], rgb[1], rgb[2])}, nil)
375+ return r.render(ports.Scene{Background: fmt.Sprintf("#%02x%02x%02x", rgb[0], rgb[1], rgb[2])}, nil)
374376 }
375377
376378 // upload is one piece of a frame: a solid fill, client pixels staged from
@@ -427,7 +429,12 @@ func (u *upload) stage(dst []byte) {
427429 }
428430 }
429431
430-func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) error {
432+// Render draws a frame. It still waits for the GPU; done is nil.
433+func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) (done *os.File, err error) {
434+ return nil, r.render(s, contents)
435+}
436+
437+func (r *Renderer) render(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent) error {
431438 // Scene rects are logical; everything below works in physical pixels.
432439 scale := s.Scale
433440 if scale <= 0 {
@@ -810,19 +817,6 @@ func (r *Renderer) Pixels() *image.RGBA {
810817 return out
811818 }
812819
813-// CopyBGRX writes the last frame into an XRGB8888 buffer with the given pitch.
814-// The image is B8G8R8A8, which is already XRGB8888 in memory.
815-func (r *Renderer) CopyBGRX(dst []byte, pitch int) {
816- if r.mapped == nil || r.readback() != nil {
817- return
818- }
819- row := r.width * 4
820- src := unsafe.Slice((*byte)(r.mapped), row*r.height)
821- for y := 0; y < r.height; y++ {
822- copy(dst[y*pitch:y*pitch+row], src[y*row:(y+1)*row])
823- }
824-}
825-
826820 // Missing from the bindings: VK_QUEUE_FAMILY_IGNORED, VK_WHOLE_SIZE.
827821 const (
828822 queueFamilyIgnored = ^uint32(0)
@@ -887,6 +881,7 @@ func (r *Renderer) Close() {
887881 _ = checked("vkDeviceWaitIdle", d.DeviceWaitIdle(r.device))
888882 }
889883 r.dropTargets()
884+ r.dropCursors()
890885 for id, im := range r.imports {
891886 r.release(im)
892887 delete(r.imports, id)
M internal/adapters/vulkan/renderer_test.go +12 -12
@@ -48,7 +48,7 @@ func TestRendererRender(t *testing.T) {
4848 bg := color.RGBA{16, 32, 48, 255}
4949 check := func(s ports.Scene, expected map[image.Point]color.RGBA) {
5050 t.Helper()
51- if err := r.Render(s, nil); err != nil {
51+ if _, err := r.Render(s, nil); err != nil {
5252 t.Fatal(err)
5353 }
5454 for p, want := range expected {
@@ -92,7 +92,7 @@ func TestRendererContents(t *testing.T) {
9292 if c != nil {
9393 contents = map[ports.WindowID]ports.SurfaceContent{1: *c}
9494 }
95- if err := r.Render(scene, contents); err != nil {
95+ if _, err := r.Render(scene, contents); err != nil {
9696 t.Fatal(err)
9797 }
9898 }
@@ -127,7 +127,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
127127 {ID: 1, Rect: ports.Rect{X: 2, Y: 2, W: 16, H: 16}},
128128 {ID: 2, Rect: ports.Rect{X: 8, Y: 8, W: 16, H: 16}},
129129 }}
130- if err := r.Render(scene, nil); err != nil {
130+ if _, err := r.Render(scene, nil); err != nil {
131131 t.Fatal(err)
132132 }
133133 c := windowColor(2)
@@ -143,7 +143,7 @@ func TestRendererUploadOrderAndOpaque(t *testing.T) {
143143 opaque := shmContent(t, 16, 16, 64, px)
144144 opaque.Opaque = true
145145 contents := map[ports.WindowID]ports.SurfaceContent{1: *opaque}
146- if err := r.Render(scene, contents); err != nil {
146+ if _, err := r.Render(scene, contents); err != nil {
147147 t.Fatal(err)
148148 }
149149 for _, tc := range []struct {
@@ -174,7 +174,7 @@ func TestRendererLayers(t *testing.T) {
174174 window := color.RGBA{wc[0], wc[1], wc[2], 255}
175175 check := func(want color.RGBA) {
176176 t.Helper()
177- if err := r.Render(scene, contents); err != nil {
177+ if _, err := r.Render(scene, contents); err != nil {
178178 t.Fatal(err)
179179 }
180180 if got := r.Pixels().RGBAAt(5, 2); got != want {
@@ -218,7 +218,7 @@ func TestRendererScale(t *testing.T) {
218218 scaled := shmContent(t, 20, 20, 80, solid(20, 20, red))
219219 scaled.ID, scaled.LogicalW, scaled.LogicalH = 1, 10, 10
220220 content := map[ports.WindowID]ports.SurfaceContent{1: *scaled}
221- if err := r.Render(s, content); err != nil {
221+ if _, err := r.Render(s, content); err != nil {
222222 t.Fatal(err)
223223 }
224224 px := r.Pixels()
@@ -236,7 +236,7 @@ func TestRendererScale(t *testing.T) {
236236 // Scale 1.5 with an integer-scale client (buffer scale 2): the 20x20
237237 // buffer is 10x10 logical and downscaled to 15x15 physical at (6,6).
238238 s.Scale = 1.5
239- if err := r.Render(s, content); err != nil {
239+ if _, err := r.Render(s, content); err != nil {
240240 t.Fatal(err)
241241 }
242242 px = r.Pixels()
@@ -252,7 +252,7 @@ func TestRendererScale(t *testing.T) {
252252 odd := shmContent(t, 16, 15, 64, solid(16, 15, red))
253253 odd.ID, odd.LogicalW, odd.LogicalH = 1, 10, 10
254254 content[1] = *odd
255- if err := r.Render(s, content); err != nil {
255+ if _, err := r.Render(s, content); err != nil {
256256 t.Fatal(err)
257257 }
258258 if got := r.Pixels().At(21, 10); got != (color.RGBA{0, 0, 0, 255}) {
@@ -309,7 +309,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
309309 {X: 3, Y: 3, Below: true, SurfaceContent: solidContent(t, 30, 20, blue)},
310310 {X: 5, Y: 5, SurfaceContent: solidContent(t, 4, 4, green)},
311311 }
312- if err := r.Render(scene, map[ports.WindowID]ports.SurfaceContent{1: root}); err != nil {
312+ if _, err := r.Render(scene, map[ports.WindowID]ports.SurfaceContent{1: root}); err != nil {
313313 t.Fatal(err)
314314 }
315315 px := r.Pixels()
@@ -327,7 +327,7 @@ func TestRendererGeometryAndSubsurfaces(t *testing.T) {
327327 }
328328 // A root with no buffer of its own still shows its children.
329329 only := ports.SurfaceContent{Children: []ports.Subsurface{{SurfaceContent: solidContent(t, 20, 10, blue)}}}
330- if err := r.Render(scene, map[ports.WindowID]ports.SurfaceContent{1: only}); err != nil {
330+ if _, err := r.Render(scene, map[ports.WindowID]ports.SurfaceContent{1: only}); err != nil {
331331 t.Fatal(err)
332332 }
333333 if got := r.Pixels().At(20, 15); got != blue {
@@ -345,7 +345,7 @@ func TestRendererTallFill(t *testing.T) {
345345 }
346346 defer r.Close()
347347 scene := ports.Scene{Background: "#102030", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 1, Y: 0, W: 6, H: h}}}}
348- if err := r.Render(scene, nil); err != nil {
348+ if _, err := r.Render(scene, nil); err != nil {
349349 t.Fatal(err)
350350 }
351351 c := windowColor(1)
@@ -403,7 +403,7 @@ func TestRendererAlphaBlend(t *testing.T) {
403403 {ID: 3, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 16, H: 8}},
404404 {ID: 4, Layer: ports.LayerOverlay, Rect: ports.Rect{X: 8, W: 16, H: 8}},
405405 }}
406- if err := r.Render(scene, contents); err != nil {
406+ if _, err := r.Render(scene, contents); err != nil {
407407 t.Fatal(err)
408408 }
409409 px := r.Pixels()
M internal/adapters/vulkan/targets.go +2 -2
@@ -17,7 +17,7 @@ import (
1717 // turns into KMS framebuffers. Render then composes straight into the
1818 // selected target and the output flips to it; nothing is copied by the
1919 // CPU. The internal image is the target until then (headless, tests).
20-// Readback into host memory happens only for Pixels and CopyBGRX.
20+// Readback into host memory happens only for Pixels (screenshots).
2121
2222 // target is an image frames are drawn into.
2323 type target struct {
@@ -107,7 +107,7 @@ func (r *Renderer) probeRenderModifiers(physical vk.PhysicalDevice) {
107107 r.id.GetPhysicalDeviceFormatProperties2(physical, vk.FormatB8g8r8a8Unorm, &fp)
108108 need := vk.FormatFeatureFlags(formatFeatureTransferDst | formatFeatureTransferSrc | vk.FormatFeatureBlitDstBit | vk.FormatFeatureColorAttachmentBit | vk.FormatFeatureColorAttachmentBlendBit)
109109 // Targets are drawn on too (blend.go): a transfer-only modifier is
110- // not a target, and without any the display falls back to readback.
110+ // not a target, and without any the output cannot start.
111111 for _, m := range mods[:list.DrmFormatModifierCount] {
112112 if m.DrmFormatModifierPlaneCount == 1 && m.DrmFormatModifierTilingFeatures&need == need && r.exportable(physical, m.DrmFormatModifier) {
113113 r.renderMods = append(r.renderMods, m.DrmFormatModifier)
M internal/adapters/vulkan/targets_test.go +2 -2
@@ -31,7 +31,7 @@ func TestExportTargets(t *testing.T) {
3131 colors := []string{"#ff0000", "#0000ff"}
3232 for i, c := range colors {
3333 r.UseTarget(i)
34- if err := r.Render(ports.Scene{Background: c}, nil); err != nil {
34+ if _, err := r.Render(ports.Scene{Background: c}, nil); err != nil {
3535 t.Fatal(err)
3636 }
3737 }
@@ -55,7 +55,7 @@ func TestExportTargets(t *testing.T) {
5555 }
5656 scene := ports.Scene{Background: "#000000", Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 32}}}}
5757 contents := map[ports.WindowID]ports.SurfaceContent{1: {ID: 1, Width: 64, Height: 32, Opaque: true, DMABuf: &b}}
58- if err := viewer.Render(scene, contents); err != nil {
58+ if _, err := viewer.Render(scene, contents); err != nil {
5959 t.Fatal(err)
6060 }
6161 if got := viewer.Pixels().At(10, 10); got != (color.RGBA{255, 0, 0, 255}) {
M internal/adapters/wayland/frames.go +2 -2
@@ -34,7 +34,7 @@ func (s *Server) pace(ctx context.Context) {
3434 case <-s.display.Stopped():
3535 return
3636 case p := <-presented:
37- flipped[p.Output] = flipped[p.Output] || p.Flip
37+ flipped[p.Output] = flipped[p.Output] || p.Flip != nil
3838 reports = append(reports, p)
3939 case <-s.frameReady:
4040 case <-timer.C:
@@ -45,7 +45,7 @@ func (s *Server) pace(ctx context.Context) {
4545 for {
4646 select {
4747 case p := <-presented:
48- flipped[p.Output] = flipped[p.Output] || p.Flip
48+ flipped[p.Output] = flipped[p.Output] || p.Flip != nil
4949 reports = append(reports, p)
5050 default:
5151 break drain
M internal/mocks/ports/devices_mock.go +128 -31
@@ -6,6 +6,7 @@ package portsmocks
66
77 import (
88 "image"
9+ "os"
910
1011 "github.com/bnema/nefertty/internal/ports"
1112 mock "github.com/stretchr/testify/mock"
@@ -80,49 +81,65 @@ func (_c *MockRenderer_Close_Call) RunAndReturn(run func()) *MockRenderer_Close_
8081 return _c
8182 }
8283
83-// CopyBGRX provides a mock function for the type MockRenderer
84-func (_mock *MockRenderer) CopyBGRX(dst []byte, pitch int) {
85- _mock.Called(dst, pitch)
86- return
84+// CursorBuffers provides a mock function for the type MockRenderer
85+func (_mock *MockRenderer) CursorBuffers(size int) ([2]ports.DMABuf, error) {
86+ ret := _mock.Called(size)
87+
88+ if len(ret) == 0 {
89+ panic("no return value specified for CursorBuffers")
90+ }
91+
92+ var r0 [2]ports.DMABuf
93+ var r1 error
94+ if returnFunc, ok := ret.Get(0).(func(int) ([2]ports.DMABuf, error)); ok {
95+ return returnFunc(size)
96+ }
97+ if returnFunc, ok := ret.Get(0).(func(int) [2]ports.DMABuf); ok {
98+ r0 = returnFunc(size)
99+ } else {
100+ if ret.Get(0) != nil {
101+ r0 = ret.Get(0).([2]ports.DMABuf)
102+ }
103+ }
104+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
105+ r1 = returnFunc(size)
106+ } else {
107+ r1 = ret.Error(1)
108+ }
109+ return r0, r1
87110 }
88111
89-// MockRenderer_CopyBGRX_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CopyBGRX'
90-type MockRenderer_CopyBGRX_Call struct {
112+// MockRenderer_CursorBuffers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CursorBuffers'
113+type MockRenderer_CursorBuffers_Call struct {
91114 *mock.Call
92115 }
93116
94-// CopyBGRX is a helper method to define mock.On call
95-// - dst []byte
96-// - pitch int
97-func (_e *MockRenderer_Expecter) CopyBGRX(dst any, pitch any) *MockRenderer_CopyBGRX_Call {
98- return &MockRenderer_CopyBGRX_Call{Call: _e.mock.On("CopyBGRX", dst, pitch)}
117+// CursorBuffers is a helper method to define mock.On call
118+// - size int
119+func (_e *MockRenderer_Expecter) CursorBuffers(size any) *MockRenderer_CursorBuffers_Call {
120+ return &MockRenderer_CursorBuffers_Call{Call: _e.mock.On("CursorBuffers", size)}
99121 }
100122
101-func (_c *MockRenderer_CopyBGRX_Call) Run(run func(dst []byte, pitch int)) *MockRenderer_CopyBGRX_Call {
123+func (_c *MockRenderer_CursorBuffers_Call) Run(run func(size int)) *MockRenderer_CursorBuffers_Call {
102124 _c.Call.Run(func(args mock.Arguments) {
103- var arg0 []byte
125+ var arg0 int
104126 if args[0] != nil {
105- arg0 = args[0].([]byte)
106- }
107- var arg1 int
108- if args[1] != nil {
109- arg1 = args[1].(int)
127+ arg0 = args[0].(int)
110128 }
111129 run(
112130 arg0,
113- arg1,
114131 )
115132 })
116133 return _c
117134 }
118135
119-func (_c *MockRenderer_CopyBGRX_Call) Return() *MockRenderer_CopyBGRX_Call {
120- _c.Call.Return()
136+func (_c *MockRenderer_CursorBuffers_Call) Return(dMABufs [2]ports.DMABuf, err error) *MockRenderer_CursorBuffers_Call {
137+ _c.Call.Return(dMABufs, err)
121138 return _c
122139 }
123140
124-func (_c *MockRenderer_CopyBGRX_Call) RunAndReturn(run func(dst []byte, pitch int)) *MockRenderer_CopyBGRX_Call {
125- _c.Run(run)
141+func (_c *MockRenderer_CursorBuffers_Call) RunAndReturn(run func(size int) ([2]ports.DMABuf, error)) *MockRenderer_CursorBuffers_Call {
142+ _c.Call.Return(run)
126143 return _c
127144 }
128145
@@ -241,20 +258,31 @@ func (_c *MockRenderer_Pixels_Call) RunAndReturn(run func() *image.RGBA) *MockRe
241258 }
242259
243260 // Render provides a mock function for the type MockRenderer
244-func (_mock *MockRenderer) Render(scene ports.Scene, windowIDToSurfaceContent map[ports.WindowID]ports.SurfaceContent) error {
261+func (_mock *MockRenderer) Render(scene ports.Scene, windowIDToSurfaceContent map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
245262 ret := _mock.Called(scene, windowIDToSurfaceContent)
246263
247264 if len(ret) == 0 {
248265 panic("no return value specified for Render")
249266 }
250267
251- var r0 error
252- if returnFunc, ok := ret.Get(0).(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error); ok {
268+ var r0 *os.File
269+ var r1 error
270+ if returnFunc, ok := ret.Get(0).(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error)); ok {
271+ return returnFunc(scene, windowIDToSurfaceContent)
272+ }
273+ if returnFunc, ok := ret.Get(0).(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) *os.File); ok {
253274 r0 = returnFunc(scene, windowIDToSurfaceContent)
254275 } else {
255- r0 = ret.Error(0)
276+ if ret.Get(0) != nil {
277+ r0 = ret.Get(0).(*os.File)
278+ }
256279 }
257- return r0
280+ if returnFunc, ok := ret.Get(1).(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error); ok {
281+ r1 = returnFunc(scene, windowIDToSurfaceContent)
282+ } else {
283+ r1 = ret.Error(1)
284+ }
285+ return r0, r1
258286 }
259287
260288 // MockRenderer_Render_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Render'
@@ -287,12 +315,12 @@ func (_c *MockRenderer_Render_Call) Run(run func(scene ports.Scene, windowIDToSu
287315 return _c
288316 }
289317
290-func (_c *MockRenderer_Render_Call) Return(err error) *MockRenderer_Render_Call {
291- _c.Call.Return(err)
318+func (_c *MockRenderer_Render_Call) Return(done *os.File, err error) *MockRenderer_Render_Call {
319+ _c.Call.Return(done, err)
292320 return _c
293321 }
294322
295-func (_c *MockRenderer_Render_Call) RunAndReturn(run func(scene ports.Scene, windowIDToSurfaceContent map[ports.WindowID]ports.SurfaceContent) error) *MockRenderer_Render_Call {
323+func (_c *MockRenderer_Render_Call) RunAndReturn(run func(scene ports.Scene, windowIDToSurfaceContent map[ports.WindowID]ports.SurfaceContent) (*os.File, error)) *MockRenderer_Render_Call {
296324 _c.Call.Return(run)
297325 return _c
298326 }
@@ -337,6 +365,75 @@ func (_c *MockRenderer_UseTarget_Call) RunAndReturn(run func(i int)) *MockRender
337365 return _c
338366 }
339367
368+// WriteCursor provides a mock function for the type MockRenderer
369+func (_mock *MockRenderer) WriteCursor(i int, pixels []byte, w int, h int) error {
370+ ret := _mock.Called(i, pixels, w, h)
371+
372+ if len(ret) == 0 {
373+ panic("no return value specified for WriteCursor")
374+ }
375+
376+ var r0 error
377+ if returnFunc, ok := ret.Get(0).(func(int, []byte, int, int) error); ok {
378+ r0 = returnFunc(i, pixels, w, h)
379+ } else {
380+ r0 = ret.Error(0)
381+ }
382+ return r0
383+}
384+
385+// MockRenderer_WriteCursor_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteCursor'
386+type MockRenderer_WriteCursor_Call struct {
387+ *mock.Call
388+}
389+
390+// WriteCursor is a helper method to define mock.On call
391+// - i int
392+// - pixels []byte
393+// - w int
394+// - h int
395+func (_e *MockRenderer_Expecter) WriteCursor(i any, pixels any, w any, h any) *MockRenderer_WriteCursor_Call {
396+ return &MockRenderer_WriteCursor_Call{Call: _e.mock.On("WriteCursor", i, pixels, w, h)}
397+}
398+
399+func (_c *MockRenderer_WriteCursor_Call) Run(run func(i int, pixels []byte, w int, h int)) *MockRenderer_WriteCursor_Call {
400+ _c.Call.Run(func(args mock.Arguments) {
401+ var arg0 int
402+ if args[0] != nil {
403+ arg0 = args[0].(int)
404+ }
405+ var arg1 []byte
406+ if args[1] != nil {
407+ arg1 = args[1].([]byte)
408+ }
409+ var arg2 int
410+ if args[2] != nil {
411+ arg2 = args[2].(int)
412+ }
413+ var arg3 int
414+ if args[3] != nil {
415+ arg3 = args[3].(int)
416+ }
417+ run(
418+ arg0,
419+ arg1,
420+ arg2,
421+ arg3,
422+ )
423+ })
424+ return _c
425+}
426+
427+func (_c *MockRenderer_WriteCursor_Call) Return(err error) *MockRenderer_WriteCursor_Call {
428+ _c.Call.Return(err)
429+ return _c
430+}
431+
432+func (_c *MockRenderer_WriteCursor_Call) RunAndReturn(run func(i int, pixels []byte, w int, h int) error) *MockRenderer_WriteCursor_Call {
433+ _c.Call.Return(run)
434+ return _c
435+}
436+
340437 // NewMockSeat creates a new instance of MockSeat. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
341438 // The first argument is typically a *testing.T value.
342439 func NewMockSeat(t interface {
M internal/ports/devices.go +14 -6
@@ -1,12 +1,17 @@
11 package ports
22
3-import "image"
3+import (
4+ "image"
5+ "os"
6+)
47
58 // Renderer draws scenes for an output. One goroutine owns it. Frames go
69 // to its own image until ExportTargets gives it scanout images; then each
7-// Render draws into the target chosen by UseTarget (ADR 014: no CPU copy).
10+// Render draws into the target chosen by UseTarget (ADR 014: no CPU pass).
811 type Renderer interface {
9- Render(Scene, map[WindowID]SurfaceContent) error
12+ // Render draws the scene. done is a sync file signalled when the GPU
13+ // finished the frame (nil: already finished); the caller closes it.
14+ Render(Scene, map[WindowID]SurfaceContent) (done *os.File, err error)
1015 // ExportTargets allocates n images of the renderer's size that the
1116 // display can scan out, with one of the given XRGB8888 modifiers
1217 // (none: any the device exports), and returns them as dmabufs.
@@ -14,11 +19,14 @@ type Renderer interface {
1419 ExportTargets(n int, modifiers []uint64) ([]DMABuf, error)
1520 // UseTarget selects the exported image the next Render draws into.
1621 UseTarget(i int)
22+ // CursorBuffers allocates two linear ARGB8888 images of size×size the
23+ // display can show on its cursor plane, and returns them as dmabufs.
24+ CursorBuffers(size int) ([2]DMABuf, error)
25+ // WriteCursor fills cursor image i with premultiplied ARGB8888 pixels
26+ // (w×h, w*4 per row), cropped to the image and cleared around it.
27+ WriteCursor(i int, pixels []byte, w, h int) error
1728 // Pixels reads the last frame back (headless screenshots, tests).
1829 Pixels() *image.RGBA
19- // CopyBGRX reads the last frame back as XRGB8888 rows of the given
20- // pitch: the logged fallback when no image can be exported.
21- CopyBGRX(dst []byte, pitch int)
2230 Close()
2331 }
2432
M internal/ports/messages.go +27 -6
@@ -1,6 +1,9 @@
11 package ports
22
3-import "os"
3+import (
4+ "os"
5+ "time"
6+)
47
58 // ClientEvent carries wayland → core notifications.
69 type ClientEvent interface{ clientEvent() }
@@ -315,17 +318,35 @@ func (OutputAdded) outputEvent() {}
315318 // OutputPresented carries output → wayland what an output shows and reads.
316319 // Flip is set when a page flip completed: frame callbacks of the surfaces
317320 // on it are due. Shown and Queued are the DMABuf IDs scanned out directly
318-// (0: a composed image); Seen is the latest content Seq the output got per
319-// window. A replaced client buffer is released once every output that
320-// reports has seen a later content of its window and neither shows nor
321-// queues it.
321+// (0: a composed image); Seen is the latest content Seq per window the
322+// output finished reading (the GPU is done with it). A replaced client
323+// buffer is released once every output that reports has seen a later
324+// content of its window and neither shows nor queues it.
322325 type OutputPresented struct {
323326 Output string
324- Flip bool
327+ Flip *FlipInfo
325328 Shown, Queued uint64
326329 Seen map[WindowID]uint64
327330 }
328331
332+// FlipInfo is one completed page flip. When is its CLOCK_MONOTONIC time
333+// (hardware clock on DRM), Seq the output's vblank counter, Refresh the
334+// refresh period (0 while variable refresh is on). ZeroCopy is set when a
335+// client buffer was shown without composition, Async for a tearing flip.
336+// Shows is the content Seq per window the flipped frame shows. Merged
337+// counts earlier flips folded into this one when the reader fell behind:
338+// their presentation feedback is discarded.
339+type FlipInfo struct {
340+ When time.Duration
341+ Seq uint64
342+ Refresh time.Duration
343+ ZeroCopy bool
344+ Async bool
345+ HardwareClock bool
346+ Merged int
347+ Shows map[WindowID]uint64
348+}
349+
329350 // OutputRemoved carries output → core an unplugged display.
330351 type OutputRemoved struct{ Name string }
331352

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 25

Tags 10

↑↓ moveenter openesc close