detached cb321d4

fix(drm): serial-tagged commit events, cursor plane fallback and TEST_ONLY image validation

Commit events carry a per-card serial so stale events never complete a newer commit. A refused cursor plane turns the hardware cursor off instead of stopping the output or blaming scanout buffers; cursor images are never written while on screen or in flight. Output images are validated with a TEST_ONLY modeset, Close turns the CRTC off when no mode is restored, and sync file waits move to a shared syncfile package that honours ctx and poll errors.
commit
cb321d425a6091131085f4d2156611716e3f9063
author
brice · · 2026-09-26 11:03 UTC
parent
7449251
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +758 -121
M internal/adapters/drm/atomic.go +1 -1
@@ -236,7 +236,7 @@ func enableAtomic(fd int) error {
236236 for _, c := range []uint64{clientCapUniversalPlanes, clientCapAtomic} {
237237 v := setClientCap{capability: c, value: 1}
238238 if err := ioctl(fd, ioctlSetClientCap, unsafe.Pointer(&v)); err != nil {
239- return errors.Join(errors.New("atomic modesetting unsupported"), err)
239+ return err
240240 }
241241 }
242242 if !hasCap(fd, capTimestampMonotonic) {
@@ -236,7 +236,7 @@ func enableAtomic(fd int) error {
236 for _, c := range []uint64{clientCapUniversalPlanes, clientCapAtomic} {236 for _, c := range []uint64{clientCapUniversalPlanes, clientCapAtomic} {
237 v := setClientCap{capability: c, value: 1}237 v := setClientCap{capability: c, value: 1}
238 if err := ioctl(fd, ioctlSetClientCap, unsafe.Pointer(&v)); err != nil {238 if err := ioctl(fd, ioctlSetClientCap, unsafe.Pointer(&v)); err != nil {
239- return errors.Join(errors.New("atomic modesetting unsupported"), err)239+ return err
240 }240 }
241 }241 }
242 if !hasCap(fd, capTimestampMonotonic) {242 if !hasCap(fd, capTimestampMonotonic) {
M internal/adapters/drm/card.go +4 -1
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "slices"
88 "sync"
9+ "sync/atomic"
910
1011 "github.com/bnema/zerowrap"
1112 "golang.org/x/sys/unix"
@@ -29,13 +30,15 @@ type Card struct {
2930 taken map[uint32]bool
3031 // async is DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP.
3132 async bool
33+ // serials numbers the commits of every output (event userData).
34+ serials atomic.Uint64
3235 }
3336
3437 // OpenCard turns on atomic modesetting and reads the card's CRTCs. fd
3538 // stays owned by the caller. A card without atomic KMS is refused.
3639 func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error) {
3740 if err := enableAtomic(fd); err != nil {
38- return nil, fmt.Errorf("%s: %w", path, err)
41+ return nil, fmt.Errorf("drm: atomic modesetting unsupported by %s: %w", path, err)
3942 }
4043 crtcs, _, err := resources(fd)
4144 if err != nil {
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "slices"7 "slices"
8 "sync"8 "sync"
9+ "sync/atomic"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
11 "golang.org/x/sys/unix"12 "golang.org/x/sys/unix"
@@ -29,13 +30,15 @@ type Card struct {
29 taken map[uint32]bool30 taken map[uint32]bool
30 // async is DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP.31 // async is DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP.
31 async bool32 async bool
33+ // serials numbers the commits of every output (event userData).
34+ serials atomic.Uint64
32 }35 }
33 36
34 // OpenCard turns on atomic modesetting and reads the card's CRTCs. fd37 // 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.38 // stays owned by the caller. A card without atomic KMS is refused.
36 func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error) {39 func OpenCard(fd int, path string, want Want, log zerowrap.Logger) (*Card, error) {
37 if err := enableAtomic(fd); err != nil {40 if err := enableAtomic(fd); err != nil {
38- return nil, fmt.Errorf("%s: %w", path, err)41+ return nil, fmt.Errorf("drm: atomic modesetting unsupported by %s: %w", path, err)
39 }42 }
40 crtcs, _, err := resources(fd)43 crtcs, _, err := resources(fd)
41 if err != nil {44 if err != nil {
A internal/adapters/drm/commit_test.go +334 -0
@@ -0,0 +1,334 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/nefertty/internal/mocks/ports"
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// An event queued before a modeset (or for an earlier output on the CRTC)
16+// must not complete the commit that follows.
17+func TestStaleEventDoesNotCompleteNewCommit(t *testing.T) {
18+ o, _, commits := testOutput(t)
19+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
20+ t.Fatal(err)
21+ }
22+ old := eventOf((*commits)[0])
23+ o.pending = false // a modeset dropped the commit; its event is queued
24+ if err := o.commitFrame(71, nil, false, false, pendingFrame{}); err != nil {
25+ t.Fatal(err)
26+ }
27+ if o.completed(old, nil) || !o.pending {
28+ t.Fatal("stale event completed the new commit")
29+ }
30+ if !o.completed(eventOf((*commits)[1]), nil) || o.pending {
31+ t.Fatal("own event did not complete the commit")
32+ }
33+}
34+
35+// After EBUSY no commit of ours is known: the next event of the CRTC ends
36+// the wait, then the frame commits.
37+func TestBusyFrameCommitsAfterEvent(t *testing.T) {
38+ o, _, commits := testOutput(t, unix.EBUSY)
39+ enabled := true
40+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); !o.commitFailed(err, &enabled) {
41+ t.Fatalf("EBUSY not handled: %v", err)
42+ }
43+ if !o.completed(flipEvent{crtc: tCrtc, user: 12345 << userKindBits}, nil) || o.pending {
44+ t.Fatal("EBUSY wait not ended by the CRTC's event")
45+ }
46+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil || len(*commits) != 2 || !o.pending {
47+ t.Fatalf("frame after EBUSY: %v, %d commits", err, len(*commits))
48+ }
49+}
50+
51+// A cursor image change writes only an image the plane neither shows nor
52+// is about to show; with both busy it waits for the event.
53+func TestCursorImageNeverOverwritesScreen(t *testing.T) {
54+ o, _, _ := testOutput(t)
55+ c := o.cursor
56+ r := portsmocks.NewMockRenderer(t)
57+ var written []int
58+ r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
59+ written = append(written, i)
60+ return nil
61+ })
62+ px := []byte{1, 2, 3, 4}
63+ // Image 0 on screen: two changes before a commit both go to image 1.
64+ c.screen, c.cur, c.image = 90, 0, true
65+ for range 2 {
66+ if err := c.setImage(r, px, 1, 1, 0, 0); err != nil {
67+ t.Fatal(err)
68+ }
69+ }
70+ if len(written) != 2 || written[0] != 1 || written[1] != 1 {
71+ t.Fatalf("written %v", written)
72+ }
73+ // Image 1 in flight, image 0 on screen: nothing is free.
74+ c.committed(cursorState{on: true, fb: 91})
75+ if err := c.setImage(r, px, 1, 1, 0, 0); err != nil || len(written) != 2 || c.later == nil {
76+ t.Fatalf("wrote a busy image: %v %v", err, written)
77+ }
78+ // The flip frees image 0.
79+ c.landed()
80+ if changed, err := c.flushLater(r); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
81+ t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)
82+ }
83+}
84+
85+// KMS refusing the cursor plane turns the hardware cursor off and the
86+// frame commits without it; the output keeps running.
87+func TestCursorRefusedFrameCommitsWithout(t *testing.T) {
88+ o, _, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
89+ o.cursor.image = true
90+ o.cursor.Move(5, 5)
91+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
92+ t.Fatal(err)
93+ }
94+ if !o.cursor.off || len(*commits) != 3 || (*commits)[1].flags != atomicTestOnly {
95+ t.Fatalf("off=%v commits %d", o.cursor.off, len(*commits))
96+ }
97+ if v, _ := (*commits)[2].req.value(tCursor, pFB); v != 0 {
98+ t.Fatalf("retry shows cursor fb %d", v)
99+ }
100+}
101+
102+// A refused cursor is not blamed on the client buffer.
103+func TestCursorRefusedDoesNotBlameScanoutBuffer(t *testing.T) {
104+ o, _, _ := testOutput(t, unix.EINVAL, unix.EINVAL)
105+ o.cursor.image = true
106+ o.cursor.Move(5, 5)
107+ o.clientFBs[9] = &clientFB{fbID: 80}
108+ direct, err := o.commitScanout(80, ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 9}}, pendingFrame{})
109+ if err != nil || !direct || o.clientFBs[9].failed != "" {
110+ t.Fatalf("direct=%v err=%v failed=%q", direct, err, o.clientFBs[9].failed)
111+ }
112+}
113+
114+// A cursor-only commit's event still reports content read meanwhile.
115+func TestCursorEventReportsSeen(t *testing.T) {
116+ o, _, commits := testOutput(t)
117+ o.cursor.image = true
118+ o.cursor.Move(1, 1)
119+ if err := o.commitState(false); err != nil {
120+ t.Fatal(err)
121+ }
122+ seen := map[ports.WindowID]uint64{2: 5}
123+ if !o.completed(eventOf((*commits)[0]), seen) {
124+ t.Fatal("event not taken")
125+ }
126+ // Run reports Seen at the top of its loop once nothing is pending.
127+ if o.pending {
128+ t.Fatal("still pending")
129+ }
130+}
131+
132+// showImages validates the images with a TEST_ONLY modeset and falls back
133+// to linear images when KMS refuses the driver's.
134+func TestShowImagesTestsBeforeModeset(t *testing.T) {
135+ o, k, commits := testOutput(t, unix.EINVAL)
136+ o.cursor = nil
137+ r := portsmocks.NewMockRenderer(t)
138+ buf := func() ports.DMABuf {
139+ f, w, _ := os.Pipe()
140+ w.Close()
141+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
142+ }
143+ r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
144+ r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
145+ r.EXPECT().UseTarget(mock.Anything).Return()
146+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
147+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
148+ k.EXPECT().rmFB(mock.Anything).Return(nil)
149+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
150+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
151+ if err := o.showImages(r, imagesDriver, nil); err != nil {
152+ t.Fatal(err)
153+ }
154+ want := []uint32{atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicAllowModes}
155+ if len(*commits) != len(want) {
156+ t.Fatalf("%d commits", len(*commits))
157+ }
158+ for i, f := range want {
159+ if (*commits)[i].flags != f {
160+ t.Fatalf("commit %d flags %#x want %#x", i, (*commits)[i].flags, f)
161+ }
162+ }
163+ if o.kind != imagesLinear {
164+ t.Fatalf("kind %d", o.kind)
165+ }
166+}
167+
168+// Enabling an output whose images were not made yet (started switched
169+// away) tests them and modesets once.
170+func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
171+ o, k, commits, commitMu := testOutputMu(t)
172+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
173+ r := portsmocks.NewMockRenderer(t)
174+ buf := func() ports.DMABuf {
175+ f, w, _ := os.Pipe()
176+ w.Close()
177+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
178+ }
179+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
180+ r.EXPECT().UseTarget(mock.Anything).Return()
181+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
182+ r.EXPECT().Close().Return().Once()
183+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
184+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
185+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
186+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
187+ active := make(chan bool, 1)
188+ active <- false
189+ ctx, cancel := context.WithCancel(context.Background())
190+ done := make(chan error, 1)
191+ go func() {
192+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1))
193+ }()
194+ count := func(flags uint32) int {
195+ commitMu.Lock()
196+ defer commitMu.Unlock()
197+ n := 0
198+ for _, c := range *commits {
199+ if c.flags == flags {
200+ n++
201+ }
202+ }
203+ return n
204+ }
205+ time.Sleep(20 * time.Millisecond)
206+ if n := count(atomicAllowModes); n != 0 {
207+ t.Fatalf("%d modesets while away", n)
208+ }
209+ active <- true
210+ waitFor(t, func() bool { return count(atomicAllowModes) == 1 })
211+ cancel()
212+ if err := <-done; err != nil {
213+ t.Fatal(err)
214+ }
215+ if n := count(atomicTestOnly | atomicAllowModes); n != 1 {
216+ t.Fatalf("%d test modesets", n)
217+ }
218+}
219+
220+// Close without a mode to restore turns the CRTC off.
221+func TestCloseWithoutSavedModeDisablesCrtc(t *testing.T) {
222+ o, k, commits := testOutput(t)
223+ k.EXPECT().rmFB(mock.Anything).Return(nil)
224+ o.Close()
225+ c := (*commits)[0]
226+ for _, p := range []struct{ obj, prop uint32 }{{tCrtc, pActive}, {tCrtc, pMode}, {tPrimary, pFB}, {tConn, pConnCrtc}} {
227+ if v, ok := c.req.value(p.obj, p.prop); !ok || v != 0 {
228+ t.Fatalf("object %d prop %d = %d %v", p.obj, p.prop, v, ok)
229+ }
230+ }
231+}
232+
233+// Close restores the saved mode and gives the connector its CRTC back.
234+func TestCloseRestoresSavedMode(t *testing.T) {
235+ o, k, commits := testOutput(t)
236+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 5, modeValid: 1, mode: modeInfo{HDisplay: 200, VDisplay: 100}}
237+ k.EXPECT().createBlob(mock.Anything).Return(98, nil).Once()
238+ k.EXPECT().destroyBlob(uint32(98)).Return(nil).Once()
239+ k.EXPECT().rmFB(mock.Anything).Return(nil)
240+ o.Close()
241+ c := (*commits)[0]
242+ if v, _ := c.req.value(tConn, pConnCrtc); v != tCrtc {
243+ t.Fatalf("connector crtc %d", v)
244+ }
245+ if v, _ := c.req.value(tPrimary, pFB); v != 5 {
246+ t.Fatalf("primary fb %d", v)
247+ }
248+}
249+
250+// With the cursor off no image is loaded (no warning per change).
251+func TestCursorOffSkipsLoads(t *testing.T) {
252+ o, _, _ := testOutput(t)
253+ o.cursor.off = true
254+ loaded := false
255+ o.setCursor(nil, func(ports.CursorChange, float64, int) (ports.CursorImage, error) {
256+ loaded = true
257+ return ports.CursorImage{}, nil
258+ }, ports.CursorChange{}, 1)
259+ if loaded || o.cursor.desired().on {
260+ t.Fatal("cursor used while off")
261+ }
262+}
263+
264+// Content read while only a cursor commit is in flight is reported when
265+// that commit's event arrives, not held until a later frame.
266+func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
267+ o, k, commits, commitMu := testOutputMu(t)
268+ o.tearing, o.cursor.fbs = false, [2]uint32{}
269+ flips := make(chan flipEvent, 1)
270+ o.flipped = flips
271+ r := portsmocks.NewMockRenderer(t)
272+ buf := func() ports.DMABuf {
273+ f, w, _ := os.Pipe()
274+ w.Close()
275+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
276+ }
277+ r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()
278+ r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)
279+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
280+ r.EXPECT().UseTarget(mock.Anything).Return()
281+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
282+ r.EXPECT().Close().Return().Once()
283+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()
284+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()
285+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
286+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
287+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
288+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
289+ load := func(ports.CursorChange, float64, int) (ports.CursorImage, error) {
290+ return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil
291+ }
292+ last := func(kind uint64) (commitRec, bool) {
293+ commitMu.Lock()
294+ defer commitMu.Unlock()
295+ for i := len(*commits) - 1; i >= 0; i-- {
296+ if c := (*commits)[i]; c.user&3 == kind {
297+ return c, true
298+ }
299+ }
300+ return commitRec{}, false
301+ }
302+ scenes := make(chan ports.Scene, 1)
303+ contents := make(chan ports.SurfaceContent, 1)
304+ presented := make(chan ports.OutputPresented, 8)
305+ ctx, cancel := context.WithCancel(context.Background())
306+ done := make(chan error, 1)
307+ go func() {
308+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents, nil, presented)
309+ }()
310+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
311+ var frame commitRec
312+ waitFor(t, func() bool { var ok bool; frame, ok = last(userFrame); return ok })
313+ flips <- eventOf(frame)
314+ <-presented // the frame's flip
315+ o.cursor.Move(3, 3)
316+ var cur commitRec
317+ waitFor(t, func() bool { var ok bool; cur, ok = last(userState); return ok })
318+ // A window this output does not show commits while the cursor flies.
319+ contents <- ports.SurfaceContent{ID: 7, Seq: 2, SHM: &ports.SHMBuffer{}}
320+ time.Sleep(20 * time.Millisecond)
321+ flips <- eventOf(cur)
322+ select {
323+ case p := <-presented:
324+ if p.Seen[7] != 2 {
325+ t.Fatalf("report %+v", p)
326+ }
327+ case <-time.After(time.Second):
328+ t.Fatal("Seen not reported after the cursor commit")
329+ }
330+ cancel()
331+ if err := <-done; err != nil {
332+ t.Fatal(err)
333+ }
334+}
@@ -0,0 +1,334 @@
1+package drm
2+
3+import (
4+ "context"
5+ "os"
6+ "testing"
7+ "time"
8+
9+ portsmocks "github.com/bnema/nefertty/internal/mocks/ports"
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/stretchr/testify/mock"
12+ "golang.org/x/sys/unix"
13+)
14+
15+// An event queued before a modeset (or for an earlier output on the CRTC)
16+// must not complete the commit that follows.
17+func TestStaleEventDoesNotCompleteNewCommit(t *testing.T) {
18+ o, _, commits := testOutput(t)
19+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
20+ t.Fatal(err)
21+ }
22+ old := eventOf((*commits)[0])
23+ o.pending = false // a modeset dropped the commit; its event is queued
24+ if err := o.commitFrame(71, nil, false, false, pendingFrame{}); err != nil {
25+ t.Fatal(err)
26+ }
27+ if o.completed(old, nil) || !o.pending {
28+ t.Fatal("stale event completed the new commit")
29+ }
30+ if !o.completed(eventOf((*commits)[1]), nil) || o.pending {
31+ t.Fatal("own event did not complete the commit")
32+ }
33+}
34+
35+// After EBUSY no commit of ours is known: the next event of the CRTC ends
36+// the wait, then the frame commits.
37+func TestBusyFrameCommitsAfterEvent(t *testing.T) {
38+ o, _, commits := testOutput(t, unix.EBUSY)
39+ enabled := true
40+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); !o.commitFailed(err, &enabled) {
41+ t.Fatalf("EBUSY not handled: %v", err)
42+ }
43+ if !o.completed(flipEvent{crtc: tCrtc, user: 12345 << userKindBits}, nil) || o.pending {
44+ t.Fatal("EBUSY wait not ended by the CRTC's event")
45+ }
46+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil || len(*commits) != 2 || !o.pending {
47+ t.Fatalf("frame after EBUSY: %v, %d commits", err, len(*commits))
48+ }
49+}
50+
51+// A cursor image change writes only an image the plane neither shows nor
52+// is about to show; with both busy it waits for the event.
53+func TestCursorImageNeverOverwritesScreen(t *testing.T) {
54+ o, _, _ := testOutput(t)
55+ c := o.cursor
56+ r := portsmocks.NewMockRenderer(t)
57+ var written []int
58+ r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).RunAndReturn(func(i int, _ []byte, _, _ int) error {
59+ written = append(written, i)
60+ return nil
61+ })
62+ px := []byte{1, 2, 3, 4}
63+ // Image 0 on screen: two changes before a commit both go to image 1.
64+ c.screen, c.cur, c.image = 90, 0, true
65+ for range 2 {
66+ if err := c.setImage(r, px, 1, 1, 0, 0); err != nil {
67+ t.Fatal(err)
68+ }
69+ }
70+ if len(written) != 2 || written[0] != 1 || written[1] != 1 {
71+ t.Fatalf("written %v", written)
72+ }
73+ // Image 1 in flight, image 0 on screen: nothing is free.
74+ c.committed(cursorState{on: true, fb: 91})
75+ if err := c.setImage(r, px, 1, 1, 0, 0); err != nil || len(written) != 2 || c.later == nil {
76+ t.Fatalf("wrote a busy image: %v %v", err, written)
77+ }
78+ // The flip frees image 0.
79+ c.landed()
80+ if changed, err := c.flushLater(r); !changed || err != nil || len(written) != 3 || written[2] != 0 || c.cur != 0 {
81+ t.Fatalf("after flip: %v %v %v cur %d", changed, err, written, c.cur)
82+ }
83+}
84+
85+// KMS refusing the cursor plane turns the hardware cursor off and the
86+// frame commits without it; the output keeps running.
87+func TestCursorRefusedFrameCommitsWithout(t *testing.T) {
88+ o, _, commits := testOutput(t, unix.EINVAL, unix.EINVAL)
89+ o.cursor.image = true
90+ o.cursor.Move(5, 5)
91+ if err := o.commitFrame(70, nil, false, false, pendingFrame{}); err != nil {
92+ t.Fatal(err)
93+ }
94+ if !o.cursor.off || len(*commits) != 3 || (*commits)[1].flags != atomicTestOnly {
95+ t.Fatalf("off=%v commits %d", o.cursor.off, len(*commits))
96+ }
97+ if v, _ := (*commits)[2].req.value(tCursor, pFB); v != 0 {
98+ t.Fatalf("retry shows cursor fb %d", v)
99+ }
100+}
101+
102+// A refused cursor is not blamed on the client buffer.
103+func TestCursorRefusedDoesNotBlameScanoutBuffer(t *testing.T) {
104+ o, _, _ := testOutput(t, unix.EINVAL, unix.EINVAL)
105+ o.cursor.image = true
106+ o.cursor.Move(5, 5)
107+ o.clientFBs[9] = &clientFB{fbID: 80}
108+ direct, err := o.commitScanout(80, ports.SurfaceContent{DMABuf: &ports.DMABuf{ID: 9}}, pendingFrame{})
109+ if err != nil || !direct || o.clientFBs[9].failed != "" {
110+ t.Fatalf("direct=%v err=%v failed=%q", direct, err, o.clientFBs[9].failed)
111+ }
112+}
113+
114+// A cursor-only commit's event still reports content read meanwhile.
115+func TestCursorEventReportsSeen(t *testing.T) {
116+ o, _, commits := testOutput(t)
117+ o.cursor.image = true
118+ o.cursor.Move(1, 1)
119+ if err := o.commitState(false); err != nil {
120+ t.Fatal(err)
121+ }
122+ seen := map[ports.WindowID]uint64{2: 5}
123+ if !o.completed(eventOf((*commits)[0]), seen) {
124+ t.Fatal("event not taken")
125+ }
126+ // Run reports Seen at the top of its loop once nothing is pending.
127+ if o.pending {
128+ t.Fatal("still pending")
129+ }
130+}
131+
132+// showImages validates the images with a TEST_ONLY modeset and falls back
133+// to linear images when KMS refuses the driver's.
134+func TestShowImagesTestsBeforeModeset(t *testing.T) {
135+ o, k, commits := testOutput(t, unix.EINVAL)
136+ o.cursor = nil
137+ r := portsmocks.NewMockRenderer(t)
138+ buf := func() ports.DMABuf {
139+ f, w, _ := os.Pipe()
140+ w.Close()
141+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
142+ }
143+ r.EXPECT().ExportTargets(2, []uint64(nil)).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
144+ r.EXPECT().ExportTargets(2, []uint64{0}).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
145+ r.EXPECT().UseTarget(mock.Anything).Return()
146+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
147+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil)
148+ k.EXPECT().rmFB(mock.Anything).Return(nil)
149+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
150+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
151+ if err := o.showImages(r, imagesDriver, nil); err != nil {
152+ t.Fatal(err)
153+ }
154+ want := []uint32{atomicTestOnly | atomicAllowModes, atomicTestOnly | atomicAllowModes, atomicAllowModes}
155+ if len(*commits) != len(want) {
156+ t.Fatalf("%d commits", len(*commits))
157+ }
158+ for i, f := range want {
159+ if (*commits)[i].flags != f {
160+ t.Fatalf("commit %d flags %#x want %#x", i, (*commits)[i].flags, f)
161+ }
162+ }
163+ if o.kind != imagesLinear {
164+ t.Fatalf("kind %d", o.kind)
165+ }
166+}
167+
168+// Enabling an output whose images were not made yet (started switched
169+// away) tests them and modesets once.
170+func TestEnableAfterStartAwayModesetsOnce(t *testing.T) {
171+ o, k, commits, commitMu := testOutputMu(t)
172+ o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{}
173+ r := portsmocks.NewMockRenderer(t)
174+ buf := func() ports.DMABuf {
175+ f, w, _ := os.Pipe()
176+ w.Close()
177+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
178+ }
179+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
180+ r.EXPECT().UseTarget(mock.Anything).Return()
181+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
182+ r.EXPECT().Close().Return().Once()
183+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
184+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
185+ k.EXPECT().destroyBlob(mock.Anything).Return(nil)
186+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
187+ active := make(chan bool, 1)
188+ active <- false
189+ ctx, cancel := context.WithCancel(context.Background())
190+ done := make(chan error, 1)
191+ go func() {
192+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, active, nil, nil, nil, make(chan ports.OutputPresented, 1))
193+ }()
194+ count := func(flags uint32) int {
195+ commitMu.Lock()
196+ defer commitMu.Unlock()
197+ n := 0
198+ for _, c := range *commits {
199+ if c.flags == flags {
200+ n++
201+ }
202+ }
203+ return n
204+ }
205+ time.Sleep(20 * time.Millisecond)
206+ if n := count(atomicAllowModes); n != 0 {
207+ t.Fatalf("%d modesets while away", n)
208+ }
209+ active <- true
210+ waitFor(t, func() bool { return count(atomicAllowModes) == 1 })
211+ cancel()
212+ if err := <-done; err != nil {
213+ t.Fatal(err)
214+ }
215+ if n := count(atomicTestOnly | atomicAllowModes); n != 1 {
216+ t.Fatalf("%d test modesets", n)
217+ }
218+}
219+
220+// Close without a mode to restore turns the CRTC off.
221+func TestCloseWithoutSavedModeDisablesCrtc(t *testing.T) {
222+ o, k, commits := testOutput(t)
223+ k.EXPECT().rmFB(mock.Anything).Return(nil)
224+ o.Close()
225+ c := (*commits)[0]
226+ for _, p := range []struct{ obj, prop uint32 }{{tCrtc, pActive}, {tCrtc, pMode}, {tPrimary, pFB}, {tConn, pConnCrtc}} {
227+ if v, ok := c.req.value(p.obj, p.prop); !ok || v != 0 {
228+ t.Fatalf("object %d prop %d = %d %v", p.obj, p.prop, v, ok)
229+ }
230+ }
231+}
232+
233+// Close restores the saved mode and gives the connector its CRTC back.
234+func TestCloseRestoresSavedMode(t *testing.T) {
235+ o, k, commits := testOutput(t)
236+ o.saved = modeCrtc{crtcID: tCrtc, fbID: 5, modeValid: 1, mode: modeInfo{HDisplay: 200, VDisplay: 100}}
237+ k.EXPECT().createBlob(mock.Anything).Return(98, nil).Once()
238+ k.EXPECT().destroyBlob(uint32(98)).Return(nil).Once()
239+ k.EXPECT().rmFB(mock.Anything).Return(nil)
240+ o.Close()
241+ c := (*commits)[0]
242+ if v, _ := c.req.value(tConn, pConnCrtc); v != tCrtc {
243+ t.Fatalf("connector crtc %d", v)
244+ }
245+ if v, _ := c.req.value(tPrimary, pFB); v != 5 {
246+ t.Fatalf("primary fb %d", v)
247+ }
248+}
249+
250+// With the cursor off no image is loaded (no warning per change).
251+func TestCursorOffSkipsLoads(t *testing.T) {
252+ o, _, _ := testOutput(t)
253+ o.cursor.off = true
254+ loaded := false
255+ o.setCursor(nil, func(ports.CursorChange, float64, int) (ports.CursorImage, error) {
256+ loaded = true
257+ return ports.CursorImage{}, nil
258+ }, ports.CursorChange{}, 1)
259+ if loaded || o.cursor.desired().on {
260+ t.Fatal("cursor used while off")
261+ }
262+}
263+
264+// Content read while only a cursor commit is in flight is reported when
265+// that commit's event arrives, not held until a later frame.
266+func TestRunReportsSeenAfterCursorCommit(t *testing.T) {
267+ o, k, commits, commitMu := testOutputMu(t)
268+ o.tearing, o.cursor.fbs = false, [2]uint32{}
269+ flips := make(chan flipEvent, 1)
270+ o.flipped = flips
271+ r := portsmocks.NewMockRenderer(t)
272+ buf := func() ports.DMABuf {
273+ f, w, _ := os.Pipe()
274+ w.Close()
275+ return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}}
276+ }
277+ r.EXPECT().CursorBuffers(64).Return([2]ports.DMABuf{buf(), buf()}, nil).Once()
278+ r.EXPECT().WriteCursor(mock.Anything, mock.Anything, 1, 1).Return(nil)
279+ r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once()
280+ r.EXPECT().UseTarget(mock.Anything).Return()
281+ r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil)
282+ r.EXPECT().Close().Return().Once()
283+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(90, nil).Once()
284+ k.EXPECT().addFB(mock.Anything, uint32(fourccARGB)).Return(91, nil).Once()
285+ k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice()
286+ k.EXPECT().createBlob(mock.Anything).Return(99, nil)
287+ k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe()
288+ k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe()
289+ load := func(ports.CursorChange, float64, int) (ports.CursorImage, error) {
290+ return ports.CursorImage{Pixels: []byte{1, 2, 3, 4}, W: 1, H: 1}, nil
291+ }
292+ last := func(kind uint64) (commitRec, bool) {
293+ commitMu.Lock()
294+ defer commitMu.Unlock()
295+ for i := len(*commits) - 1; i >= 0; i-- {
296+ if c := (*commits)[i]; c.user&3 == kind {
297+ return c, true
298+ }
299+ }
300+ return commitRec{}, false
301+ }
302+ scenes := make(chan ports.Scene, 1)
303+ contents := make(chan ports.SurfaceContent, 1)
304+ presented := make(chan ports.OutputPresented, 8)
305+ ctx, cancel := context.WithCancel(context.Background())
306+ done := make(chan error, 1)
307+ go func() {
308+ done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, load, make(chan bool), scenes, contents, nil, presented)
309+ }()
310+ scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1}
311+ var frame commitRec
312+ waitFor(t, func() bool { var ok bool; frame, ok = last(userFrame); return ok })
313+ flips <- eventOf(frame)
314+ <-presented // the frame's flip
315+ o.cursor.Move(3, 3)
316+ var cur commitRec
317+ waitFor(t, func() bool { var ok bool; cur, ok = last(userState); return ok })
318+ // A window this output does not show commits while the cursor flies.
319+ contents <- ports.SurfaceContent{ID: 7, Seq: 2, SHM: &ports.SHMBuffer{}}
320+ time.Sleep(20 * time.Millisecond)
321+ flips <- eventOf(cur)
322+ select {
323+ case p := <-presented:
324+ if p.Seen[7] != 2 {
325+ t.Fatalf("report %+v", p)
326+ }
327+ case <-time.After(time.Second):
328+ t.Fatal("Seen not reported after the cursor commit")
329+ }
330+ cancel()
331+ if err := <-done; err != nil {
332+ t.Fatal(err)
333+ }
334+}
M internal/adapters/drm/cursor.go +70 -5
@@ -2,6 +2,7 @@ package drm
22
33 import (
44 "errors"
5+ "slices"
56 "sync"
67
78 "github.com/bnema/nefertty/internal/ports"
@@ -38,6 +39,21 @@ type Cursor struct {
3839 hotX, hotY int
3940 applied cursorState // what the last commit put on the plane
4041 commits int
42+ // screen is the image the plane scans out (0: none); flight is what
43+ // the pending commit puts on it when flying. An image is written only
44+ // when it is neither, so the plane never shows a half-written image.
45+ screen uint32
46+ flight cursorState
47+ flying bool
48+ // later is an image that waits for a free slot (both on screen or in
49+ // flight); off is set when KMS refused the cursor plane.
50+ later *laterImage
51+ off bool
52+}
53+
54+type laterImage struct {
55+ pixels []byte
56+ w, h, hotX, hotY int
4157 }
4258
4359 // cursorState is the cursor plane as committed: off, or fb at x, y.
@@ -115,9 +131,12 @@ func (c *Cursor) setup(k kms, r ports.Renderer) error {
115131 return err
116132 }
117133
118-// setImage loads premultiplied ARGB8888 pixels into the image not on
119-// screen; an empty image hides the cursor (a client asked for none).
134+// setImage loads premultiplied ARGB8888 pixels into an image neither on
135+// screen nor in flight; when there is none it waits for the next commit
136+// event (flushLater). An empty image hides the cursor (a client asked for
137+// none).
120138 func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {
139+ c.later = nil
121140 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
122141 c.image = false
123142 return nil
@@ -125,7 +144,11 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
125144 if c.fbs[0] == 0 {
126145 return errors.New("no cursor images")
127146 }
128- next := 1 - c.cur
147+ next := c.freeSlot()
148+ if next < 0 {
149+ c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}
150+ return nil
151+ }
129152 if err := r.WriteCursor(next, pixels, w, h); err != nil {
130153 return err
131154 }
@@ -133,12 +156,53 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
133156 return nil
134157 }
135158
159+// freeSlot is an image the plane neither shows nor is about to show, -1
160+// when both are busy.
161+func (c *Cursor) freeSlot() int {
162+ for i, fb := range c.fbs {
163+ if fb != c.screen && !(c.flying && c.flight.on && c.flight.fb == fb) {
164+ return i
165+ }
166+ }
167+ return -1
168+}
169+
170+// flushLater loads an image that waited for a free slot. It reports
171+// whether the cursor changed.
172+func (c *Cursor) flushLater(r ports.Renderer) (bool, error) {
173+ l := c.later
174+ if l == nil || c.freeSlot() < 0 {
175+ return false, nil
176+ }
177+ return true, c.setImage(r, l.pixels, l.w, l.h, l.hotX, l.hotY)
178+}
179+
180+// committed records that a commit carrying state s is in flight.
181+func (c *Cursor) committed(s cursorState) {
182+ if s != c.applied {
183+ c.mu.Lock()
184+ c.commits++
185+ c.mu.Unlock()
186+ }
187+ c.applied, c.flight, c.flying = s, s, true
188+}
189+
190+// landed records that the pending commit reached the screen.
191+func (c *Cursor) landed() {
192+ if c.flying {
193+ c.screen, c.flying = 0, false
194+ if c.flight.on {
195+ c.screen = c.flight.fb
196+ }
197+ }
198+}
199+
136200 // desired is what the cursor plane should show now.
137201 func (c *Cursor) desired() cursorState {
138202 c.mu.Lock()
139203 x, y, away := c.x, c.y, c.away
140204 c.mu.Unlock()
141- if !c.image || away || c.fbs[c.cur] == 0 {
205+ if !c.image || away || c.off || c.fbs[c.cur] == 0 {
142206 return cursorState{}
143207 }
144208 return cursorState{on: true, fb: c.fbs[c.cur], x: x - c.hotX, y: y - c.hotY}
@@ -172,5 +236,6 @@ func (c *Cursor) free(k kms) {
172236 c.fbs[i] = 0
173237 }
174238 }
175- c.image = false
239+ c.image, c.later = false, nil
240+ c.screen, c.flying = 0, false
176241 }
@@ -2,6 +2,7 @@ package drm
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "slices"
5 "sync"6 "sync"
6 7
7 "github.com/bnema/nefertty/internal/ports"8 "github.com/bnema/nefertty/internal/ports"
@@ -38,6 +39,21 @@ type Cursor struct {
38 hotX, hotY int39 hotX, hotY int
39 applied cursorState // what the last commit put on the plane40 applied cursorState // what the last commit put on the plane
40 commits int41 commits int
42+ // screen is the image the plane scans out (0: none); flight is what
43+ // the pending commit puts on it when flying. An image is written only
44+ // when it is neither, so the plane never shows a half-written image.
45+ screen uint32
46+ flight cursorState
47+ flying bool
48+ // later is an image that waits for a free slot (both on screen or in
49+ // flight); off is set when KMS refused the cursor plane.
50+ later *laterImage
51+ off bool
52+}
53+
54+type laterImage struct {
55+ pixels []byte
56+ w, h, hotX, hotY int
41 }57 }
42 58
43 // cursorState is the cursor plane as committed: off, or fb at x, y.59 // cursorState is the cursor plane as committed: off, or fb at x, y.
@@ -115,9 +131,12 @@ func (c *Cursor) setup(k kms, r ports.Renderer) error {
115 return err131 return err
116 }132 }
117 133
118-// setImage loads premultiplied ARGB8888 pixels into the image not on134+// setImage loads premultiplied ARGB8888 pixels into an image neither on
119-// screen; an empty image hides the cursor (a client asked for none).135+// screen nor in flight; when there is none it waits for the next commit
136+// event (flushLater). An empty image hides the cursor (a client asked for
137+// none).
120 func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {138 func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int) error {
139+ c.later = nil
121 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {140 if w <= 0 || h <= 0 || len(pixels) < w*h*4 {
122 c.image = false141 c.image = false
123 return nil142 return nil
@@ -125,7 +144,11 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
125 if c.fbs[0] == 0 {144 if c.fbs[0] == 0 {
126 return errors.New("no cursor images")145 return errors.New("no cursor images")
127 }146 }
128- next := 1 - c.cur147+ next := c.freeSlot()
148+ if next < 0 {
149+ c.later = &laterImage{pixels: slices.Clone(pixels[:w*h*4]), w: w, h: h, hotX: hotX, hotY: hotY}
150+ return nil
151+ }
129 if err := r.WriteCursor(next, pixels, w, h); err != nil {152 if err := r.WriteCursor(next, pixels, w, h); err != nil {
130 return err153 return err
131 }154 }
@@ -133,12 +156,53 @@ func (c *Cursor) setImage(r ports.Renderer, pixels []byte, w, h, hotX, hotY int)
133 return nil156 return nil
134 }157 }
135 158
159+// freeSlot is an image the plane neither shows nor is about to show, -1
160+// when both are busy.
161+func (c *Cursor) freeSlot() int {
162+ for i, fb := range c.fbs {
163+ if fb != c.screen && !(c.flying && c.flight.on && c.flight.fb == fb) {
164+ return i
165+ }
166+ }
167+ return -1
168+}
169+
170+// flushLater loads an image that waited for a free slot. It reports
171+// whether the cursor changed.
172+func (c *Cursor) flushLater(r ports.Renderer) (bool, error) {
173+ l := c.later
174+ if l == nil || c.freeSlot() < 0 {
175+ return false, nil
176+ }
177+ return true, c.setImage(r, l.pixels, l.w, l.h, l.hotX, l.hotY)
178+}
179+
180+// committed records that a commit carrying state s is in flight.
181+func (c *Cursor) committed(s cursorState) {
182+ if s != c.applied {
183+ c.mu.Lock()
184+ c.commits++
185+ c.mu.Unlock()
186+ }
187+ c.applied, c.flight, c.flying = s, s, true
188+}
189+
190+// landed records that the pending commit reached the screen.
191+func (c *Cursor) landed() {
192+ if c.flying {
193+ c.screen, c.flying = 0, false
194+ if c.flight.on {
195+ c.screen = c.flight.fb
196+ }
197+ }
198+}
199+
136 // desired is what the cursor plane should show now.200 // desired is what the cursor plane should show now.
137 func (c *Cursor) desired() cursorState {201 func (c *Cursor) desired() cursorState {
138 c.mu.Lock()202 c.mu.Lock()
139 x, y, away := c.x, c.y, c.away203 x, y, away := c.x, c.y, c.away
140 c.mu.Unlock()204 c.mu.Unlock()
141- if !c.image || away || c.fbs[c.cur] == 0 {205+ if !c.image || away || c.off || c.fbs[c.cur] == 0 {
142 return cursorState{}206 return cursorState{}
143 }207 }
144 return cursorState{on: true, fb: c.fbs[c.cur], x: x - c.hotX, y: y - c.hotY}208 return cursorState{on: true, fb: c.fbs[c.cur], x: x - c.hotX, y: y - c.hotY}
@@ -172,5 +236,6 @@ func (c *Cursor) free(k kms) {
172 c.fbs[i] = 0236 c.fbs[i] = 0
173 }237 }
174 }238 }
175- c.image = false239+ c.image, c.later = false, nil
240+ c.screen, c.flying = 0, false
176 }241 }
M internal/adapters/drm/kms_test.go +1 -0
@@ -32,6 +32,7 @@ func TestStructSizesMatchIoctls(t *testing.T) {
3232 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},
3333 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},
3434 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},
35+ {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},
3536 } {
3637 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
3738 t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
@@ -32,6 +32,7 @@ func TestStructSizesMatchIoctls(t *testing.T) {
32 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},32 {"atomic", unsafe.Sizeof(modeAtomic{}), ioctlAtomic},
33 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},33 {"create_blob", unsafe.Sizeof(createBlob{}), ioctlCreateBlob},
34 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},34 {"get_cap", unsafe.Sizeof(getCap{}), ioctlGetCap},
35+ {"destroy_blob", unsafe.Sizeof(uint32(0)), ioctlDestroyBlob},
35 } {36 } {
36 if want := (tc.req >> 16) & 0x3fff; tc.got != want {37 if want := (tc.req >> 16) & 0x3fff; tc.got != want {
37 t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)38 t.Errorf("%s: size %d, ioctl encodes %d", tc.name, tc.got, want)
M internal/adapters/drm/output.go +176 -86
@@ -7,9 +7,11 @@ import (
77 "maps"
88 "os"
99 "slices"
10+ "sync/atomic"
1011 "time"
1112 "unsafe"
1213
14+ "github.com/bnema/nefertty/internal/adapters/syncfile"
1315 "github.com/bnema/nefertty/internal/ports"
1416 "github.com/bnema/zerowrap"
1517 "golang.org/x/sys/unix"
@@ -46,8 +48,13 @@ type Output struct {
4648 // new frame (else it only changed the cursor or VRR).
4749 pending bool
4850 pendingFrame pendingFrame
49- flipStart time.Time
50- flips int
51+ // serials numbers commits per card, so an event queued before a
52+ // modeset, or for an earlier output on this CRTC, never completes a
53+ // newer commit; pendingSerial is the pending commit's.
54+ serials *atomic.Uint64
55+ pendingSerial, nextSerial uint64
56+ flipStart time.Time
57+ flips int
5158 // Direct scanout: client framebuffers by DMABuf ID and the buffer on
5259 // screen and queued (0: the composed image).
5360 scanout bool
@@ -67,9 +74,8 @@ type Output struct {
6774 vrrProp uint32
6875 vrrOn bool
6976 composedSince time.Time
70- // kind is how the images were made; validated once a modeset took them.
71- kind imageKind
72- validated bool
77+ // kind is how the images were made.
78+ kind imageKind
7379 }
7480
7581 // pendingFrame is what the pending frame commit shows.
@@ -80,12 +86,27 @@ type pendingFrame struct {
8086 shows map[ports.WindowID]uint64
8187 }
8288
83-// Commit event userData.
89+// Commit event userData: the commit serial above userKindBits, the kind
90+// of commit below.
8491 const (
85- userFrame = 1
86- userState = 2
92+ userFrame = 1
93+ userState = 2
94+ userKindBits = 2
8795 )
8896
97+// userData takes a new serial for the next commit of kind and returns
98+// what the commit carries.
99+func (o *Output) userData(kind uint64) uint64 {
100+ o.nextSerial = o.serials.Add(1)
101+ return o.nextSerial<<userKindBits | kind
102+}
103+
104+// begin marks the commit made with the last userData pending.
105+func (o *Output) begin(f pendingFrame) {
106+ o.pendingSerial = o.nextSerial
107+ o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
108+}
109+
89110 // CursorLoader returns the image of a cursor at an output scale, at most
90111 // limit pixels on a side; an empty image hides the cursor.
91112 type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
@@ -94,7 +115,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
94115 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
95116 log := card.log
96117 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"}
118+ o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}
98119 var err error
99120 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
100121 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -227,11 +248,72 @@ func (o *Output) modeset() error {
227248 o.pending = false
228249 o.pendingFrame = pendingFrame{}
229250 o.shown, o.queued = 0, 0
230- mode := o.mode
251+ blob, err := o.modeBlobFor(o.mode)
252+ if err != nil {
253+ return err
254+ }
255+ if err := o.k.commit(o.modesetReq(blob), atomicAllowModes, 0); err != nil {
256+ _ = o.k.destroyBlob(blob)
257+ return fmt.Errorf("modeset: %w", err)
258+ }
259+ if o.modeBlob != 0 {
260+ _ = o.k.destroyBlob(o.modeBlob)
261+ }
262+ o.modeBlob = blob
263+ o.vrrOn = false
264+ if o.cursor != nil {
265+ o.cursor.applied = cursorState{}
266+ o.cursor.screen, o.cursor.flying = 0, false
267+ }
268+ o.log.Info().Str("connector", o.conn.name).Msg("modeset")
269+ o.testCursor()
270+ return nil
271+}
272+
273+// testModeset asks KMS whether it would take the modeset of the output
274+// images without applying it.
275+func (o *Output) testModeset() error {
276+ blob, err := o.modeBlobFor(o.mode)
277+ if err != nil {
278+ return err
279+ }
280+ defer func() { _ = o.k.destroyBlob(blob) }()
281+ if err := o.k.commit(o.modesetReq(blob), atomicTestOnly|atomicAllowModes, 0); err != nil {
282+ return fmt.Errorf("modeset test: %w", err)
283+ }
284+ return nil
285+}
286+
287+func (o *Output) modeBlobFor(mode modeInfo) (uint32, error) {
231288 blob, err := o.k.createBlob(unsafe.Slice((*byte)(unsafe.Pointer(&mode)), unsafe.Sizeof(mode)))
232289 if err != nil {
233- return fmt.Errorf("mode blob: %w", err)
290+ return 0, fmt.Errorf("mode blob: %w", err)
291+ }
292+ return blob, nil
293+}
294+
295+// testCursor turns the hardware cursor off when KMS refuses its images on
296+// the cursor plane (e.g. a pitch the plane cannot scan out).
297+func (o *Output) testCursor() {
298+ c := o.cursor
299+ if c == nil || c.off || c.fbs[0] == 0 {
300+ return
234301 }
302+ req := &atomicReq{}
303+ c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
304+ if err := o.k.commit(req, atomicTestOnly, 0); refused(err) {
305+ o.cursorOff(err)
306+ }
307+}
308+
309+// cursorOff stops using the cursor plane for the rest of the output's life.
310+func (o *Output) cursorOff(err error) {
311+ o.cursor.off = true
312+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
313+}
314+
315+// modesetReq is the modeset of the front image with mode blob.
316+func (o *Output) modesetReq(blob uint32) *atomicReq {
235317 req := &atomicReq{}
236318 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
237319 req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
@@ -250,20 +332,7 @@ func (o *Output) modeset() error {
250332 req.set(p.id, p.prop("FB_ID"), 0)
251333 req.set(p.id, p.prop("CRTC_ID"), 0)
252334 }
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{}
264- }
265- o.log.Info().Str("connector", o.conn.name).Msg("modeset")
266- return nil
335+ return req
267336 }
268337
269338 // probeAsync checks once whether async commits may carry IN_FENCE_FD.
@@ -333,7 +402,10 @@ func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f
333402 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
334403 }
335404 // The kernel takes its own reference on the fence.
336- if err := o.k.commit(req, flags, userFrame); err != nil {
405+ if err := o.k.commit(req, flags, o.userData(userFrame)); err != nil {
406+ if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
407+ return o.commitFrame(fb, fence, false, vrr, f)
408+ }
337409 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
338410 // Retry without turning VRR on: if that passes, the driver
339411 // refuses VRR on this output.
@@ -350,21 +422,25 @@ func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f
350422 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
351423 }
352424 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- }
425+ }
426+ if o.cursor != nil {
427+ // An async commit leaves the cursor plane as applied.
428+ o.cursor.committed(cur)
361429 }
362430 f.frame, f.async = true, async
363431 o.setAsync(async)
364- o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
432+ o.begin(f)
365433 return nil
366434 }
367435
436+// cursorRefused reports, after a commit carrying the cursor failed with
437+// EINVAL, whether the cursor plane was the cause; then the cursor is off
438+// and the commit can be retried without it.
439+func (o *Output) cursorRefused() bool {
440+ o.testCursor()
441+ return o.cursor.off
442+}
443+
368444 // commitState commits a cursor or VRR change without a new frame.
369445 func (o *Output) commitState(vrr bool) error {
370446 req := &atomicReq{}
@@ -381,20 +457,20 @@ func (o *Output) commitState(vrr bool) error {
381457 if len(req.objs) == 0 {
382458 return nil
383459 }
384- if err := o.k.commit(req, atomicNonblock|flipEventFlag, userState); err != nil {
460+ if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.userData(userState)); err != nil {
461+ if cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
462+ return o.commitState(vrr)
463+ }
385464 return err
386465 }
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()
466+ if o.cursor != nil {
467+ o.cursor.committed(cur)
392468 }
393469 if vrr != o.vrrOn {
394470 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
395471 }
396472 o.vrrOn = vrr
397- o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
473+ o.begin(pendingFrame{})
398474 return nil
399475 }
400476
@@ -477,13 +553,14 @@ func (o *Output) Close() {
477553 req.set(o.crtc, o.vrrProp, 0)
478554 s := o.saved
479555 var blob uint32
556+ p := o.primary
557+ restored := false
480558 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
559+ if b, err := o.modeBlobFor(s.mode); err == nil {
560+ blob, restored = b, true
484561 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b))
485562 req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
486- p := o.primary
563+ req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
487564 req.set(p.id, p.prop("FB_ID"), uint64(s.fbID))
488565 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
489566 req.set(p.id, p.prop("SRC_X"), 0)
@@ -496,8 +573,18 @@ func (o *Output) Close() {
496573 req.set(p.id, p.prop("CRTC_H"), uint64(s.mode.VDisplay))
497574 }
498575 }
576+ if !restored {
577+ // Nothing to give back: leave the CRTC off.
578+ req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
579+ req.set(o.crtc, o.crtcProps["MODE_ID"], 0)
580+ req.set(p.id, p.prop("FB_ID"), 0)
581+ req.set(p.id, p.prop("CRTC_ID"), 0)
582+ req.set(o.conn.id, o.connCrtc, 0)
583+ }
499584 // Best effort: at exit the card may already belong to another session.
500- _ = o.k.commit(req, atomicAllowModes, 0)
585+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
586+ o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("restore crtc")
587+ }
501588 if blob != 0 {
502589 _ = o.k.destroyBlob(blob)
503590 }
@@ -523,7 +610,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
523610 defer r.Close()
524611 if o.cursor != nil {
525612 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")
613+ o.cursor.off = true
614+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
527615 }
528616 }
529617 // The seat sends its state first; while switched away (e.g. a monitor
@@ -534,16 +622,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
534622 default:
535623 }
536624 // Output images, from the best to the linear fallback (ADR 014).
537- // While switched away they are validated by the first modeset on enable.
538- if enabled {
539- err = o.showImages(r, imagesDriver, nil)
540- } else {
541- o.kind, err = o.setupImages(r, imagesDriver, nil)
542- }
543- if err != nil {
544- return err
545- }
625+ // While switched away they are made on the first enable.
546626 if enabled {
627+ if err := o.showImages(r, imagesDriver, nil); err != nil {
628+ return err
629+ }
547630 o.probeAsync(r)
548631 }
549632 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
@@ -592,22 +675,21 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
592675 case on := <-active:
593676 // Modeset on every enable: a fast disable+enable can coalesce to one true.
594677 if on {
595- err := o.modeset()
678+ var err error
679+ if o.fbs[0] == 0 {
680+ err = o.showImages(r, imagesDriver, nil)
681+ } else {
682+ err = o.modeset()
683+ }
596684 if lostMaster(err) {
597685 // The seat took DRM master back: wait for the next enable.
598686 o.log.Warn().Err(err).Msg("resume")
599687 continue
600688 }
601- if refused(err) && !o.validated && o.kind < imagesLinear {
602- o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(o.kind)).Msg("modeset refused the output images")
603- o.freeImages()
604- err = o.showImages(r, o.kind+1, err)
605- }
606689 if err != nil {
607690 o.log.Error().Err(err).Msg("resume")
608691 return err
609692 }
610- o.validated = true
611693 o.probeAsync(r)
612694 dirty = haveScene
613695 stateDirty = true
@@ -615,12 +697,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
615697 enabled = on
616698 o.log.Info().Bool("enabled", on).Msg("output")
617699 case ev := <-o.flipped:
618- if !o.pending {
700+ if !o.completed(ev, seen) {
619701 continue // stale event from before a modeset
620702 }
621- o.completed(ev, seen)
622- reportDirty = false
623703 stateDirty = true
704+ if o.cursor != nil {
705+ // An image that waited for a free slot loads now.
706+ if _, err := o.cursor.flushLater(r); err != nil {
707+ o.log.Warn().Err(err).Msg("cursor")
708+ }
709+ }
624710 case s := <-scenes:
625711 if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
626712 cursorScale = s.Scale
@@ -707,7 +793,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
707793 func (o *Output) commitFailed(err error, enabled *bool) bool {
708794 switch {
709795 case errors.Is(err, unix.EBUSY):
796+ // A commit of ours is in flight (e.g. across a VT switch): wait
797+ // for any event of this CRTC.
710798 o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
799+ o.pendingSerial = 0
711800 return true
712801 case lostMaster(err):
713802 o.log.Warn().Err(err).Msg("commit refused")
@@ -719,15 +808,22 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
719808 }
720809
721810 // 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) {
811+// reported with its kernel timestamp. It reports false for an event of
812+// another commit, which is dropped.
813+func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
814+ if !o.pending || o.pendingSerial != 0 && ev.user>>userKindBits != o.pendingSerial {
815+ return false
816+ }
724817 f := o.pendingFrame
725818 o.pending, o.pendingFrame = false, pendingFrame{}
726819 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
727820 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
728821 }
822+ if o.cursor != nil {
823+ o.cursor.landed()
824+ }
729825 if !f.frame {
730- return
826+ return true
731827 }
732828 o.flips++
733829 o.shown = f.queued
@@ -739,10 +835,14 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) {
739835 refresh = time.Duration(int64(time.Second) * 1000 / int64(max(1, o.mode.refreshMilli())))
740836 }
741837 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)
838+ return true
742839 }
743840
744841 // setCursor loads a cursor for scale into the cursor images.
745842 func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) {
843+ if o.cursor.off {
844+ return
845+ }
746846 img, err := load(c, scale, o.cursor.Limit())
747847 if err == nil {
748848 err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)
@@ -807,7 +907,8 @@ const (
807907 )
808908
809909 // showImages sets up images from kind on and modesets them. KMS may refuse
810-// an image only at modeset: then the next kind is tried. cause is why the
910+// an image only in a modeset: a TEST_ONLY modeset checks it first, and the
911+// next kind is tried on refusal. cause is why the
811912 // previous kind failed, for the log.
812913 func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {
813914 for {
@@ -816,11 +917,11 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
816917 return err
817918 }
818919 o.kind = got
819- if err = o.modeset(); err == nil {
820- o.validated = true
821- return nil
920+ if err = o.testModeset(); err == nil {
921+ return o.modeset()
822922 }
823923 if !refused(err) || got == imagesLinear {
924+ o.freeImages()
824925 return err
825926 }
826927 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
@@ -879,7 +980,7 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
879980 var done *os.File
880981 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
881982 // The modeset is a blocking commit: wait for the clear.
882- err = errors.Join(err, waitFence(done))
983+ err = errors.Join(err, syncfile.Wait(context.Background(), done))
883984 done.Close()
884985 }
885986 }
@@ -892,17 +993,6 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
892993 return nil
893994 }
894995
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-
906996 // freeImages removes the output images' framebuffers.
907997 func (o *Output) freeImages() {
908998 for i, fb := range o.fbs {
@@ -7,9 +7,11 @@ import (
7 "maps"7 "maps"
8 "os"8 "os"
9 "slices"9 "slices"
10+ "sync/atomic"
10 "time"11 "time"
11 "unsafe"12 "unsafe"
12 13
14+ "github.com/bnema/nefertty/internal/adapters/syncfile"
13 "github.com/bnema/nefertty/internal/ports"15 "github.com/bnema/nefertty/internal/ports"
14 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
15 "golang.org/x/sys/unix"17 "golang.org/x/sys/unix"
@@ -46,8 +48,13 @@ type Output struct {
46 // new frame (else it only changed the cursor or VRR).48 // new frame (else it only changed the cursor or VRR).
47 pending bool49 pending bool
48 pendingFrame pendingFrame50 pendingFrame pendingFrame
49- flipStart time.Time51+ // serials numbers commits per card, so an event queued before a
50- flips int52+ // modeset, or for an earlier output on this CRTC, never completes a
53+ // newer commit; pendingSerial is the pending commit's.
54+ serials *atomic.Uint64
55+ pendingSerial, nextSerial uint64
56+ flipStart time.Time
57+ flips int
51 // Direct scanout: client framebuffers by DMABuf ID and the buffer on58 // Direct scanout: client framebuffers by DMABuf ID and the buffer on
52 // screen and queued (0: the composed image).59 // screen and queued (0: the composed image).
53 scanout bool60 scanout bool
@@ -67,9 +74,8 @@ type Output struct {
67 vrrProp uint3274 vrrProp uint32
68 vrrOn bool75 vrrOn bool
69 composedSince time.Time76 composedSince time.Time
70- // kind is how the images were made; validated once a modeset took them.77+ // kind is how the images were made.
71- kind imageKind78+ kind imageKind
72- validated bool
73 }79 }
74 80
75 // pendingFrame is what the pending frame commit shows.81 // pendingFrame is what the pending frame commit shows.
@@ -80,12 +86,27 @@ type pendingFrame struct {
80 shows map[ports.WindowID]uint6486 shows map[ports.WindowID]uint64
81 }87 }
82 88
83-// Commit event userData.89+// Commit event userData: the commit serial above userKindBits, the kind
90+// of commit below.
84 const (91 const (
85- userFrame = 192+ userFrame = 1
86- userState = 293+ userState = 2
94+ userKindBits = 2
87 )95 )
88 96
97+// userData takes a new serial for the next commit of kind and returns
98+// what the commit carries.
99+func (o *Output) userData(kind uint64) uint64 {
100+ o.nextSerial = o.serials.Add(1)
101+ return o.nextSerial<<userKindBits | kind
102+}
103+
104+// begin marks the commit made with the last userData pending.
105+func (o *Output) begin(f pendingFrame) {
106+ o.pendingSerial = o.nextSerial
107+ o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()
108+}
109+
89 // CursorLoader returns the image of a cursor at an output scale, at most110 // CursorLoader returns the image of a cursor at an output scale, at most
90 // limit pixels on a side; an empty image hides the cursor.111 // limit pixels on a side; an empty image hides the cursor.
91 type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)112 type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.CursorImage, error)
@@ -94,7 +115,7 @@ type CursorLoader func(c ports.CursorChange, scale float64, limit int) (ports.Cu
94 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {115 func newOutput(card *Card, c connector, mode modeInfo, crtc uint32) (*Output, error) {
95 log := card.log116 log := card.log
96 pipe := slices.Index(card.crtcs, crtc)117 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"}118+ o := &Output{k: card.k, flipped: card.flips[crtc], serials: &card.serials, crtc: crtc, conn: c, mode: mode, log: log, monitor: readMonitor(card.path, c.name), scanout: !card.want.NoScanout, clientFBs: map[uint64]*clientFB{}, reason: "start"}
98 var err error119 var err error
99 if o.saved, err = getCrtc(card.fd, crtc); err != nil {120 if o.saved, err = getCrtc(card.fd, crtc); err != nil {
100 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")121 log.Warn().Err(err).Uint32("crtc", crtc).Msg("save crtc; it will not be restored on exit")
@@ -227,11 +248,72 @@ func (o *Output) modeset() error {
227 o.pending = false248 o.pending = false
228 o.pendingFrame = pendingFrame{}249 o.pendingFrame = pendingFrame{}
229 o.shown, o.queued = 0, 0250 o.shown, o.queued = 0, 0
230- mode := o.mode251+ blob, err := o.modeBlobFor(o.mode)
252+ if err != nil {
253+ return err
254+ }
255+ if err := o.k.commit(o.modesetReq(blob), atomicAllowModes, 0); err != nil {
256+ _ = o.k.destroyBlob(blob)
257+ return fmt.Errorf("modeset: %w", err)
258+ }
259+ if o.modeBlob != 0 {
260+ _ = o.k.destroyBlob(o.modeBlob)
261+ }
262+ o.modeBlob = blob
263+ o.vrrOn = false
264+ if o.cursor != nil {
265+ o.cursor.applied = cursorState{}
266+ o.cursor.screen, o.cursor.flying = 0, false
267+ }
268+ o.log.Info().Str("connector", o.conn.name).Msg("modeset")
269+ o.testCursor()
270+ return nil
271+}
272+
273+// testModeset asks KMS whether it would take the modeset of the output
274+// images without applying it.
275+func (o *Output) testModeset() error {
276+ blob, err := o.modeBlobFor(o.mode)
277+ if err != nil {
278+ return err
279+ }
280+ defer func() { _ = o.k.destroyBlob(blob) }()
281+ if err := o.k.commit(o.modesetReq(blob), atomicTestOnly|atomicAllowModes, 0); err != nil {
282+ return fmt.Errorf("modeset test: %w", err)
283+ }
284+ return nil
285+}
286+
287+func (o *Output) modeBlobFor(mode modeInfo) (uint32, error) {
231 blob, err := o.k.createBlob(unsafe.Slice((*byte)(unsafe.Pointer(&mode)), unsafe.Sizeof(mode)))288 blob, err := o.k.createBlob(unsafe.Slice((*byte)(unsafe.Pointer(&mode)), unsafe.Sizeof(mode)))
232 if err != nil {289 if err != nil {
233- return fmt.Errorf("mode blob: %w", err)290+ return 0, fmt.Errorf("mode blob: %w", err)
291+ }
292+ return blob, nil
293+}
294+
295+// testCursor turns the hardware cursor off when KMS refuses its images on
296+// the cursor plane (e.g. a pitch the plane cannot scan out).
297+func (o *Output) testCursor() {
298+ c := o.cursor
299+ if c == nil || c.off || c.fbs[0] == 0 {
300+ return
234 }301 }
302+ req := &atomicReq{}
303+ c.props(req, o.crtc, cursorState{on: true, fb: c.fbs[0]})
304+ if err := o.k.commit(req, atomicTestOnly, 0); refused(err) {
305+ o.cursorOff(err)
306+ }
307+}
308+
309+// cursorOff stops using the cursor plane for the rest of the output's life.
310+func (o *Output) cursorOff(err error) {
311+ o.cursor.off = true
312+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("cursor plane refused; hardware cursor off")
313+}
314+
315+// modesetReq is the modeset of the front image with mode blob.
316+func (o *Output) modesetReq(blob uint32) *atomicReq {
235 req := &atomicReq{}317 req := &atomicReq{}
236 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))318 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(blob))
237 req.set(o.crtc, o.crtcProps["ACTIVE"], 1)319 req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
@@ -250,20 +332,7 @@ func (o *Output) modeset() error {
250 req.set(p.id, p.prop("FB_ID"), 0)332 req.set(p.id, p.prop("FB_ID"), 0)
251 req.set(p.id, p.prop("CRTC_ID"), 0)333 req.set(p.id, p.prop("CRTC_ID"), 0)
252 }334 }
253- if err := o.k.commit(req, atomicAllowModes, 0); err != nil {335+ return req
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{}
264- }
265- o.log.Info().Str("connector", o.conn.name).Msg("modeset")
266- return nil
267 }336 }
268 337
269 // probeAsync checks once whether async commits may carry IN_FENCE_FD.338 // probeAsync checks once whether async commits may carry IN_FENCE_FD.
@@ -333,7 +402,10 @@ func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f
333 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))402 req.set(o.primary.id, o.primary.prop("IN_FENCE_FD"), uint64(fence.Fd()))
334 }403 }
335 // The kernel takes its own reference on the fence.404 // The kernel takes its own reference on the fence.
336- if err := o.k.commit(req, flags, userFrame); err != nil {405+ if err := o.k.commit(req, flags, o.userData(userFrame)); err != nil {
406+ if !async && cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
407+ return o.commitFrame(fb, fence, false, vrr, f)
408+ }
337 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {409 if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) {
338 // Retry without turning VRR on: if that passes, the driver410 // Retry without turning VRR on: if that passes, the driver
339 // refuses VRR on this output.411 // refuses VRR on this output.
@@ -350,21 +422,25 @@ func (o *Output) commitFrame(fb uint32, fence *os.File, async bool, vrr bool, f
350 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")422 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
351 }423 }
352 o.vrrOn = vrr424 o.vrrOn = vrr
353- if o.cursor != nil {425+ }
354- if cur != o.cursor.applied {426+ if o.cursor != nil {
355- o.cursor.mu.Lock()427+ // An async commit leaves the cursor plane as applied.
356- o.cursor.commits++428+ o.cursor.committed(cur)
357- o.cursor.mu.Unlock()
358- }
359- o.cursor.applied = cur
360- }
361 }429 }
362 f.frame, f.async = true, async430 f.frame, f.async = true, async
363 o.setAsync(async)431 o.setAsync(async)
364- o.pending, o.pendingFrame, o.flipStart = true, f, time.Now()432+ o.begin(f)
365 return nil433 return nil
366 }434 }
367 435
436+// cursorRefused reports, after a commit carrying the cursor failed with
437+// EINVAL, whether the cursor plane was the cause; then the cursor is off
438+// and the commit can be retried without it.
439+func (o *Output) cursorRefused() bool {
440+ o.testCursor()
441+ return o.cursor.off
442+}
443+
368 // commitState commits a cursor or VRR change without a new frame.444 // commitState commits a cursor or VRR change without a new frame.
369 func (o *Output) commitState(vrr bool) error {445 func (o *Output) commitState(vrr bool) error {
370 req := &atomicReq{}446 req := &atomicReq{}
@@ -381,20 +457,20 @@ func (o *Output) commitState(vrr bool) error {
381 if len(req.objs) == 0 {457 if len(req.objs) == 0 {
382 return nil458 return nil
383 }459 }
384- if err := o.k.commit(req, atomicNonblock|flipEventFlag, userState); err != nil {460+ if err := o.k.commit(req, atomicNonblock|flipEventFlag, o.userData(userState)); err != nil {
461+ if cur.on && errors.Is(err, unix.EINVAL) && o.cursorRefused() {
462+ return o.commitState(vrr)
463+ }
385 return err464 return err
386 }465 }
387- if o.cursor != nil && cur != o.cursor.applied {466+ if o.cursor != nil {
388- o.cursor.applied = cur467+ o.cursor.committed(cur)
389- o.cursor.mu.Lock()
390- o.cursor.commits++
391- o.cursor.mu.Unlock()
392 }468 }
393 if vrr != o.vrrOn {469 if vrr != o.vrrOn {
394 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")470 o.log.Info().Bool("vrr", vrr).Str("connector", o.conn.name).Msg("vrr")
395 }471 }
396 o.vrrOn = vrr472 o.vrrOn = vrr
397- o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()473+ o.begin(pendingFrame{})
398 return nil474 return nil
399 }475 }
400 476
@@ -477,13 +553,14 @@ func (o *Output) Close() {
477 req.set(o.crtc, o.vrrProp, 0)553 req.set(o.crtc, o.vrrProp, 0)
478 s := o.saved554 s := o.saved
479 var blob uint32555 var blob uint32
556+ p := o.primary
557+ restored := false
480 if s.crtcID != 0 && s.modeValid != 0 && s.fbID != 0 {558 if s.crtcID != 0 && s.modeValid != 0 && s.fbID != 0 {
481- mode := s.mode559+ if b, err := o.modeBlobFor(s.mode); err == nil {
482- if b, err := o.k.createBlob(unsafe.Slice((*byte)(unsafe.Pointer(&mode)), unsafe.Sizeof(mode))); err == nil {560+ blob, restored = b, true
483- blob = b
484 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b))561 req.set(o.crtc, o.crtcProps["MODE_ID"], uint64(b))
485 req.set(o.crtc, o.crtcProps["ACTIVE"], 1)562 req.set(o.crtc, o.crtcProps["ACTIVE"], 1)
486- p := o.primary563+ req.set(o.conn.id, o.connCrtc, uint64(o.crtc))
487 req.set(p.id, p.prop("FB_ID"), uint64(s.fbID))564 req.set(p.id, p.prop("FB_ID"), uint64(s.fbID))
488 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))565 req.set(p.id, p.prop("CRTC_ID"), uint64(o.crtc))
489 req.set(p.id, p.prop("SRC_X"), 0)566 req.set(p.id, p.prop("SRC_X"), 0)
@@ -496,8 +573,18 @@ func (o *Output) Close() {
496 req.set(p.id, p.prop("CRTC_H"), uint64(s.mode.VDisplay))573 req.set(p.id, p.prop("CRTC_H"), uint64(s.mode.VDisplay))
497 }574 }
498 }575 }
576+ if !restored {
577+ // Nothing to give back: leave the CRTC off.
578+ req.set(o.crtc, o.crtcProps["ACTIVE"], 0)
579+ req.set(o.crtc, o.crtcProps["MODE_ID"], 0)
580+ req.set(p.id, p.prop("FB_ID"), 0)
581+ req.set(p.id, p.prop("CRTC_ID"), 0)
582+ req.set(o.conn.id, o.connCrtc, 0)
583+ }
499 // Best effort: at exit the card may already belong to another session.584 // Best effort: at exit the card may already belong to another session.
500- _ = o.k.commit(req, atomicAllowModes, 0)585+ if err := o.k.commit(req, atomicAllowModes, 0); err != nil {
586+ o.log.Debug().Err(err).Str("connector", o.conn.name).Msg("restore crtc")
587+ }
501 if blob != 0 {588 if blob != 0 {
502 _ = o.k.destroyBlob(blob)589 _ = o.k.destroyBlob(blob)
503 }590 }
@@ -523,7 +610,8 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
523 defer r.Close()610 defer r.Close()
524 if o.cursor != nil {611 if o.cursor != nil {
525 if err := o.cursor.setup(o.k, r); err != nil {612 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")613+ o.cursor.off = true
614+ o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("no hardware cursor images; hardware cursor off")
527 }615 }
528 }616 }
529 // The seat sends its state first; while switched away (e.g. a monitor617 // The seat sends its state first; while switched away (e.g. a monitor
@@ -534,16 +622,11 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
534 default:622 default:
535 }623 }
536 // Output images, from the best to the linear fallback (ADR 014).624 // Output images, from the best to the linear fallback (ADR 014).
537- // While switched away they are validated by the first modeset on enable.625+ // While switched away they are made on the first enable.
538- if enabled {
539- err = o.showImages(r, imagesDriver, nil)
540- } else {
541- o.kind, err = o.setupImages(r, imagesDriver, nil)
542- }
543- if err != nil {
544- return err
545- }
546 if enabled {626 if enabled {
627+ if err := o.showImages(r, imagesDriver, nil); err != nil {
628+ return err
629+ }
547 o.probeAsync(r)630 o.probeAsync(r)
548 }631 }
549 surfaces := make(map[ports.WindowID]ports.SurfaceContent)632 surfaces := make(map[ports.WindowID]ports.SurfaceContent)
@@ -592,22 +675,21 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
592 case on := <-active:675 case on := <-active:
593 // Modeset on every enable: a fast disable+enable can coalesce to one true.676 // Modeset on every enable: a fast disable+enable can coalesce to one true.
594 if on {677 if on {
595- err := o.modeset()678+ var err error
679+ if o.fbs[0] == 0 {
680+ err = o.showImages(r, imagesDriver, nil)
681+ } else {
682+ err = o.modeset()
683+ }
596 if lostMaster(err) {684 if lostMaster(err) {
597 // The seat took DRM master back: wait for the next enable.685 // The seat took DRM master back: wait for the next enable.
598 o.log.Warn().Err(err).Msg("resume")686 o.log.Warn().Err(err).Msg("resume")
599 continue687 continue
600 }688 }
601- if refused(err) && !o.validated && o.kind < imagesLinear {
602- o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(o.kind)).Msg("modeset refused the output images")
603- o.freeImages()
604- err = o.showImages(r, o.kind+1, err)
605- }
606 if err != nil {689 if err != nil {
607 o.log.Error().Err(err).Msg("resume")690 o.log.Error().Err(err).Msg("resume")
608 return err691 return err
609 }692 }
610- o.validated = true
611 o.probeAsync(r)693 o.probeAsync(r)
612 dirty = haveScene694 dirty = haveScene
613 stateDirty = true695 stateDirty = true
@@ -615,12 +697,16 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
615 enabled = on697 enabled = on
616 o.log.Info().Bool("enabled", on).Msg("output")698 o.log.Info().Bool("enabled", on).Msg("output")
617 case ev := <-o.flipped:699 case ev := <-o.flipped:
618- if !o.pending {700+ if !o.completed(ev, seen) {
619 continue // stale event from before a modeset701 continue // stale event from before a modeset
620 }702 }
621- o.completed(ev, seen)
622- reportDirty = false
623 stateDirty = true703 stateDirty = true
704+ if o.cursor != nil {
705+ // An image that waited for a free slot loads now.
706+ if _, err := o.cursor.flushLater(r); err != nil {
707+ o.log.Warn().Err(err).Msg("cursor")
708+ }
709+ }
624 case s := <-scenes:710 case s := <-scenes:
625 if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {711 if o.cursor != nil && loadCursor != nil && s.Scale != cursorScale {
626 cursorScale = s.Scale712 cursorScale = s.Scale
@@ -707,7 +793,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend
707 func (o *Output) commitFailed(err error, enabled *bool) bool {793 func (o *Output) commitFailed(err error, enabled *bool) bool {
708 switch {794 switch {
709 case errors.Is(err, unix.EBUSY):795 case errors.Is(err, unix.EBUSY):
796+ // A commit of ours is in flight (e.g. across a VT switch): wait
797+ // for any event of this CRTC.
710 o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()798 o.pending, o.pendingFrame, o.flipStart = true, pendingFrame{}, time.Now()
799+ o.pendingSerial = 0
711 return true800 return true
712 case lostMaster(err):801 case lostMaster(err):
713 o.log.Warn().Err(err).Msg("commit refused")802 o.log.Warn().Err(err).Msg("commit refused")
@@ -719,15 +808,22 @@ func (o *Output) commitFailed(err error, enabled *bool) bool {
719 }808 }
720 809
721 // completed handles the event of the pending commit: a flipped frame is810 // completed handles the event of the pending commit: a flipped frame is
722-// reported with its kernel timestamp.811+// reported with its kernel timestamp. It reports false for an event of
723-func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) {812+// another commit, which is dropped.
813+func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) bool {
814+ if !o.pending || o.pendingSerial != 0 && ev.user>>userKindBits != o.pendingSerial {
815+ return false
816+ }
724 f := o.pendingFrame817 f := o.pendingFrame
725 o.pending, o.pendingFrame = false, pendingFrame{}818 o.pending, o.pendingFrame = false, pendingFrame{}
726 if d := time.Since(o.flipStart); d > 20*time.Millisecond {819 if d := time.Since(o.flipStart); d > 20*time.Millisecond {
727 o.log.Info().Dur("flip_ms", d).Msg("slow flip")820 o.log.Info().Dur("flip_ms", d).Msg("slow flip")
728 }821 }
822+ if o.cursor != nil {
823+ o.cursor.landed()
824+ }
729 if !f.frame {825 if !f.frame {
730- return826+ return true
731 }827 }
732 o.flips++828 o.flips++
733 o.shown = f.queued829 o.shown = f.queued
@@ -739,10 +835,14 @@ func (o *Output) completed(ev flipEvent, seen map[ports.WindowID]uint64) {
739 refresh = time.Duration(int64(time.Second) * 1000 / int64(max(1, o.mode.refreshMilli())))835 refresh = time.Duration(int64(time.Second) * 1000 / int64(max(1, o.mode.refreshMilli())))
740 }836 }
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)837 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)
838+ return true
742 }839 }
743 840
744 // setCursor loads a cursor for scale into the cursor images.841 // 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) {842 func (o *Output) setCursor(r ports.Renderer, load CursorLoader, c ports.CursorChange, scale float64) {
843+ if o.cursor.off {
844+ return
845+ }
746 img, err := load(c, scale, o.cursor.Limit())846 img, err := load(c, scale, o.cursor.Limit())
747 if err == nil {847 if err == nil {
748 err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)848 err = o.cursor.setImage(r, img.Pixels, img.W, img.H, img.HotX, img.HotY)
@@ -807,7 +907,8 @@ const (
807 )907 )
808 908
809 // showImages sets up images from kind on and modesets them. KMS may refuse909 // showImages sets up images from kind on and modesets them. KMS may refuse
810-// an image only at modeset: then the next kind is tried. cause is why the910+// an image only in a modeset: a TEST_ONLY modeset checks it first, and the
911+// next kind is tried on refusal. cause is why the
811 // previous kind failed, for the log.912 // previous kind failed, for the log.
812 func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {913 func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error {
813 for {914 for {
@@ -816,11 +917,11 @@ func (o *Output) showImages(r ports.Renderer, kind imageKind, cause error) error
816 return err917 return err
817 }918 }
818 o.kind = got919 o.kind = got
819- if err = o.modeset(); err == nil {920+ if err = o.testModeset(); err == nil {
820- o.validated = true921+ return o.modeset()
821- return nil
822 }922 }
823 if !refused(err) || got == imagesLinear {923 if !refused(err) || got == imagesLinear {
924+ o.freeImages()
824 return err925 return err
825 }926 }
826 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")927 o.log.Warn().Err(err).Str("connector", o.conn.name).Int("kind", int(got)).Msg("modeset refused the output images")
@@ -879,7 +980,7 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
879 var done *os.File980 var done *os.File
880 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {981 if done, err = r.Render(ports.Scene{Background: "#000000"}, nil); done != nil {
881 // The modeset is a blocking commit: wait for the clear.982 // The modeset is a blocking commit: wait for the clear.
882- err = errors.Join(err, waitFence(done))983+ err = errors.Join(err, syncfile.Wait(context.Background(), done))
883 done.Close()984 done.Close()
884 }985 }
885 }986 }
@@ -892,17 +993,6 @@ func (o *Output) exportImages(r ports.Renderer, mods []uint64) error {
892 return nil993 return nil
893 }994 }
894 995
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.996 // freeImages removes the output images' framebuffers.
907 func (o *Output) freeImages() {997 func (o *Output) freeImages() {
908 for i, fb := range o.fbs {998 for i, fb := range o.fbs {
M internal/adapters/drm/output_test.go +27 -14
@@ -4,6 +4,7 @@ import (
44 "context"
55 "os"
66 "sync"
7+ "sync/atomic"
78 "testing"
89 "time"
910
@@ -23,9 +24,6 @@ const (
2324
2425 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}
2526
26-// commitMu guards recorded commits (Run commits on its goroutine).
27-var commitMu sync.Mutex
28-
2927 // commitRec is one recorded atomic commit.
3028 type commitRec struct {
3129 req atomicReq
@@ -36,8 +34,16 @@ type commitRec struct {
3634 // testOutput returns an output on a mocked kms that records commits;
3735 // commit returns the next error of errs (nil when empty).
3836 func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {
37+ o, k, commits, _ := testOutputMu(t, errs...)
38+ return o, k, commits
39+}
40+
41+// testOutputMu is testOutput with the mutex that guards the recorded
42+// commits (Run commits on its goroutine).
43+func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) {
3944 k := newMockkms(t)
4045 var commits []commitRec
46+ commitMu := &sync.Mutex{}
4147 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, user uint64) error {
4248 commitMu.Lock()
4349 defer commitMu.Unlock()
@@ -57,12 +63,15 @@ func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {
5763 primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps}
5864 cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64)
5965 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(),
66+ o := &Output{k: k, serials: &atomic.Uint64{}, crtc: tCrtc, conn: connector{id: tConn, name: "DP-1"}, mode: modeInfo{HDisplay: 200, VDisplay: 100, VRefresh: 60}, log: zerowrap.Default(),
6167 crtcProps: map[string]uint32{"MODE_ID": pMode, "ACTIVE": pActive, "VRR_ENABLED": pVRR}, connCrtc: pConnCrtc, vrrProp: pVRR,
6268 primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}
63- return o, k, &commits
69+ return o, k, &commits, commitMu
6470 }
6571
72+// eventOf is the flip event of commit c.
73+func eventOf(c commitRec) flipEvent { return flipEvent{crtc: tCrtc, user: c.user} }
74+
6675 func objCount(c commitRec) int { return len(c.req.objs) }
6776
6877 func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
@@ -79,7 +88,7 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
7988 t.Fatal(err)
8089 }
8190 c := (*commits)[0]
82- if c.flags != atomicNonblock|flipEventFlag || c.user != userFrame {
91+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userFrame || c.user>>userKindBits != o.pendingSerial {
8392 t.Fatalf("flags %#x user %d", c.flags, c.user)
8493 }
8594 if v, _ := c.req.value(tPrimary, pFB); v != 70 {
@@ -167,12 +176,12 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
167176 t.Fatal(err)
168177 }
169178 c := (*commits)[0]
170- if c.flags != atomicNonblock|flipEventFlag || c.user != userState || !o.pending {
179+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userState || !o.pending {
171180 t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.pending)
172181 }
173182 // Its event is not a frame: nothing is reported.
174183 seen := map[ports.WindowID]uint64{}
175- o.completed(flipEvent{user: userState}, seen)
184+ o.completed(eventOf(c), seen)
176185 if o.pending || len(o.unsent) != 0 || o.flips != 0 {
177186 t.Fatalf("pending=%v unsent=%d flips=%d", o.pending, len(o.unsent), o.flips)
178187 }
@@ -192,13 +201,13 @@ func TestBusyCommitStaysPending(t *testing.T) {
192201 }
193202
194203 func TestFlipReportsTimestampAndShownFrame(t *testing.T) {
195- o, _, _ := testOutput(t)
204+ o, _, commits := testOutput(t)
196205 o.vrrOn = false
197206 o.mode.Clock, o.mode.HTotal, o.mode.VTotal = 0, 0, 0
198207 if err := o.commitFrame(70, nil, false, false, pendingFrame{shows: map[ports.WindowID]uint64{1: 4}}); err != nil {
199208 t.Fatal(err)
200209 }
201- o.completed(flipEvent{user: userFrame, when: 5 * time.Second, seq: 11}, map[ports.WindowID]uint64{1: 4})
210+ o.completed(flipEvent{user: (*commits)[0].user, when: 5 * time.Second, seq: 11}, map[ports.WindowID]uint64{1: 4})
202211 if len(o.unsent) != 1 {
203212 t.Fatalf("reports %d", len(o.unsent))
204213 }
@@ -344,7 +353,7 @@ func TestBufferRefusedIsNotBlamedOnVRR(t *testing.T) {
344353 // GPU, so a newer content is reported only after the flip event; every
345354 // frame fence is closed.
346355 func TestRunReportsSeenAfterFlip(t *testing.T) {
347- o, k, commits := testOutput(t)
356+ o, k, commits, commitMu := testOutputMu(t)
348357 o.cursor, o.tearing = nil, false
349358 flips := make(chan flipEvent, 1)
350359 o.flipped = flips
@@ -360,7 +369,8 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
360369 r.EXPECT().UseTarget(mock.Anything).Return()
361370 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
362371 f, w, _ := os.Pipe()
363- w.Close() // readable: signalled
372+ _, _ = w.Write([]byte{1}) // readable: signalled
373+ w.Close()
364374 mu.Lock()
365375 fences = append(fences, f)
366376 mu.Unlock()
@@ -376,7 +386,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
376386 defer commitMu.Unlock()
377387 n := 0
378388 for _, c := range *commits {
379- if c.user == userFrame {
389+ if c.user&3 == userFrame {
380390 n++
381391 }
382392 }
@@ -399,7 +409,10 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
399409 t.Fatalf("report while a frame is in flight: %+v", p)
400410 case <-time.After(30 * time.Millisecond):
401411 }
402- flips <- flipEvent{crtc: tCrtc, user: userFrame, when: time.Second, seq: 1}
412+ commitMu.Lock()
413+ last := (*commits)[len(*commits)-1]
414+ commitMu.Unlock()
415+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
403416 select {
404417 case p := <-presented:
405418 if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 {
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "os"5 "os"
6 "sync"6 "sync"
7+ "sync/atomic"
7 "testing"8 "testing"
8 "time"9 "time"
9 10
@@ -23,9 +24,6 @@ const (
23 24
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 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
26-// commitMu guards recorded commits (Run commits on its goroutine).
27-var commitMu sync.Mutex
28-
29 // commitRec is one recorded atomic commit.27 // commitRec is one recorded atomic commit.
30 type commitRec struct {28 type commitRec struct {
31 req atomicReq29 req atomicReq
@@ -36,8 +34,16 @@ type commitRec struct {
36 // testOutput returns an output on a mocked kms that records commits;34 // testOutput returns an output on a mocked kms that records commits;
37 // commit returns the next error of errs (nil when empty).35 // commit returns the next error of errs (nil when empty).
38 func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {36 func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {
37+ o, k, commits, _ := testOutputMu(t, errs...)
38+ return o, k, commits
39+}
40+
41+// testOutputMu is testOutput with the mutex that guards the recorded
42+// commits (Run commits on its goroutine).
43+func testOutputMu(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec, *sync.Mutex) {
39 k := newMockkms(t)44 k := newMockkms(t)
40 var commits []commitRec45 var commits []commitRec
46+ commitMu := &sync.Mutex{}
41 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, user uint64) error {47 k.EXPECT().commit(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(r *atomicReq, flags uint32, user uint64) error {
42 commitMu.Lock()48 commitMu.Lock()
43 defer commitMu.Unlock()49 defer commitMu.Unlock()
@@ -57,12 +63,15 @@ func testOutput(t *testing.T, errs ...error) (*Output, *mockkms, *[]commitRec) {
57 primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps}63 primary := &plane{id: tPrimary, typ: planePrimary, props: planeProps}
58 cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64)64 cur := newCursor(&plane{id: tCursor, typ: planeCursor, props: planeProps}, 64)
59 cur.fbs = [2]uint32{90, 91}65 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(),66+ o := &Output{k: k, serials: &atomic.Uint64{}, 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,67 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}68 primary: primary, cursor: cur, fbs: [2]uint32{70, 71}, clientFBs: map[uint64]*clientFB{}, scanout: true, tearing: true, asyncFence: true}
63- return o, k, &commits69+ return o, k, &commits, commitMu
64 }70 }
65 71
72+// eventOf is the flip event of commit c.
73+func eventOf(c commitRec) flipEvent { return flipEvent{crtc: tCrtc, user: c.user} }
74+
66 func objCount(c commitRec) int { return len(c.req.objs) }75 func objCount(c commitRec) int { return len(c.req.objs) }
67 76
68 func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {77 func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
@@ -79,7 +88,7 @@ func TestFrameCommitCarriesPrimaryFenceCursorAndVRR(t *testing.T) {
79 t.Fatal(err)88 t.Fatal(err)
80 }89 }
81 c := (*commits)[0]90 c := (*commits)[0]
82- if c.flags != atomicNonblock|flipEventFlag || c.user != userFrame {91+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userFrame || c.user>>userKindBits != o.pendingSerial {
83 t.Fatalf("flags %#x user %d", c.flags, c.user)92 t.Fatalf("flags %#x user %d", c.flags, c.user)
84 }93 }
85 if v, _ := c.req.value(tPrimary, pFB); v != 70 {94 if v, _ := c.req.value(tPrimary, pFB); v != 70 {
@@ -167,12 +176,12 @@ func TestCursorCommitWaitsForEvent(t *testing.T) {
167 t.Fatal(err)176 t.Fatal(err)
168 }177 }
169 c := (*commits)[0]178 c := (*commits)[0]
170- if c.flags != atomicNonblock|flipEventFlag || c.user != userState || !o.pending {179+ if c.flags != atomicNonblock|flipEventFlag || c.user&3 != userState || !o.pending {
171 t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.pending)180 t.Fatalf("cursor commit flags %#x user %d pending %v", c.flags, c.user, o.pending)
172 }181 }
173 // Its event is not a frame: nothing is reported.182 // Its event is not a frame: nothing is reported.
174 seen := map[ports.WindowID]uint64{}183 seen := map[ports.WindowID]uint64{}
175- o.completed(flipEvent{user: userState}, seen)184+ o.completed(eventOf(c), seen)
176 if o.pending || len(o.unsent) != 0 || o.flips != 0 {185 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)186 t.Fatalf("pending=%v unsent=%d flips=%d", o.pending, len(o.unsent), o.flips)
178 }187 }
@@ -192,13 +201,13 @@ func TestBusyCommitStaysPending(t *testing.T) {
192 }201 }
193 202
194 func TestFlipReportsTimestampAndShownFrame(t *testing.T) {203 func TestFlipReportsTimestampAndShownFrame(t *testing.T) {
195- o, _, _ := testOutput(t)204+ o, _, commits := testOutput(t)
196 o.vrrOn = false205 o.vrrOn = false
197 o.mode.Clock, o.mode.HTotal, o.mode.VTotal = 0, 0, 0206 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 {207 if err := o.commitFrame(70, nil, false, false, pendingFrame{shows: map[ports.WindowID]uint64{1: 4}}); err != nil {
199 t.Fatal(err)208 t.Fatal(err)
200 }209 }
201- o.completed(flipEvent{user: userFrame, when: 5 * time.Second, seq: 11}, map[ports.WindowID]uint64{1: 4})210+ o.completed(flipEvent{user: (*commits)[0].user, when: 5 * time.Second, seq: 11}, map[ports.WindowID]uint64{1: 4})
202 if len(o.unsent) != 1 {211 if len(o.unsent) != 1 {
203 t.Fatalf("reports %d", len(o.unsent))212 t.Fatalf("reports %d", len(o.unsent))
204 }213 }
@@ -344,7 +353,7 @@ func TestBufferRefusedIsNotBlamedOnVRR(t *testing.T) {
344 // GPU, so a newer content is reported only after the flip event; every353 // GPU, so a newer content is reported only after the flip event; every
345 // frame fence is closed.354 // frame fence is closed.
346 func TestRunReportsSeenAfterFlip(t *testing.T) {355 func TestRunReportsSeenAfterFlip(t *testing.T) {
347- o, k, commits := testOutput(t)356+ o, k, commits, commitMu := testOutputMu(t)
348 o.cursor, o.tearing = nil, false357 o.cursor, o.tearing = nil, false
349 flips := make(chan flipEvent, 1)358 flips := make(chan flipEvent, 1)
350 o.flipped = flips359 o.flipped = flips
@@ -360,7 +369,8 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
360 r.EXPECT().UseTarget(mock.Anything).Return()369 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) {370 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
362 f, w, _ := os.Pipe()371 f, w, _ := os.Pipe()
363- w.Close() // readable: signalled372+ _, _ = w.Write([]byte{1}) // readable: signalled
373+ w.Close()
364 mu.Lock()374 mu.Lock()
365 fences = append(fences, f)375 fences = append(fences, f)
366 mu.Unlock()376 mu.Unlock()
@@ -376,7 +386,7 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
376 defer commitMu.Unlock()386 defer commitMu.Unlock()
377 n := 0387 n := 0
378 for _, c := range *commits {388 for _, c := range *commits {
379- if c.user == userFrame {389+ if c.user&3 == userFrame {
380 n++390 n++
381 }391 }
382 }392 }
@@ -399,7 +409,10 @@ func TestRunReportsSeenAfterFlip(t *testing.T) {
399 t.Fatalf("report while a frame is in flight: %+v", p)409 t.Fatalf("report while a frame is in flight: %+v", p)
400 case <-time.After(30 * time.Millisecond):410 case <-time.After(30 * time.Millisecond):
401 }411 }
402- flips <- flipEvent{crtc: tCrtc, user: userFrame, when: time.Second, seq: 1}412+ commitMu.Lock()
413+ last := (*commits)[len(*commits)-1]
414+ commitMu.Unlock()
415+ flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1}
403 select {416 select {
404 case p := <-presented:417 case p := <-presented:
405 if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 {418 if p.Flip == nil || p.Flip.When != time.Second || p.Seen[1] != 3 {
M internal/adapters/headless/output.go +2 -14
@@ -2,7 +2,6 @@ package headless
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "image"
87 "image/png"
@@ -11,9 +10,9 @@ import (
1110 "path/filepath"
1211 "time"
1312
13+ "github.com/bnema/nefertty/internal/adapters/syncfile"
1414 "github.com/bnema/nefertty/internal/ports"
1515 "github.com/bnema/zerowrap"
16- "golang.org/x/sys/unix"
1716 )
1817
1918 type Options struct {
@@ -127,7 +126,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
127126 if done != nil {
128127 // No screen paces headless frames: wait for the GPU before
129128 // reporting that its buffers were read.
130- err = waitFence(done)
129+ err = syncfile.Wait(ctx, done)
131130 done.Close()
132131 if err != nil {
133132 return fmt.Errorf("frame fence: %w", err)
@@ -186,14 +185,3 @@ func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
186185 return r
187186 }
188187 }
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-}
@@ -2,7 +2,6 @@ package headless
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "image"6 "image"
8 "image/png"7 "image/png"
@@ -11,9 +10,9 @@ import (
11 "path/filepath"10 "path/filepath"
12 "time"11 "time"
13 12
13+ "github.com/bnema/nefertty/internal/adapters/syncfile"
14 "github.com/bnema/nefertty/internal/ports"14 "github.com/bnema/nefertty/internal/ports"
15 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
16- "golang.org/x/sys/unix"
17 )16 )
18 17
19 type Options struct {18 type Options struct {
@@ -127,7 +126,7 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
127 if done != nil {126 if done != nil {
128 // No screen paces headless frames: wait for the GPU before127 // No screen paces headless frames: wait for the GPU before
129 // reporting that its buffers were read.128 // reporting that its buffers were read.
130- err = waitFence(done)129+ err = syncfile.Wait(ctx, done)
131 done.Close()130 done.Close()
132 if err != nil {131 if err != nil {
133 return fmt.Errorf("frame fence: %w", err)132 return fmt.Errorf("frame fence: %w", err)
@@ -186,14 +185,3 @@ func (opts Options) send(r *ports.OutputPresented) *ports.OutputPresented {
186 return r185 return r
187 }186 }
188 }187 }
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-}
A internal/adapters/syncfile/syncfile.go +38 -0
@@ -0,0 +1,38 @@
1+// Package syncfile waits for Linux sync files (dma-fence fds), such as the
2+// GPU fence a frame render returns.
3+package syncfile
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "os"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// pollSlice bounds one poll so a canceled ctx is seen.
15+const pollSlice = 100 // ms
16+
17+// Wait blocks until f signals or ctx ends. A sync file is signalled when
18+// it polls readable; an error or invalid fd is reported, not taken as
19+// signalled.
20+func Wait(ctx context.Context, f *os.File) error {
21+ fds := []unix.PollFd{{Fd: int32(f.Fd()), Events: unix.POLLIN}}
22+ for {
23+ n, err := unix.Poll(fds, pollSlice)
24+ switch {
25+ case errors.Is(err, unix.EINTR):
26+ continue
27+ case err != nil:
28+ return fmt.Errorf("poll sync file: %w", err)
29+ case n > 0 && fds[0].Revents&unix.POLLIN != 0:
30+ return nil
31+ case n > 0:
32+ return fmt.Errorf("sync file poll events %#x", fds[0].Revents)
33+ }
34+ if err := ctx.Err(); err != nil {
35+ return err
36+ }
37+ }
38+}
@@ -0,0 +1,38 @@
1+// Package syncfile waits for Linux sync files (dma-fence fds), such as the
2+// GPU fence a frame render returns.
3+package syncfile
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "os"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// pollSlice bounds one poll so a canceled ctx is seen.
15+const pollSlice = 100 // ms
16+
17+// Wait blocks until f signals or ctx ends. A sync file is signalled when
18+// it polls readable; an error or invalid fd is reported, not taken as
19+// signalled.
20+func Wait(ctx context.Context, f *os.File) error {
21+ fds := []unix.PollFd{{Fd: int32(f.Fd()), Events: unix.POLLIN}}
22+ for {
23+ n, err := unix.Poll(fds, pollSlice)
24+ switch {
25+ case errors.Is(err, unix.EINTR):
26+ continue
27+ case err != nil:
28+ return fmt.Errorf("poll sync file: %w", err)
29+ case n > 0 && fds[0].Revents&unix.POLLIN != 0:
30+ return nil
31+ case n > 0:
32+ return fmt.Errorf("sync file poll events %#x", fds[0].Revents)
33+ }
34+ if err := ctx.Err(); err != nil {
35+ return err
36+ }
37+ }
38+}
A internal/adapters/syncfile/syncfile_test.go +53 -0
@@ -0,0 +1,53 @@
1+package syncfile
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "testing"
8+ "time"
9+)
10+
11+// A pipe stands in for a sync file: readable means signalled.
12+func TestWaitReturnsWhenReadable(t *testing.T) {
13+ r, w, err := os.Pipe()
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ defer r.Close()
18+ go func() {
19+ time.Sleep(10 * time.Millisecond)
20+ _, _ = w.Write([]byte{1})
21+ w.Close()
22+ }()
23+ if err := Wait(context.Background(), r); err != nil {
24+ t.Fatal(err)
25+ }
26+}
27+
28+func TestWaitStopsOnCancel(t *testing.T) {
29+ r, w, err := os.Pipe()
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer r.Close()
34+ defer w.Close()
35+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
36+ defer cancel()
37+ if err := Wait(ctx, r); !errors.Is(err, context.DeadlineExceeded) {
38+ t.Fatalf("err %v", err)
39+ }
40+}
41+
42+// POLLERR alone (a write end whose reader is gone) is not a signal.
43+func TestWaitReportsErrorEvents(t *testing.T) {
44+ r, w, err := os.Pipe()
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ defer w.Close()
49+ r.Close()
50+ if err := Wait(context.Background(), w); err == nil {
51+ t.Fatal("POLLERR taken as signalled")
52+ }
53+}
@@ -0,0 +1,53 @@
1+package syncfile
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "testing"
8+ "time"
9+)
10+
11+// A pipe stands in for a sync file: readable means signalled.
12+func TestWaitReturnsWhenReadable(t *testing.T) {
13+ r, w, err := os.Pipe()
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ defer r.Close()
18+ go func() {
19+ time.Sleep(10 * time.Millisecond)
20+ _, _ = w.Write([]byte{1})
21+ w.Close()
22+ }()
23+ if err := Wait(context.Background(), r); err != nil {
24+ t.Fatal(err)
25+ }
26+}
27+
28+func TestWaitStopsOnCancel(t *testing.T) {
29+ r, w, err := os.Pipe()
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer r.Close()
34+ defer w.Close()
35+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
36+ defer cancel()
37+ if err := Wait(ctx, r); !errors.Is(err, context.DeadlineExceeded) {
38+ t.Fatalf("err %v", err)
39+ }
40+}
41+
42+// POLLERR alone (a write end whose reader is gone) is not a signal.
43+func TestWaitReportsErrorEvents(t *testing.T) {
44+ r, w, err := os.Pipe()
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ defer w.Close()
49+ r.Close()
50+ if err := Wait(context.Background(), w); err == nil {
51+ t.Fatal("POLLERR taken as signalled")
52+ }
53+}
M internal/adapters/vulkan/cursor.go +4 -0
@@ -79,6 +79,10 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
7979 sub := vk.ImageSubresource{AspectMask: vk.ImageAspectColorBit}
8080 d.GetImageSubresourceLayout(r.device, c.image, &sub, &layout)
8181 c.pitch = int(layout.RowPitch)
82+ // Cursor planes scan out packed rows from the start of the buffer.
83+ if c.pitch != size*4 || layout.Offset != 0 {
84+ return nil, ports.DMABuf{}, fmt.Errorf("cursor image layout pitch %d offset %d, want pitch %d offset 0", c.pitch, layout.Offset, size*4)
85+ }
8286 if err := checked("vkMapMemory(cursor)", d.MapMemory(r.device, c.memory, 0, vk.DeviceSize(req.Size), 0, &c.mapped)); err != nil {
8387 return nil, ports.DMABuf{}, err
8488 }
@@ -79,6 +79,10 @@ func (r *Renderer) cursorImage(size int) (*cursorImage, ports.DMABuf, error) {
79 sub := vk.ImageSubresource{AspectMask: vk.ImageAspectColorBit}79 sub := vk.ImageSubresource{AspectMask: vk.ImageAspectColorBit}
80 d.GetImageSubresourceLayout(r.device, c.image, &sub, &layout)80 d.GetImageSubresourceLayout(r.device, c.image, &sub, &layout)
81 c.pitch = int(layout.RowPitch)81 c.pitch = int(layout.RowPitch)
82+ // Cursor planes scan out packed rows from the start of the buffer.
83+ if c.pitch != size*4 || layout.Offset != 0 {
84+ return nil, ports.DMABuf{}, fmt.Errorf("cursor image layout pitch %d offset %d, want pitch %d offset 0", c.pitch, layout.Offset, size*4)
85+ }
82 if err := checked("vkMapMemory(cursor)", d.MapMemory(r.device, c.memory, 0, vk.DeviceSize(req.Size), 0, &c.mapped)); err != nil {86 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{}, err87 return nil, ports.DMABuf{}, err
84 }88 }
A internal/adapters/vulkan/cursor_test.go +48 -0
@@ -0,0 +1,48 @@
1+package vulkan
2+
3+import (
4+ "testing"
5+ "unsafe"
6+)
7+
8+// Cursor images are packed (pitch = size*4), exported, and WriteCursor
9+// crops to the image and clears around the pixels.
10+func TestCursorBuffersWriteCursor(t *testing.T) {
11+ r, err := New(64, 48)
12+ if err != nil {
13+ t.Skipf("Vulkan unavailable: %v", err)
14+ }
15+ defer r.Close()
16+ bufs, err := r.CursorBuffers(64)
17+ if err != nil {
18+ t.Skipf("no exportable cursor images: %v", err)
19+ }
20+ for _, b := range bufs {
21+ if p := b.Planes[0]; p.Stride != 64*4 || p.Offset != 0 || b.Format != fourccARGB {
22+ t.Fatalf("layout %+v format %#x", p, b.Format)
23+ }
24+ b.Planes[0].File.Close()
25+ }
26+ mem := func() []byte { c := r.cursors[1]; return unsafe.Slice((*byte)(c.mapped), c.pitch*c.size) }
27+ // A first image fills the corner; a second, smaller one must clear it.
28+ big := make([]byte, 100*100*4)
29+ for i := range big {
30+ big[i] = 0xff
31+ }
32+ if err := r.WriteCursor(1, big, 100, 100); err != nil { // cropped to 64
33+ t.Fatal(err)
34+ }
35+ if m := mem(); m[63*256+63*4] != 0xff {
36+ t.Fatal("cropped image not written")
37+ }
38+ if err := r.WriteCursor(1, []byte{1, 2, 3, 4}, 1, 1); err != nil {
39+ t.Fatal(err)
40+ }
41+ m := mem()
42+ if m[0] != 1 || m[3] != 4 || m[4] != 0 || m[256] != 0 || m[63*256+63*4] != 0 {
43+ t.Fatal("image not cleared around the new pixels")
44+ }
45+ if err := r.WriteCursor(1, []byte{1}, 1, 1); err == nil {
46+ t.Fatal("short image accepted")
47+ }
48+}
@@ -0,0 +1,48 @@
1+package vulkan
2+
3+import (
4+ "testing"
5+ "unsafe"
6+)
7+
8+// Cursor images are packed (pitch = size*4), exported, and WriteCursor
9+// crops to the image and clears around the pixels.
10+func TestCursorBuffersWriteCursor(t *testing.T) {
11+ r, err := New(64, 48)
12+ if err != nil {
13+ t.Skipf("Vulkan unavailable: %v", err)
14+ }
15+ defer r.Close()
16+ bufs, err := r.CursorBuffers(64)
17+ if err != nil {
18+ t.Skipf("no exportable cursor images: %v", err)
19+ }
20+ for _, b := range bufs {
21+ if p := b.Planes[0]; p.Stride != 64*4 || p.Offset != 0 || b.Format != fourccARGB {
22+ t.Fatalf("layout %+v format %#x", p, b.Format)
23+ }
24+ b.Planes[0].File.Close()
25+ }
26+ mem := func() []byte { c := r.cursors[1]; return unsafe.Slice((*byte)(c.mapped), c.pitch*c.size) }
27+ // A first image fills the corner; a second, smaller one must clear it.
28+ big := make([]byte, 100*100*4)
29+ for i := range big {
30+ big[i] = 0xff
31+ }
32+ if err := r.WriteCursor(1, big, 100, 100); err != nil { // cropped to 64
33+ t.Fatal(err)
34+ }
35+ if m := mem(); m[63*256+63*4] != 0xff {
36+ t.Fatal("cropped image not written")
37+ }
38+ if err := r.WriteCursor(1, []byte{1, 2, 3, 4}, 1, 1); err != nil {
39+ t.Fatal(err)
40+ }
41+ m := mem()
42+ if m[0] != 1 || m[3] != 4 || m[4] != 0 || m[256] != 0 || m[63*256+63*4] != 0 {
43+ t.Fatal("image not cleared around the new pixels")
44+ }
45+ if err := r.WriteCursor(1, []byte{1}, 1, 1); err == nil {
46+ t.Fatal("short image accepted")
47+ }
48+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close