feat(surface): rebuild imports after a dmabuf feedback change (#3)
* feat(surface): rebuild imports after a dmabuf feedback change Add Surface.DestroyImports to drop every imported buffer and timeline, safe while a frame is pending, and Feedback.Equal/Clone to tell a changed preference from a repeated one. Document the renderer rebuild pattern. * fix(surface): correct the syncobj test ioctl and clarify DestroyImports storage rules * test(surface): destroy imports before the acquire point and use separate syncobjs
- commit
- e178e0ba9910f57c94ad321b34cc918a07cd1d83
- author
- bnema · · 2026-10-01 20:54 UTC
- committer
- GitHub · · 2026-10-01 20:54 UTC
- parent
- 8c028cb
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.2.0
6 files · +503 -1
M doc.go +26 -0
| @@ -1,4 +1,30 @@ | |||
| 1 | 1 | // Package neferclient is a Wayland client toolkit library for Go: connection, | |
| 2 | 2 | // outputs, surface roles, seat and DMA-BUF presentation. It does not render | |
| 3 | 3 | // and holds no application policy. | |
| 4 | + | // | |
| 5 | + | // # Rebuilding after a dmabuf feedback change | |
| 6 | + | // | |
| 7 | + | // The compositor may change its linux-dmabuf preference at any time (another | |
| 8 | + | // main device or format table, for example when a surface moves to a GPU on | |
| 9 | + | // another output). Every complete round calls [Handler.FeedbackDone], also when | |
| 10 | + | // nothing changed. A renderer built from the first feedback must then be | |
| 11 | + | // rebuilt from the new one. On the owner goroutine: | |
| 12 | + | // | |
| 13 | + | // 1. Keep a [Feedback.Clone] of the feedback each renderer was built from. In | |
| 14 | + | // FeedbackDone, return when [Feedback.Equal] says the preference is the | |
| 15 | + | // one in use. | |
| 16 | + | // 2. Stop watching the renderer's descriptors, call [Surface.DestroyImports] | |
| 17 | + | // (a frame may be pending: that is allowed), then close the old renderer. | |
| 18 | + | // 3. Build the new renderer from [Surface.Feedback] and import its buffers and | |
| 19 | + | // timelines on demand, as for the first one. Ids may be reused. | |
| 20 | + | // 4. Present again when [Handler.Frame] reports the pending frame; Present | |
| 21 | + | // refuses until then, with the old renderer or the new one. | |
| 22 | + | // | |
| 23 | + | // Destroying the wl_buffer and timeline objects never withdraws a commit the | |
| 24 | + | // compositor already holds, and the duplicated descriptors are owned by the | |
| 25 | + | // requests, so rebuilding cannot touch a destroyed object or close a | |
| 26 | + | // descriptor twice. The compositor holds its own references to the DMA-BUFs | |
| 27 | + | // and timelines it received, so closing the old renderer at once is safe; what | |
| 28 | + | // the explicit-sync rule forbids is writing to or recycling storage before its | |
| 29 | + | // release point signals. | |
| 4 | 30 | package neferclient | |
| @@ -1,4 +1,30 @@ | |||||
| 1 | // Package neferclient is a Wayland client toolkit library for Go: connection, | 1 | // Package neferclient is a Wayland client toolkit library for Go: connection, | ||
| 2 | // outputs, surface roles, seat and DMA-BUF presentation. It does not render | 2 | // outputs, surface roles, seat and DMA-BUF presentation. It does not render | ||
| 3 | // and holds no application policy. | 3 | // and holds no application policy. | ||
| 4 | + | // | |||
| 5 | + | // # Rebuilding after a dmabuf feedback change | |||
| 6 | + | // | |||
| 7 | + | // The compositor may change its linux-dmabuf preference at any time (another | |||
| 8 | + | // main device or format table, for example when a surface moves to a GPU on | |||
| 9 | + | // another output). Every complete round calls [Handler.FeedbackDone], also when | |||
| 10 | + | // nothing changed. A renderer built from the first feedback must then be | |||
| 11 | + | // rebuilt from the new one. On the owner goroutine: | |||
| 12 | + | // | |||
| 13 | + | // 1. Keep a [Feedback.Clone] of the feedback each renderer was built from. In | |||
| 14 | + | // FeedbackDone, return when [Feedback.Equal] says the preference is the | |||
| 15 | + | // one in use. | |||
| 16 | + | // 2. Stop watching the renderer's descriptors, call [Surface.DestroyImports] | |||
| 17 | + | // (a frame may be pending: that is allowed), then close the old renderer. | |||
| 18 | + | // 3. Build the new renderer from [Surface.Feedback] and import its buffers and | |||
| 19 | + | // timelines on demand, as for the first one. Ids may be reused. | |||
| 20 | + | // 4. Present again when [Handler.Frame] reports the pending frame; Present | |||
| 21 | + | // refuses until then, with the old renderer or the new one. | |||
| 22 | + | // | |||
| 23 | + | // Destroying the wl_buffer and timeline objects never withdraws a commit the | |||
| 24 | + | // compositor already holds, and the duplicated descriptors are owned by the | |||
| 25 | + | // requests, so rebuilding cannot touch a destroyed object or close a | |||
| 26 | + | // descriptor twice. The compositor holds its own references to the DMA-BUFs | |||
| 27 | + | // and timelines it received, so closing the old renderer at once is safe; what | |||
| 28 | + | // the explicit-sync rule forbids is writing to or recycling storage before its | |||
| 29 | + | // release point signals. | |||
| 4 | package neferclient | 30 | package neferclient | ||
A feedback_wire_test.go +277 -0
| @@ -0,0 +1,277 @@ | |||
| 1 | + | package neferclient_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | "golang.org/x/sys/unix" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/neferclient" | |
| 13 | + | neferclientmocks "github.com/bnema/neferclient/mocks" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | dmabufGlobal = 2 // names in surfaceGlobals | |
| 18 | + | syncobjGlobal = 3 | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // feedbackPeer scripts zwp_linux_dmabuf_feedback_v1 rounds and tracks the | |
| 22 | + | // lifetime of every wl_buffer and timeline object the client creates, over the | |
| 23 | + | // wire peer. The peer answers a destructor with delete_id, so ids are recycled | |
| 24 | + | // as with a real compositor and lifetimes are tracked per live object. A | |
| 25 | + | // destructor is counted only for a live object: a second destroy of one would | |
| 26 | + | // leave created != destroyed or break the wire (the peer's delete_id for an id | |
| 27 | + | // already released is a protocol error, which fails the roundtrip). | |
| 28 | + | type feedbackPeer struct { | |
| 29 | + | t *testing.T | |
| 30 | + | srv *wireServer | |
| 31 | + | ||
| 32 | + | mu sync.Mutex | |
| 33 | + | feedback uint32 // the surface's feedback object | |
| 34 | + | live map[uint32]bool // buffers and timelines created and not yet destroyed | |
| 35 | + | params map[uint32]bool // zwp_linux_buffer_params_v1 objects | |
| 36 | + | created int | |
| 37 | + | destroyed int | |
| 38 | + | violation []string // an id created while its previous object is still live | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func newFeedbackPeer(t *testing.T, srv *wireServer) *feedbackPeer { | |
| 42 | + | p := &feedbackPeer{t: t, srv: srv, live: map[uint32]bool{}, params: map[uint32]bool{}} | |
| 43 | + | srv.mu.Lock() | |
| 44 | + | srv.hook = func(obj uint32, op uint16, body []byte) { | |
| 45 | + | srv.mu.Lock() | |
| 46 | + | dmabuf, syncobj := srv.bound[dmabufGlobal].id, srv.bound[syncobjGlobal].id | |
| 47 | + | srv.mu.Unlock() | |
| 48 | + | p.mu.Lock() | |
| 49 | + | defer p.mu.Unlock() | |
| 50 | + | create := func(id uint32) { | |
| 51 | + | if p.live[id] { | |
| 52 | + | p.violation = append(p.violation, fmt.Sprintf("object %d created twice", id)) | |
| 53 | + | } | |
| 54 | + | p.live[id] = true | |
| 55 | + | p.created++ | |
| 56 | + | } | |
| 57 | + | switch { | |
| 58 | + | case obj == dmabuf && op == 1 && len(body) == 4: // create_params(new_id) | |
| 59 | + | p.params[binary.LittleEndian.Uint32(body)] = true | |
| 60 | + | case obj == dmabuf && op == 2 && len(body) == 4: // get_default_feedback(new_id) | |
| 61 | + | p.feedback = binary.LittleEndian.Uint32(body) | |
| 62 | + | case obj == syncobj && op == 2 && len(body) == 4: // import_timeline(new_id, fd) | |
| 63 | + | create(binary.LittleEndian.Uint32(body)) | |
| 64 | + | case p.params[obj] && op == 3 && len(body) == 20: // params.create_immed(new_id, w, h, format, flags) | |
| 65 | + | create(binary.LittleEndian.Uint32(body)) | |
| 66 | + | case p.params[obj] && op == 0 && len(body) == 0: // params.destroy: the id may be reused | |
| 67 | + | delete(p.params, obj) | |
| 68 | + | case op == 0 && len(body) == 0 && p.live[obj]: // destructor of a live buffer or timeline | |
| 69 | + | delete(p.live, obj) | |
| 70 | + | p.destroyed++ | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | srv.mu.Unlock() | |
| 74 | + | return p | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // stats returns how many tracked objects were created, destroyed and are live. | |
| 78 | + | func (p *feedbackPeer) stats() (created, destroyed, live int, violations []string) { | |
| 79 | + | p.mu.Lock() | |
| 80 | + | defer p.mu.Unlock() | |
| 81 | + | return p.created, p.destroyed, len(p.live), append([]string(nil), p.violation...) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // round sends one complete feedback round: the format table, the main device | |
| 85 | + | // and one tranche listing the given table indices. | |
| 86 | + | func (p *feedbackPeer) round(table []neferclient.Format, device uint64, indices ...uint16) { | |
| 87 | + | p.t.Helper() | |
| 88 | + | p.mu.Lock() | |
| 89 | + | fb := p.feedback | |
| 90 | + | p.mu.Unlock() | |
| 91 | + | require.NotZero(p.t, fb, "the client requested its feedback object") | |
| 92 | + | ||
| 93 | + | var entries []byte | |
| 94 | + | for _, f := range table { | |
| 95 | + | entries = binary.NativeEndian.AppendUint32(entries, f.FourCC) | |
| 96 | + | entries = binary.NativeEndian.AppendUint32(entries, 0) | |
| 97 | + | entries = binary.NativeEndian.AppendUint64(entries, f.Modifier) | |
| 98 | + | } | |
| 99 | + | fd, err := unix.MemfdCreate("table", unix.MFD_CLOEXEC) | |
| 100 | + | require.NoError(p.t, err) | |
| 101 | + | defer unix.Close(fd) | |
| 102 | + | _, err = unix.Write(fd, entries) | |
| 103 | + | require.NoError(p.t, err) | |
| 104 | + | _, _, err = p.srv.conn.WriteMsgUnix(frame(fb, 1, appendU32(nil, uint32(len(entries)))), unix.UnixRights(fd), nil) | |
| 105 | + | require.NoError(p.t, err) | |
| 106 | + | ||
| 107 | + | dev := appendU32(nil, 8) | |
| 108 | + | dev = binary.NativeEndian.AppendUint64(dev, device) | |
| 109 | + | p.srv.write(frame(fb, 2, dev)) | |
| 110 | + | var idx []byte | |
| 111 | + | for _, i := range indices { | |
| 112 | + | idx = binary.NativeEndian.AppendUint16(idx, i) | |
| 113 | + | } | |
| 114 | + | n := len(idx) | |
| 115 | + | for len(idx)%4 != 0 { | |
| 116 | + | idx = append(idx, 0) | |
| 117 | + | } | |
| 118 | + | p.srv.write(frame(fb, 5, append(appendU32(nil, uint32(n)), idx...))) | |
| 119 | + | p.srv.write(frame(fb, 3, nil)) // tranche_done | |
| 120 | + | p.srv.write(frame(fb, 0, nil)) // done | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func connectFeedbackSurface(t *testing.T) (*neferclient.Conn, *neferclient.Surface, *feedbackPeer) { | |
| 124 | + | t.Helper() | |
| 125 | + | c, srv := connectWire(t, surfaceGlobals, surfaceOutputs) | |
| 126 | + | peer := newFeedbackPeer(t, srv) | |
| 127 | + | s, err := c.NewToplevel("feedback", 64, 48) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | require.NoError(t, c.Roundtrip()) | |
| 130 | + | return c, s, peer | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // TestFeedbackChangeAfterStartup: every complete round is reported; Equal tells | |
| 134 | + | // a changed main device or format table from a repeated round, and Clone keeps | |
| 135 | + | // the feedback a renderer was built from valid across later rounds. | |
| 136 | + | func TestFeedbackChangeAfterStartup(t *testing.T) { | |
| 137 | + | c, s, peer := connectFeedbackSurface(t) | |
| 138 | + | require.Nil(t, s.Feedback()) | |
| 139 | + | ||
| 140 | + | var done int | |
| 141 | + | h := neferclientmocks.NewMockHandler(t) | |
| 142 | + | h.EXPECT().FeedbackDone(s.ID()).Run(func(neferclient.SurfaceID) { done++ }).Return() | |
| 143 | + | wait := func(n int) { | |
| 144 | + | t.Helper() | |
| 145 | + | dispatchUntil(t, c, h, func() bool { return done >= n }) | |
| 146 | + | } | |
| 147 | + | table := []neferclient.Format{{FourCC: 0x34325258}, {FourCC: 0x34325241, Modifier: 7}, {FourCC: 0x34325258, Modifier: 1 << 40}} | |
| 148 | + | ||
| 149 | + | peer.round(table, 0xe280, 0, 1) | |
| 150 | + | wait(1) | |
| 151 | + | inUse := s.Feedback().Clone() // what the first renderer was built from | |
| 152 | + | require.Equal(t, &neferclient.Feedback{MainDevice: 0xe280, Formats: table[:2]}, inUse) | |
| 153 | + | require.True(t, s.Feedback().Equal(inUse)) | |
| 154 | + | ||
| 155 | + | peer.round(table, 0xe280, 0, 1) // the compositor repeats its preference | |
| 156 | + | wait(2) | |
| 157 | + | require.True(t, s.Feedback().Equal(inUse), "a repeated round is not a change") | |
| 158 | + | ||
| 159 | + | peer.round(table, 0xe281, 0, 1) // another main device | |
| 160 | + | wait(3) | |
| 161 | + | require.False(t, s.Feedback().Equal(inUse), "main device changed") | |
| 162 | + | require.Equal(t, uint64(0xe280), inUse.MainDevice, "the clone is unaffected by the round") | |
| 163 | + | ||
| 164 | + | peer.round(table, 0xe281, 0, 1, 2) // same device, more formats | |
| 165 | + | wait(4) | |
| 166 | + | changed := s.Feedback().Clone() | |
| 167 | + | require.Equal(t, table, changed.Formats) | |
| 168 | + | ||
| 169 | + | peer.round(table, 0xe281, 2, 1, 0) // same formats, other order: tranche order matters | |
| 170 | + | wait(5) | |
| 171 | + | require.False(t, s.Feedback().Equal(changed), "format order changed") | |
| 172 | + | require.Len(t, changed.Formats, 3, "the clone keeps its own formats") | |
| 173 | + | require.Equal(t, table[0], changed.Formats[0]) | |
| 174 | + | // The library double-buffers its format storage: this round writes the | |
| 175 | + | // buffer the earlier round used, which a shallow clone would share. | |
| 176 | + | peer.round(table, 0xe281, 2, 2, 2) | |
| 177 | + | wait(6) | |
| 178 | + | require.Equal(t, table, changed.Formats, "the clone does not alias library storage") | |
| 179 | + | require.Equal(t, table[:2], inUse.Formats) | |
| 180 | + | ||
| 181 | + | var none *neferclient.Feedback | |
| 182 | + | require.True(t, none.Equal(nil)) | |
| 183 | + | require.False(t, none.Equal(inUse)) | |
| 184 | + | require.False(t, inUse.Equal(nil)) | |
| 185 | + | require.Nil(t, none.Clone()) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // TestDestroyImportsWithPendingFrame: the buffers and timelines are dropped | |
| 189 | + | // while a frame is pending, the old ids are refused, no request goes to a | |
| 190 | + | // destroyed object, the new imports present once the frame fired, and every | |
| 191 | + | // object is destroyed exactly once, Close included. | |
| 192 | + | func TestDestroyImportsWithPendingFrame(t *testing.T) { | |
| 193 | + | c, s, peer := connectFeedbackSurface(t) | |
| 194 | + | s.MarkPresentable(64, 48) | |
| 195 | + | surfaceID := s.SurfaceObjectID() | |
| 196 | + | var callback, commits int | |
| 197 | + | var cbMu sync.Mutex | |
| 198 | + | peer.srv.mu.Lock() | |
| 199 | + | inner := peer.srv.hook | |
| 200 | + | peer.srv.hook = func(obj uint32, op uint16, body []byte) { | |
| 201 | + | inner(obj, op, body) | |
| 202 | + | if obj == surfaceID { | |
| 203 | + | cbMu.Lock() | |
| 204 | + | defer cbMu.Unlock() | |
| 205 | + | switch { | |
| 206 | + | case op == 3 && len(body) == 4: | |
| 207 | + | callback = int(binary.LittleEndian.Uint32(body)) | |
| 208 | + | case op == 6: | |
| 209 | + | commits++ | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | peer.srv.mu.Unlock() | |
| 214 | + | ||
| 215 | + | memfd, err := unix.MemfdCreate("imports", unix.MFD_CLOEXEC) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | defer unix.Close(memfd) | |
| 218 | + | buf := &neferclient.Buffer{Width: 64, Height: 48, FourCC: 0x34325258, PlaneCount: 1, | |
| 219 | + | Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}} | |
| 220 | + | imports := func() { | |
| 221 | + | t.Helper() | |
| 222 | + | require.NoError(t, s.ImportBuffer(1, buf)) | |
| 223 | + | require.NoError(t, s.ImportBuffer(2, buf)) | |
| 224 | + | require.NoError(t, s.ImportTimeline(1, memfd)) | |
| 225 | + | require.NoError(t, s.ImportTimeline(2, memfd)) | |
| 226 | + | } | |
| 227 | + | imports() | |
| 228 | + | p := &neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, AcquirePoint: 1, ReleasePoint: 2} | |
| 229 | + | require.NoError(t, s.Present(p)) | |
| 230 | + | require.NoError(t, c.Roundtrip()) | |
| 231 | + | cbMu.Lock() | |
| 232 | + | cb, committed := callback, commits | |
| 233 | + | cbMu.Unlock() | |
| 234 | + | require.NotZero(t, cb, "a frame is pending") | |
| 235 | + | require.Positive(t, committed) | |
| 236 | + | created, destroyed, live, bad := peer.stats() | |
| 237 | + | require.Equal(t, [3]int{4, 0, 4}, [3]int{created, destroyed, live}) | |
| 238 | + | require.Empty(t, bad) | |
| 239 | + | ||
| 240 | + | open := openFDs(t) | |
| 241 | + | require.NoError(t, s.DestroyImports()) | |
| 242 | + | require.NoError(t, s.DestroyImports(), "nothing left to destroy") | |
| 243 | + | require.NoError(t, c.Roundtrip()) | |
| 244 | + | require.Equal(t, open, openFDs(t), "no descriptor is closed or leaked") | |
| 245 | + | created, destroyed, live, bad = peer.stats() | |
| 246 | + | require.Equal(t, [3]int{4, 4, 0}, [3]int{created, destroyed, live}, "every old object destroyed exactly once") | |
| 247 | + | require.Empty(t, bad) | |
| 248 | + | ||
| 249 | + | // The old ids are gone: Present, DestroyBuffer and DestroyTimeline send nothing. | |
| 250 | + | require.ErrorContains(t, s.Present(p), "frame callback pending") // gate first | |
| 251 | + | require.ErrorContains(t, s.DestroyBuffer(1), "unknown buffer 1") | |
| 252 | + | require.ErrorContains(t, s.DestroyTimeline(1), "unknown timeline 1") | |
| 253 | + | ||
| 254 | + | // The frame fires; the surface is still presentable with re-imported ids. | |
| 255 | + | h := neferclientmocks.NewMockHandler(t) | |
| 256 | + | var framed bool | |
| 257 | + | h.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { framed = true }).Return() | |
| 258 | + | peer.srv.write(frame(uint32(cb), 0, appendU32(nil, 1))) | |
| 259 | + | peer.srv.write(frame(1, 1, appendU32(nil, uint32(cb)))) | |
| 260 | + | dispatchUntil(t, c, h, func() bool { return framed }) | |
| 261 | + | require.ErrorContains(t, s.Present(p), "unknown buffer 1") | |
| 262 | + | imports() | |
| 263 | + | require.NoError(t, s.Present(p)) | |
| 264 | + | require.NoError(t, c.Roundtrip()) | |
| 265 | + | created, destroyed, live, bad = peer.stats() | |
| 266 | + | require.Equal(t, [3]int{8, 4, 4}, [3]int{created, destroyed, live}) | |
| 267 | + | require.Empty(t, bad) | |
| 268 | + | ||
| 269 | + | // Close destroys what is imported now, and only that. | |
| 270 | + | require.NoError(t, s.Close()) | |
| 271 | + | require.ErrorIs(t, s.DestroyImports(), neferclient.ErrClosed) | |
| 272 | + | require.NoError(t, c.Roundtrip()) | |
| 273 | + | created, destroyed, live, bad = peer.stats() | |
| 274 | + | require.Equal(t, [3]int{8, 8, 0}, [3]int{created, destroyed, live}, "Close destroys the new objects once") | |
| 275 | + | require.Empty(t, bad) | |
| 276 | + | require.NoError(t, c.Dispatch(nil)) | |
| 277 | + | } | |
| @@ -0,0 +1,277 @@ | |||||
| 1 | + | package neferclient_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | "golang.org/x/sys/unix" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/neferclient" | |||
| 13 | + | neferclientmocks "github.com/bnema/neferclient/mocks" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | dmabufGlobal = 2 // names in surfaceGlobals | |||
| 18 | + | syncobjGlobal = 3 | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // feedbackPeer scripts zwp_linux_dmabuf_feedback_v1 rounds and tracks the | |||
| 22 | + | // lifetime of every wl_buffer and timeline object the client creates, over the | |||
| 23 | + | // wire peer. The peer answers a destructor with delete_id, so ids are recycled | |||
| 24 | + | // as with a real compositor and lifetimes are tracked per live object. A | |||
| 25 | + | // destructor is counted only for a live object: a second destroy of one would | |||
| 26 | + | // leave created != destroyed or break the wire (the peer's delete_id for an id | |||
| 27 | + | // already released is a protocol error, which fails the roundtrip). | |||
| 28 | + | type feedbackPeer struct { | |||
| 29 | + | t *testing.T | |||
| 30 | + | srv *wireServer | |||
| 31 | + | ||||
| 32 | + | mu sync.Mutex | |||
| 33 | + | feedback uint32 // the surface's feedback object | |||
| 34 | + | live map[uint32]bool // buffers and timelines created and not yet destroyed | |||
| 35 | + | params map[uint32]bool // zwp_linux_buffer_params_v1 objects | |||
| 36 | + | created int | |||
| 37 | + | destroyed int | |||
| 38 | + | violation []string // an id created while its previous object is still live | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func newFeedbackPeer(t *testing.T, srv *wireServer) *feedbackPeer { | |||
| 42 | + | p := &feedbackPeer{t: t, srv: srv, live: map[uint32]bool{}, params: map[uint32]bool{}} | |||
| 43 | + | srv.mu.Lock() | |||
| 44 | + | srv.hook = func(obj uint32, op uint16, body []byte) { | |||
| 45 | + | srv.mu.Lock() | |||
| 46 | + | dmabuf, syncobj := srv.bound[dmabufGlobal].id, srv.bound[syncobjGlobal].id | |||
| 47 | + | srv.mu.Unlock() | |||
| 48 | + | p.mu.Lock() | |||
| 49 | + | defer p.mu.Unlock() | |||
| 50 | + | create := func(id uint32) { | |||
| 51 | + | if p.live[id] { | |||
| 52 | + | p.violation = append(p.violation, fmt.Sprintf("object %d created twice", id)) | |||
| 53 | + | } | |||
| 54 | + | p.live[id] = true | |||
| 55 | + | p.created++ | |||
| 56 | + | } | |||
| 57 | + | switch { | |||
| 58 | + | case obj == dmabuf && op == 1 && len(body) == 4: // create_params(new_id) | |||
| 59 | + | p.params[binary.LittleEndian.Uint32(body)] = true | |||
| 60 | + | case obj == dmabuf && op == 2 && len(body) == 4: // get_default_feedback(new_id) | |||
| 61 | + | p.feedback = binary.LittleEndian.Uint32(body) | |||
| 62 | + | case obj == syncobj && op == 2 && len(body) == 4: // import_timeline(new_id, fd) | |||
| 63 | + | create(binary.LittleEndian.Uint32(body)) | |||
| 64 | + | case p.params[obj] && op == 3 && len(body) == 20: // params.create_immed(new_id, w, h, format, flags) | |||
| 65 | + | create(binary.LittleEndian.Uint32(body)) | |||
| 66 | + | case p.params[obj] && op == 0 && len(body) == 0: // params.destroy: the id may be reused | |||
| 67 | + | delete(p.params, obj) | |||
| 68 | + | case op == 0 && len(body) == 0 && p.live[obj]: // destructor of a live buffer or timeline | |||
| 69 | + | delete(p.live, obj) | |||
| 70 | + | p.destroyed++ | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | srv.mu.Unlock() | |||
| 74 | + | return p | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // stats returns how many tracked objects were created, destroyed and are live. | |||
| 78 | + | func (p *feedbackPeer) stats() (created, destroyed, live int, violations []string) { | |||
| 79 | + | p.mu.Lock() | |||
| 80 | + | defer p.mu.Unlock() | |||
| 81 | + | return p.created, p.destroyed, len(p.live), append([]string(nil), p.violation...) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // round sends one complete feedback round: the format table, the main device | |||
| 85 | + | // and one tranche listing the given table indices. | |||
| 86 | + | func (p *feedbackPeer) round(table []neferclient.Format, device uint64, indices ...uint16) { | |||
| 87 | + | p.t.Helper() | |||
| 88 | + | p.mu.Lock() | |||
| 89 | + | fb := p.feedback | |||
| 90 | + | p.mu.Unlock() | |||
| 91 | + | require.NotZero(p.t, fb, "the client requested its feedback object") | |||
| 92 | + | ||||
| 93 | + | var entries []byte | |||
| 94 | + | for _, f := range table { | |||
| 95 | + | entries = binary.NativeEndian.AppendUint32(entries, f.FourCC) | |||
| 96 | + | entries = binary.NativeEndian.AppendUint32(entries, 0) | |||
| 97 | + | entries = binary.NativeEndian.AppendUint64(entries, f.Modifier) | |||
| 98 | + | } | |||
| 99 | + | fd, err := unix.MemfdCreate("table", unix.MFD_CLOEXEC) | |||
| 100 | + | require.NoError(p.t, err) | |||
| 101 | + | defer unix.Close(fd) | |||
| 102 | + | _, err = unix.Write(fd, entries) | |||
| 103 | + | require.NoError(p.t, err) | |||
| 104 | + | _, _, err = p.srv.conn.WriteMsgUnix(frame(fb, 1, appendU32(nil, uint32(len(entries)))), unix.UnixRights(fd), nil) | |||
| 105 | + | require.NoError(p.t, err) | |||
| 106 | + | ||||
| 107 | + | dev := appendU32(nil, 8) | |||
| 108 | + | dev = binary.NativeEndian.AppendUint64(dev, device) | |||
| 109 | + | p.srv.write(frame(fb, 2, dev)) | |||
| 110 | + | var idx []byte | |||
| 111 | + | for _, i := range indices { | |||
| 112 | + | idx = binary.NativeEndian.AppendUint16(idx, i) | |||
| 113 | + | } | |||
| 114 | + | n := len(idx) | |||
| 115 | + | for len(idx)%4 != 0 { | |||
| 116 | + | idx = append(idx, 0) | |||
| 117 | + | } | |||
| 118 | + | p.srv.write(frame(fb, 5, append(appendU32(nil, uint32(n)), idx...))) | |||
| 119 | + | p.srv.write(frame(fb, 3, nil)) // tranche_done | |||
| 120 | + | p.srv.write(frame(fb, 0, nil)) // done | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func connectFeedbackSurface(t *testing.T) (*neferclient.Conn, *neferclient.Surface, *feedbackPeer) { | |||
| 124 | + | t.Helper() | |||
| 125 | + | c, srv := connectWire(t, surfaceGlobals, surfaceOutputs) | |||
| 126 | + | peer := newFeedbackPeer(t, srv) | |||
| 127 | + | s, err := c.NewToplevel("feedback", 64, 48) | |||
| 128 | + | require.NoError(t, err) | |||
| 129 | + | require.NoError(t, c.Roundtrip()) | |||
| 130 | + | return c, s, peer | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // TestFeedbackChangeAfterStartup: every complete round is reported; Equal tells | |||
| 134 | + | // a changed main device or format table from a repeated round, and Clone keeps | |||
| 135 | + | // the feedback a renderer was built from valid across later rounds. | |||
| 136 | + | func TestFeedbackChangeAfterStartup(t *testing.T) { | |||
| 137 | + | c, s, peer := connectFeedbackSurface(t) | |||
| 138 | + | require.Nil(t, s.Feedback()) | |||
| 139 | + | ||||
| 140 | + | var done int | |||
| 141 | + | h := neferclientmocks.NewMockHandler(t) | |||
| 142 | + | h.EXPECT().FeedbackDone(s.ID()).Run(func(neferclient.SurfaceID) { done++ }).Return() | |||
| 143 | + | wait := func(n int) { | |||
| 144 | + | t.Helper() | |||
| 145 | + | dispatchUntil(t, c, h, func() bool { return done >= n }) | |||
| 146 | + | } | |||
| 147 | + | table := []neferclient.Format{{FourCC: 0x34325258}, {FourCC: 0x34325241, Modifier: 7}, {FourCC: 0x34325258, Modifier: 1 << 40}} | |||
| 148 | + | ||||
| 149 | + | peer.round(table, 0xe280, 0, 1) | |||
| 150 | + | wait(1) | |||
| 151 | + | inUse := s.Feedback().Clone() // what the first renderer was built from | |||
| 152 | + | require.Equal(t, &neferclient.Feedback{MainDevice: 0xe280, Formats: table[:2]}, inUse) | |||
| 153 | + | require.True(t, s.Feedback().Equal(inUse)) | |||
| 154 | + | ||||
| 155 | + | peer.round(table, 0xe280, 0, 1) // the compositor repeats its preference | |||
| 156 | + | wait(2) | |||
| 157 | + | require.True(t, s.Feedback().Equal(inUse), "a repeated round is not a change") | |||
| 158 | + | ||||
| 159 | + | peer.round(table, 0xe281, 0, 1) // another main device | |||
| 160 | + | wait(3) | |||
| 161 | + | require.False(t, s.Feedback().Equal(inUse), "main device changed") | |||
| 162 | + | require.Equal(t, uint64(0xe280), inUse.MainDevice, "the clone is unaffected by the round") | |||
| 163 | + | ||||
| 164 | + | peer.round(table, 0xe281, 0, 1, 2) // same device, more formats | |||
| 165 | + | wait(4) | |||
| 166 | + | changed := s.Feedback().Clone() | |||
| 167 | + | require.Equal(t, table, changed.Formats) | |||
| 168 | + | ||||
| 169 | + | peer.round(table, 0xe281, 2, 1, 0) // same formats, other order: tranche order matters | |||
| 170 | + | wait(5) | |||
| 171 | + | require.False(t, s.Feedback().Equal(changed), "format order changed") | |||
| 172 | + | require.Len(t, changed.Formats, 3, "the clone keeps its own formats") | |||
| 173 | + | require.Equal(t, table[0], changed.Formats[0]) | |||
| 174 | + | // The library double-buffers its format storage: this round writes the | |||
| 175 | + | // buffer the earlier round used, which a shallow clone would share. | |||
| 176 | + | peer.round(table, 0xe281, 2, 2, 2) | |||
| 177 | + | wait(6) | |||
| 178 | + | require.Equal(t, table, changed.Formats, "the clone does not alias library storage") | |||
| 179 | + | require.Equal(t, table[:2], inUse.Formats) | |||
| 180 | + | ||||
| 181 | + | var none *neferclient.Feedback | |||
| 182 | + | require.True(t, none.Equal(nil)) | |||
| 183 | + | require.False(t, none.Equal(inUse)) | |||
| 184 | + | require.False(t, inUse.Equal(nil)) | |||
| 185 | + | require.Nil(t, none.Clone()) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // TestDestroyImportsWithPendingFrame: the buffers and timelines are dropped | |||
| 189 | + | // while a frame is pending, the old ids are refused, no request goes to a | |||
| 190 | + | // destroyed object, the new imports present once the frame fired, and every | |||
| 191 | + | // object is destroyed exactly once, Close included. | |||
| 192 | + | func TestDestroyImportsWithPendingFrame(t *testing.T) { | |||
| 193 | + | c, s, peer := connectFeedbackSurface(t) | |||
| 194 | + | s.MarkPresentable(64, 48) | |||
| 195 | + | surfaceID := s.SurfaceObjectID() | |||
| 196 | + | var callback, commits int | |||
| 197 | + | var cbMu sync.Mutex | |||
| 198 | + | peer.srv.mu.Lock() | |||
| 199 | + | inner := peer.srv.hook | |||
| 200 | + | peer.srv.hook = func(obj uint32, op uint16, body []byte) { | |||
| 201 | + | inner(obj, op, body) | |||
| 202 | + | if obj == surfaceID { | |||
| 203 | + | cbMu.Lock() | |||
| 204 | + | defer cbMu.Unlock() | |||
| 205 | + | switch { | |||
| 206 | + | case op == 3 && len(body) == 4: | |||
| 207 | + | callback = int(binary.LittleEndian.Uint32(body)) | |||
| 208 | + | case op == 6: | |||
| 209 | + | commits++ | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | peer.srv.mu.Unlock() | |||
| 214 | + | ||||
| 215 | + | memfd, err := unix.MemfdCreate("imports", unix.MFD_CLOEXEC) | |||
| 216 | + | require.NoError(t, err) | |||
| 217 | + | defer unix.Close(memfd) | |||
| 218 | + | buf := &neferclient.Buffer{Width: 64, Height: 48, FourCC: 0x34325258, PlaneCount: 1, | |||
| 219 | + | Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}} | |||
| 220 | + | imports := func() { | |||
| 221 | + | t.Helper() | |||
| 222 | + | require.NoError(t, s.ImportBuffer(1, buf)) | |||
| 223 | + | require.NoError(t, s.ImportBuffer(2, buf)) | |||
| 224 | + | require.NoError(t, s.ImportTimeline(1, memfd)) | |||
| 225 | + | require.NoError(t, s.ImportTimeline(2, memfd)) | |||
| 226 | + | } | |||
| 227 | + | imports() | |||
| 228 | + | p := &neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, AcquirePoint: 1, ReleasePoint: 2} | |||
| 229 | + | require.NoError(t, s.Present(p)) | |||
| 230 | + | require.NoError(t, c.Roundtrip()) | |||
| 231 | + | cbMu.Lock() | |||
| 232 | + | cb, committed := callback, commits | |||
| 233 | + | cbMu.Unlock() | |||
| 234 | + | require.NotZero(t, cb, "a frame is pending") | |||
| 235 | + | require.Positive(t, committed) | |||
| 236 | + | created, destroyed, live, bad := peer.stats() | |||
| 237 | + | require.Equal(t, [3]int{4, 0, 4}, [3]int{created, destroyed, live}) | |||
| 238 | + | require.Empty(t, bad) | |||
| 239 | + | ||||
| 240 | + | open := openFDs(t) | |||
| 241 | + | require.NoError(t, s.DestroyImports()) | |||
| 242 | + | require.NoError(t, s.DestroyImports(), "nothing left to destroy") | |||
| 243 | + | require.NoError(t, c.Roundtrip()) | |||
| 244 | + | require.Equal(t, open, openFDs(t), "no descriptor is closed or leaked") | |||
| 245 | + | created, destroyed, live, bad = peer.stats() | |||
| 246 | + | require.Equal(t, [3]int{4, 4, 0}, [3]int{created, destroyed, live}, "every old object destroyed exactly once") | |||
| 247 | + | require.Empty(t, bad) | |||
| 248 | + | ||||
| 249 | + | // The old ids are gone: Present, DestroyBuffer and DestroyTimeline send nothing. | |||
| 250 | + | require.ErrorContains(t, s.Present(p), "frame callback pending") // gate first | |||
| 251 | + | require.ErrorContains(t, s.DestroyBuffer(1), "unknown buffer 1") | |||
| 252 | + | require.ErrorContains(t, s.DestroyTimeline(1), "unknown timeline 1") | |||
| 253 | + | ||||
| 254 | + | // The frame fires; the surface is still presentable with re-imported ids. | |||
| 255 | + | h := neferclientmocks.NewMockHandler(t) | |||
| 256 | + | var framed bool | |||
| 257 | + | h.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { framed = true }).Return() | |||
| 258 | + | peer.srv.write(frame(uint32(cb), 0, appendU32(nil, 1))) | |||
| 259 | + | peer.srv.write(frame(1, 1, appendU32(nil, uint32(cb)))) | |||
| 260 | + | dispatchUntil(t, c, h, func() bool { return framed }) | |||
| 261 | + | require.ErrorContains(t, s.Present(p), "unknown buffer 1") | |||
| 262 | + | imports() | |||
| 263 | + | require.NoError(t, s.Present(p)) | |||
| 264 | + | require.NoError(t, c.Roundtrip()) | |||
| 265 | + | created, destroyed, live, bad = peer.stats() | |||
| 266 | + | require.Equal(t, [3]int{8, 4, 4}, [3]int{created, destroyed, live}) | |||
| 267 | + | require.Empty(t, bad) | |||
| 268 | + | ||||
| 269 | + | // Close destroys what is imported now, and only that. | |||
| 270 | + | require.NoError(t, s.Close()) | |||
| 271 | + | require.ErrorIs(t, s.DestroyImports(), neferclient.ErrClosed) | |||
| 272 | + | require.NoError(t, c.Roundtrip()) | |||
| 273 | + | created, destroyed, live, bad = peer.stats() | |||
| 274 | + | require.Equal(t, [3]int{8, 8, 0}, [3]int{created, destroyed, live}, "Close destroys the new objects once") | |||
| 275 | + | require.Empty(t, bad) | |||
| 276 | + | require.NoError(t, c.Dispatch(nil)) | |||
| 277 | + | } | |||
M headless_surface_test.go +135 -0
| @@ -2,11 +2,14 @@ package neferclient_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "runtime" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 8 | + | "unsafe" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/stretchr/testify/mock" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 10 | 13 | ||
| 11 | 14 | "github.com/bnema/neferclient" | |
| 12 | 15 | neferclientmocks "github.com/bnema/neferclient/mocks" | |
| @@ -135,3 +138,135 @@ func TestHeadlessSeat(t *testing.T) { | |||
| 135 | 138 | require.NoError(t, seat.SetCursor(neferclient.CursorPointer)) | |
| 136 | 139 | require.NoError(t, s.Close()) | |
| 137 | 140 | } | |
| 141 | + | ||
| 142 | + | // newUdmabuf returns a linear DMA-BUF made from a udmabuf; the test is skipped | |
| 143 | + | // when the machine lacks /dev/udmabuf. | |
| 144 | + | func newUdmabuf(t *testing.T, w, h int) int { | |
| 145 | + | t.Helper() | |
| 146 | + | size := (w*h*4 + 4095) &^ 4095 | |
| 147 | + | mem, err := unix.MemfdCreate("udmabuf", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | t.Cleanup(func() { _ = unix.Close(mem) }) | |
| 150 | + | require.NoError(t, unix.Ftruncate(mem, int64(size))) | |
| 151 | + | _, err = unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | dev, err := unix.Open("/dev/udmabuf", unix.O_RDWR|unix.O_CLOEXEC, 0) | |
| 154 | + | if err != nil { | |
| 155 | + | t.Skipf("no udmabuf: %v", err) | |
| 156 | + | } | |
| 157 | + | defer unix.Close(dev) | |
| 158 | + | req := struct { | |
| 159 | + | memfd, flags uint32 | |
| 160 | + | offset, size uint64 | |
| 161 | + | }{memfd: uint32(mem), size: uint64(size)} | |
| 162 | + | fd, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0x40187542, uintptr(unsafe.Pointer(&req))) | |
| 163 | + | if errno != 0 { | |
| 164 | + | t.Skipf("UDMABUF_CREATE: %v", errno) | |
| 165 | + | } | |
| 166 | + | t.Cleanup(func() { _ = unix.Close(int(fd)) }) | |
| 167 | + | return int(fd) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // newSyncobjFD returns an exported DRM syncobj for use as a timeline and a | |
| 171 | + | // function that signals one of its points; the test is skipped when there is | |
| 172 | + | // no render node. | |
| 173 | + | func newSyncobjFD(t *testing.T) (fd int, signal func(point uint64)) { | |
| 174 | + | t.Helper() | |
| 175 | + | dev, err := unix.Open("/dev/dri/renderD128", unix.O_RDWR|unix.O_CLOEXEC, 0) | |
| 176 | + | if err != nil { | |
| 177 | + | t.Skipf("no render node: %v", err) | |
| 178 | + | } | |
| 179 | + | t.Cleanup(func() { _ = unix.Close(dev) }) | |
| 180 | + | create := struct{ handle, flags uint32 }{} // struct drm_syncobj_create | |
| 181 | + | if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0xC00864BF, uintptr(unsafe.Pointer(&create))); errno != 0 { | |
| 182 | + | t.Skipf("SYNCOBJ_CREATE: %v", errno) | |
| 183 | + | } | |
| 184 | + | exp := struct { // struct drm_syncobj_handle, 24 bytes | |
| 185 | + | handle, flags uint32 | |
| 186 | + | fd, pad int32 | |
| 187 | + | point uint64 | |
| 188 | + | }{handle: create.handle, fd: -1} | |
| 189 | + | if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0xC01864C1, uintptr(unsafe.Pointer(&exp))); errno != 0 { | |
| 190 | + | t.Skipf("SYNCOBJ_HANDLE_TO_FD: %v", errno) | |
| 191 | + | } | |
| 192 | + | t.Cleanup(func() { _ = unix.Close(int(exp.fd)) }) | |
| 193 | + | signal = func(point uint64) { | |
| 194 | + | t.Helper() | |
| 195 | + | // The kernel reads these through addresses stored in arg. Nothing | |
| 196 | + | // between taking them and the syscall can move the stack, and | |
| 197 | + | // KeepAlive holds them until it returns. | |
| 198 | + | handles, points := [1]uint32{create.handle}, [1]uint64{point} | |
| 199 | + | arg := struct { // struct drm_syncobj_timeline_array, 24 bytes | |
| 200 | + | handles, points uint64 | |
| 201 | + | count, flags uint32 | |
| 202 | + | }{uint64(uintptr(unsafe.Pointer(&handles))), uint64(uintptr(unsafe.Pointer(&points))), 1, 0} | |
| 203 | + | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0xC01864CD, uintptr(unsafe.Pointer(&arg))) | |
| 204 | + | runtime.KeepAlive(&handles) | |
| 205 | + | runtime.KeepAlive(&points) | |
| 206 | + | if errno != 0 { | |
| 207 | + | t.Fatalf("SYNCOBJ_TIMELINE_SIGNAL: %v", errno) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return int(exp.fd), signal | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // TestHeadlessReconfigureWhilePending drops every import of a mapped surface | |
| 214 | + | // while its frame is pending, imports again and presents again: the compositor | |
| 215 | + | // must raise no protocol error (the connection stays usable) and the frame | |
| 216 | + | // still arrives. The first commit's objects are destroyed before its acquire | |
| 217 | + | // point is signalled: a compositor that waits for acquire points still holds | |
| 218 | + | // that commit unapplied at that moment. Headless NeferWL applies commits | |
| 219 | + | // without waiting, so there it only shows the requests are accepted. | |
| 220 | + | func TestHeadlessReconfigureWhilePending(t *testing.T) { | |
| 221 | + | c := connectHeadless(t) | |
| 222 | + | const w, h = 200, 50 | |
| 223 | + | s, err := c.NewLayerSurface(neferclient.LayerConfig{ | |
| 224 | + | Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: w, Height: h, | |
| 225 | + | }) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | var configured, feedback bool | |
| 228 | + | var frames int | |
| 229 | + | hd := neferclientmocks.NewMockHandler(t) | |
| 230 | + | hd.EXPECT().Configure(s.ID(), mock.Anything, mock.Anything).Run(func(neferclient.SurfaceID, int32, int32) { configured = true }).Return().Maybe() | |
| 231 | + | hd.EXPECT().FeedbackDone(s.ID()).Run(func(neferclient.SurfaceID) { feedback = true }).Return().Maybe() | |
| 232 | + | hd.EXPECT().Scale(s.ID(), mock.Anything).Return().Maybe() | |
| 233 | + | hd.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |
| 234 | + | dispatchUntil(t, c, hd, func() bool { return configured && feedback }) | |
| 235 | + | pw, ph, err := s.PhysicalSize() | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | ||
| 238 | + | dmabuf := newUdmabuf(t, int(pw), int(ph)) | |
| 239 | + | // Separate acquire and release syncobjs keep their points independent. | |
| 240 | + | acquire, signal := newSyncobjFD(t) | |
| 241 | + | release, _ := newSyncobjFD(t) | |
| 242 | + | imports := func() { | |
| 243 | + | t.Helper() | |
| 244 | + | require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: pw, Height: ph, FourCC: 0x34325258, PlaneCount: 1, | |
| 245 | + | Planes: [4]neferclient.Plane{{FD: dmabuf, Stride: uint32(pw) * 4}}})) | |
| 246 | + | require.NoError(t, s.ImportTimeline(1, acquire)) | |
| 247 | + | require.NoError(t, s.ImportTimeline(2, release)) | |
| 248 | + | } | |
| 249 | + | present := func(point uint64) { | |
| 250 | + | t.Helper() | |
| 251 | + | require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, | |
| 252 | + | AcquirePoint: point, ReleasePoint: point, Opaque: true})) | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | imports() | |
| 256 | + | present(1) // acquire point 1 is not signalled yet | |
| 257 | + | // No Dispatch ran since the commit: its frame is pending for the library. | |
| 258 | + | require.ErrorContains(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2}), "frame callback pending") | |
| 259 | + | require.NoError(t, s.DestroyImports()) | |
| 260 | + | require.NoError(t, c.Roundtrip()) // a protocol error would fail here | |
| 261 | + | signal(1) // lets a waiting compositor apply the commit | |
| 262 | + | dispatchUntil(t, c, hd, func() bool { return frames == 1 }) | |
| 263 | + | ||
| 264 | + | imports() | |
| 265 | + | signal(2) | |
| 266 | + | present(2) | |
| 267 | + | dispatchUntil(t, c, hd, func() bool { return frames == 2 }) | |
| 268 | + | require.NoError(t, s.DestroyImports()) | |
| 269 | + | require.NoError(t, s.Close()) | |
| 270 | + | require.NoError(t, c.Roundtrip()) | |
| 271 | + | require.NoError(t, c.Dispatch(hd)) | |
| 272 | + | } | |
| @@ -2,11 +2,14 @@ package neferclient_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "runtime" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | "time" | 7 | "time" | ||
| 8 | + | "unsafe" | |||
| 7 | 9 | ||||
| 8 | "github.com/stretchr/testify/mock" | 10 | "github.com/stretchr/testify/mock" | ||
| 9 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | + | "golang.org/x/sys/unix" | |||
| 10 | 13 | ||||
| 11 | "github.com/bnema/neferclient" | 14 | "github.com/bnema/neferclient" | ||
| 12 | neferclientmocks "github.com/bnema/neferclient/mocks" | 15 | neferclientmocks "github.com/bnema/neferclient/mocks" | ||
| @@ -135,3 +138,135 @@ func TestHeadlessSeat(t *testing.T) { | |||||
| 135 | require.NoError(t, seat.SetCursor(neferclient.CursorPointer)) | 138 | require.NoError(t, seat.SetCursor(neferclient.CursorPointer)) | ||
| 136 | require.NoError(t, s.Close()) | 139 | require.NoError(t, s.Close()) | ||
| 137 | } | 140 | } | ||
| 141 | + | ||||
| 142 | + | // newUdmabuf returns a linear DMA-BUF made from a udmabuf; the test is skipped | |||
| 143 | + | // when the machine lacks /dev/udmabuf. | |||
| 144 | + | func newUdmabuf(t *testing.T, w, h int) int { | |||
| 145 | + | t.Helper() | |||
| 146 | + | size := (w*h*4 + 4095) &^ 4095 | |||
| 147 | + | mem, err := unix.MemfdCreate("udmabuf", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) | |||
| 148 | + | require.NoError(t, err) | |||
| 149 | + | t.Cleanup(func() { _ = unix.Close(mem) }) | |||
| 150 | + | require.NoError(t, unix.Ftruncate(mem, int64(size))) | |||
| 151 | + | _, err = unix.FcntlInt(uintptr(mem), unix.F_ADD_SEALS, unix.F_SEAL_SHRINK) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | dev, err := unix.Open("/dev/udmabuf", unix.O_RDWR|unix.O_CLOEXEC, 0) | |||
| 154 | + | if err != nil { | |||
| 155 | + | t.Skipf("no udmabuf: %v", err) | |||
| 156 | + | } | |||
| 157 | + | defer unix.Close(dev) | |||
| 158 | + | req := struct { | |||
| 159 | + | memfd, flags uint32 | |||
| 160 | + | offset, size uint64 | |||
| 161 | + | }{memfd: uint32(mem), size: uint64(size)} | |||
| 162 | + | fd, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0x40187542, uintptr(unsafe.Pointer(&req))) | |||
| 163 | + | if errno != 0 { | |||
| 164 | + | t.Skipf("UDMABUF_CREATE: %v", errno) | |||
| 165 | + | } | |||
| 166 | + | t.Cleanup(func() { _ = unix.Close(int(fd)) }) | |||
| 167 | + | return int(fd) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // newSyncobjFD returns an exported DRM syncobj for use as a timeline and a | |||
| 171 | + | // function that signals one of its points; the test is skipped when there is | |||
| 172 | + | // no render node. | |||
| 173 | + | func newSyncobjFD(t *testing.T) (fd int, signal func(point uint64)) { | |||
| 174 | + | t.Helper() | |||
| 175 | + | dev, err := unix.Open("/dev/dri/renderD128", unix.O_RDWR|unix.O_CLOEXEC, 0) | |||
| 176 | + | if err != nil { | |||
| 177 | + | t.Skipf("no render node: %v", err) | |||
| 178 | + | } | |||
| 179 | + | t.Cleanup(func() { _ = unix.Close(dev) }) | |||
| 180 | + | create := struct{ handle, flags uint32 }{} // struct drm_syncobj_create | |||
| 181 | + | if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0xC00864BF, uintptr(unsafe.Pointer(&create))); errno != 0 { | |||
| 182 | + | t.Skipf("SYNCOBJ_CREATE: %v", errno) | |||
| 183 | + | } | |||
| 184 | + | exp := struct { // struct drm_syncobj_handle, 24 bytes | |||
| 185 | + | handle, flags uint32 | |||
| 186 | + | fd, pad int32 | |||
| 187 | + | point uint64 | |||
| 188 | + | }{handle: create.handle, fd: -1} | |||
| 189 | + | if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0xC01864C1, uintptr(unsafe.Pointer(&exp))); errno != 0 { | |||
| 190 | + | t.Skipf("SYNCOBJ_HANDLE_TO_FD: %v", errno) | |||
| 191 | + | } | |||
| 192 | + | t.Cleanup(func() { _ = unix.Close(int(exp.fd)) }) | |||
| 193 | + | signal = func(point uint64) { | |||
| 194 | + | t.Helper() | |||
| 195 | + | // The kernel reads these through addresses stored in arg. Nothing | |||
| 196 | + | // between taking them and the syscall can move the stack, and | |||
| 197 | + | // KeepAlive holds them until it returns. | |||
| 198 | + | handles, points := [1]uint32{create.handle}, [1]uint64{point} | |||
| 199 | + | arg := struct { // struct drm_syncobj_timeline_array, 24 bytes | |||
| 200 | + | handles, points uint64 | |||
| 201 | + | count, flags uint32 | |||
| 202 | + | }{uint64(uintptr(unsafe.Pointer(&handles))), uint64(uintptr(unsafe.Pointer(&points))), 1, 0} | |||
| 203 | + | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(dev), 0xC01864CD, uintptr(unsafe.Pointer(&arg))) | |||
| 204 | + | runtime.KeepAlive(&handles) | |||
| 205 | + | runtime.KeepAlive(&points) | |||
| 206 | + | if errno != 0 { | |||
| 207 | + | t.Fatalf("SYNCOBJ_TIMELINE_SIGNAL: %v", errno) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | return int(exp.fd), signal | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // TestHeadlessReconfigureWhilePending drops every import of a mapped surface | |||
| 214 | + | // while its frame is pending, imports again and presents again: the compositor | |||
| 215 | + | // must raise no protocol error (the connection stays usable) and the frame | |||
| 216 | + | // still arrives. The first commit's objects are destroyed before its acquire | |||
| 217 | + | // point is signalled: a compositor that waits for acquire points still holds | |||
| 218 | + | // that commit unapplied at that moment. Headless NeferWL applies commits | |||
| 219 | + | // without waiting, so there it only shows the requests are accepted. | |||
| 220 | + | func TestHeadlessReconfigureWhilePending(t *testing.T) { | |||
| 221 | + | c := connectHeadless(t) | |||
| 222 | + | const w, h = 200, 50 | |||
| 223 | + | s, err := c.NewLayerSurface(neferclient.LayerConfig{ | |||
| 224 | + | Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: w, Height: h, | |||
| 225 | + | }) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | var configured, feedback bool | |||
| 228 | + | var frames int | |||
| 229 | + | hd := neferclientmocks.NewMockHandler(t) | |||
| 230 | + | hd.EXPECT().Configure(s.ID(), mock.Anything, mock.Anything).Run(func(neferclient.SurfaceID, int32, int32) { configured = true }).Return().Maybe() | |||
| 231 | + | hd.EXPECT().FeedbackDone(s.ID()).Run(func(neferclient.SurfaceID) { feedback = true }).Return().Maybe() | |||
| 232 | + | hd.EXPECT().Scale(s.ID(), mock.Anything).Return().Maybe() | |||
| 233 | + | hd.EXPECT().Frame(s.ID()).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe() | |||
| 234 | + | dispatchUntil(t, c, hd, func() bool { return configured && feedback }) | |||
| 235 | + | pw, ph, err := s.PhysicalSize() | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | ||||
| 238 | + | dmabuf := newUdmabuf(t, int(pw), int(ph)) | |||
| 239 | + | // Separate acquire and release syncobjs keep their points independent. | |||
| 240 | + | acquire, signal := newSyncobjFD(t) | |||
| 241 | + | release, _ := newSyncobjFD(t) | |||
| 242 | + | imports := func() { | |||
| 243 | + | t.Helper() | |||
| 244 | + | require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: pw, Height: ph, FourCC: 0x34325258, PlaneCount: 1, | |||
| 245 | + | Planes: [4]neferclient.Plane{{FD: dmabuf, Stride: uint32(pw) * 4}}})) | |||
| 246 | + | require.NoError(t, s.ImportTimeline(1, acquire)) | |||
| 247 | + | require.NoError(t, s.ImportTimeline(2, release)) | |||
| 248 | + | } | |||
| 249 | + | present := func(point uint64) { | |||
| 250 | + | t.Helper() | |||
| 251 | + | require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, | |||
| 252 | + | AcquirePoint: point, ReleasePoint: point, Opaque: true})) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | imports() | |||
| 256 | + | present(1) // acquire point 1 is not signalled yet | |||
| 257 | + | // No Dispatch ran since the commit: its frame is pending for the library. | |||
| 258 | + | require.ErrorContains(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2}), "frame callback pending") | |||
| 259 | + | require.NoError(t, s.DestroyImports()) | |||
| 260 | + | require.NoError(t, c.Roundtrip()) // a protocol error would fail here | |||
| 261 | + | signal(1) // lets a waiting compositor apply the commit | |||
| 262 | + | dispatchUntil(t, c, hd, func() bool { return frames == 1 }) | |||
| 263 | + | ||||
| 264 | + | imports() | |||
| 265 | + | signal(2) | |||
| 266 | + | present(2) | |||
| 267 | + | dispatchUntil(t, c, hd, func() bool { return frames == 2 }) | |||
| 268 | + | require.NoError(t, s.DestroyImports()) | |||
| 269 | + | require.NoError(t, s.Close()) | |||
| 270 | + | require.NoError(t, c.Roundtrip()) | |||
| 271 | + | require.NoError(t, c.Dispatch(hd)) | |||
| 272 | + | } | |||
M present.go +38 -0
| @@ -140,6 +140,44 @@ func (s *Surface) DestroyTimeline(id uint64) error { | |||
| 140 | 140 | return nil | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | // DestroyImports destroys every imported buffer and timeline of the surface, | |
| 144 | + | // so a renderer built from older dmabuf feedback can be replaced by one that | |
| 145 | + | // imports its buffers again under any ids (the old ids are free afterwards). | |
| 146 | + | // It is safe while a frame is pending and when nothing is imported. | |
| 147 | + | // | |
| 148 | + | // Present refuses the old ids from now on, so nothing is sent on a destroyed | |
| 149 | + | // object. A frame callback already requested still fires and is delivered as | |
| 150 | + | // usual; the next Present waits for it as always. The compositor keeps the | |
| 151 | + | // content and the DMA-BUF references of a commit it already received: destroying | |
| 152 | + | // a wl_buffer or a timeline never withdraws a commit, and the descriptors were | |
| 153 | + | // duplicated at import, so none is closed twice. The caller may drop its own | |
| 154 | + | // references to the storage (close the renderer) at once, but must not write | |
| 155 | + | // to or recycle it before the release points of the commits that used it | |
| 156 | + | // signal (the explicit-sync rule). | |
| 157 | + | // | |
| 158 | + | // Every object is destroyed exactly once, even if a request fails; the errors | |
| 159 | + | // are joined. It returns [ErrClosed] after Surface.Close or Conn.Close. See | |
| 160 | + | // [Feedback.Equal] and the package documentation. | |
| 161 | + | func (s *Surface) DestroyImports() error { | |
| 162 | + | if s.closed || s.c.closed { | |
| 163 | + | return ErrClosed | |
| 164 | + | } | |
| 165 | + | var errs []error | |
| 166 | + | for id, b := range s.buffers { | |
| 167 | + | delete(s.buffers, id) | |
| 168 | + | if err := b.buf.Destroy(); err != nil { | |
| 169 | + | errs = append(errs, fmt.Errorf("neferclient: destroy buffer %d: %w", id, err)) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | for id, t := range s.timelines { | |
| 173 | + | delete(s.timelines, id) | |
| 174 | + | if err := t.Destroy(); err != nil { | |
| 175 | + | errs = append(errs, fmt.Errorf("neferclient: destroy timeline %d: %w", id, err)) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return errors.Join(errs...) | |
| 179 | + | } | |
| 180 | + | ||
| 143 | 181 | // Present commits an imported buffer. It refuses before the first configure, | |
| 144 | 182 | // before the first complete dmabuf feedback and while the previous frame | |
| 145 | 183 | // callback is pending. | |
| @@ -140,6 +140,44 @@ func (s *Surface) DestroyTimeline(id uint64) error { | |||||
| 140 | return nil | 140 | return nil | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | + | // DestroyImports destroys every imported buffer and timeline of the surface, | |||
| 144 | + | // so a renderer built from older dmabuf feedback can be replaced by one that | |||
| 145 | + | // imports its buffers again under any ids (the old ids are free afterwards). | |||
| 146 | + | // It is safe while a frame is pending and when nothing is imported. | |||
| 147 | + | // | |||
| 148 | + | // Present refuses the old ids from now on, so nothing is sent on a destroyed | |||
| 149 | + | // object. A frame callback already requested still fires and is delivered as | |||
| 150 | + | // usual; the next Present waits for it as always. The compositor keeps the | |||
| 151 | + | // content and the DMA-BUF references of a commit it already received: destroying | |||
| 152 | + | // a wl_buffer or a timeline never withdraws a commit, and the descriptors were | |||
| 153 | + | // duplicated at import, so none is closed twice. The caller may drop its own | |||
| 154 | + | // references to the storage (close the renderer) at once, but must not write | |||
| 155 | + | // to or recycle it before the release points of the commits that used it | |||
| 156 | + | // signal (the explicit-sync rule). | |||
| 157 | + | // | |||
| 158 | + | // Every object is destroyed exactly once, even if a request fails; the errors | |||
| 159 | + | // are joined. It returns [ErrClosed] after Surface.Close or Conn.Close. See | |||
| 160 | + | // [Feedback.Equal] and the package documentation. | |||
| 161 | + | func (s *Surface) DestroyImports() error { | |||
| 162 | + | if s.closed || s.c.closed { | |||
| 163 | + | return ErrClosed | |||
| 164 | + | } | |||
| 165 | + | var errs []error | |||
| 166 | + | for id, b := range s.buffers { | |||
| 167 | + | delete(s.buffers, id) | |||
| 168 | + | if err := b.buf.Destroy(); err != nil { | |||
| 169 | + | errs = append(errs, fmt.Errorf("neferclient: destroy buffer %d: %w", id, err)) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | for id, t := range s.timelines { | |||
| 173 | + | delete(s.timelines, id) | |||
| 174 | + | if err := t.Destroy(); err != nil { | |||
| 175 | + | errs = append(errs, fmt.Errorf("neferclient: destroy timeline %d: %w", id, err)) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | return errors.Join(errs...) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 143 | // Present commits an imported buffer. It refuses before the first configure, | 181 | // Present commits an imported buffer. It refuses before the first configure, | ||
| 144 | // before the first complete dmabuf feedback and while the previous frame | 182 | // before the first complete dmabuf feedback and while the previous frame | ||
| 145 | // callback is pending. | 183 | // callback is pending. | ||
M surface.go +2 -1
| @@ -168,7 +168,8 @@ func (s *Surface) Size() (w, h int32, scale float64) { return s.width, s.height, | |||
| 168 | 168 | ||
| 169 | 169 | // Feedback returns the latest complete dmabuf feedback, or nil before the | |
| 170 | 170 | // first Handler.FeedbackDone. The value and its slice stay valid until the | |
| 171 | - | // next FeedbackDone. | |
| 171 | + | // next FeedbackDone; use [Feedback.Clone] to keep it, and [Feedback.Equal] to | |
| 172 | + | // tell a changed preference from a repeated one. | |
| 172 | 173 | func (s *Surface) Feedback() *Feedback { | |
| 173 | 174 | if !s.fbDone { | |
| 174 | 175 | return nil | |
| @@ -168,7 +168,8 @@ func (s *Surface) Size() (w, h int32, scale float64) { return s.width, s.height, | |||||
| 168 | 168 | ||||
| 169 | // Feedback returns the latest complete dmabuf feedback, or nil before the | 169 | // Feedback returns the latest complete dmabuf feedback, or nil before the | ||
| 170 | // first Handler.FeedbackDone. The value and its slice stay valid until the | 170 | // first Handler.FeedbackDone. The value and its slice stay valid until the | ||
| 171 | - | // next FeedbackDone. | 171 | + | // next FeedbackDone; use [Feedback.Clone] to keep it, and [Feedback.Equal] to |
| 172 | + | // tell a changed preference from a repeated one. | |||
| 172 | func (s *Surface) Feedback() *Feedback { | 173 | func (s *Surface) Feedback() *Feedback { | ||
| 173 | if !s.fbDone { | 174 | if !s.fbDone { | ||
| 174 | return nil | 175 | return nil | ||
M surface_types.go +25 -0
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | package neferclient | |
| 2 | 2 | ||
| 3 | + | import "slices" | |
| 4 | + | ||
| 3 | 5 | // SurfaceID identifies a [Surface] in Handler callbacks. IDs are never | |
| 4 | 6 | // reused within a connection. | |
| 5 | 7 | type SurfaceID uint32 | |
| @@ -16,6 +18,29 @@ type Feedback struct { | |||
| 16 | 18 | Formats []Format // every tranche's formats, flattened in tranche order | |
| 17 | 19 | } | |
| 18 | 20 | ||
| 21 | + | // Equal reports whether f and o have the same main device and the same formats | |
| 22 | + | // in the same order. Feedback flattens tranches, so a change limited to a | |
| 23 | + | // tranche's target device or scanout flag is not visible here. A nil Feedback | |
| 24 | + | // equals only nil. The compositor sends a complete round again whenever it | |
| 25 | + | // re-evaluates its preference, often unchanged, so compare against the | |
| 26 | + | // feedback a renderer was built from before rebuilding it. | |
| 27 | + | func (f *Feedback) Equal(o *Feedback) bool { | |
| 28 | + | if f == nil || o == nil { | |
| 29 | + | return f == o | |
| 30 | + | } | |
| 31 | + | return f.MainDevice == o.MainDevice && slices.Equal(f.Formats, o.Formats) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // Clone returns a deep copy that stays valid after later rounds. The value from | |
| 35 | + | // [Surface.Feedback] shares memory the library reuses, so keep a Clone of the | |
| 36 | + | // feedback a renderer was built from. A nil Feedback clones to nil. | |
| 37 | + | func (f *Feedback) Clone() *Feedback { | |
| 38 | + | if f == nil { | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | return &Feedback{MainDevice: f.MainDevice, Formats: slices.Clone(f.Formats)} | |
| 42 | + | } | |
| 43 | + | ||
| 19 | 44 | // Rect is an integer rectangle: surface-local logical pixels for input | |
| 20 | 45 | // regions, buffer pixels for damage. | |
| 21 | 46 | type Rect struct{ X, Y, Width, Height int32 } | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | package neferclient | 1 | package neferclient | ||
| 2 | 2 | ||||
| 3 | + | import "slices" | |||
| 4 | + | ||||
| 3 | // SurfaceID identifies a [Surface] in Handler callbacks. IDs are never | 5 | // SurfaceID identifies a [Surface] in Handler callbacks. IDs are never | ||
| 4 | // reused within a connection. | 6 | // reused within a connection. | ||
| 5 | type SurfaceID uint32 | 7 | type SurfaceID uint32 | ||
| @@ -16,6 +18,29 @@ type Feedback struct { | |||||
| 16 | Formats []Format // every tranche's formats, flattened in tranche order | 18 | Formats []Format // every tranche's formats, flattened in tranche order | ||
| 17 | } | 19 | } | ||
| 18 | 20 | ||||
| 21 | + | // Equal reports whether f and o have the same main device and the same formats | |||
| 22 | + | // in the same order. Feedback flattens tranches, so a change limited to a | |||
| 23 | + | // tranche's target device or scanout flag is not visible here. A nil Feedback | |||
| 24 | + | // equals only nil. The compositor sends a complete round again whenever it | |||
| 25 | + | // re-evaluates its preference, often unchanged, so compare against the | |||
| 26 | + | // feedback a renderer was built from before rebuilding it. | |||
| 27 | + | func (f *Feedback) Equal(o *Feedback) bool { | |||
| 28 | + | if f == nil || o == nil { | |||
| 29 | + | return f == o | |||
| 30 | + | } | |||
| 31 | + | return f.MainDevice == o.MainDevice && slices.Equal(f.Formats, o.Formats) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // Clone returns a deep copy that stays valid after later rounds. The value from | |||
| 35 | + | // [Surface.Feedback] shares memory the library reuses, so keep a Clone of the | |||
| 36 | + | // feedback a renderer was built from. A nil Feedback clones to nil. | |||
| 37 | + | func (f *Feedback) Clone() *Feedback { | |||
| 38 | + | if f == nil { | |||
| 39 | + | return nil | |||
| 40 | + | } | |||
| 41 | + | return &Feedback{MainDevice: f.MainDevice, Formats: slices.Clone(f.Formats)} | |||
| 42 | + | } | |||
| 43 | + | ||||
| 19 | // Rect is an integer rectangle: surface-local logical pixels for input | 44 | // Rect is an integer rectangle: surface-local logical pixels for input | ||
| 20 | // regions, buffer pixels for damage. | 45 | // regions, buffer pixels for damage. | ||
| 21 | type Rect struct{ X, Y, Width, Height int32 } | 46 | type Rect struct{ X, Y, Width, Height int32 } | ||