detached e178e0b

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 UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.0
6 files · +503 -1
M doc.go +26 -0
@@ -1,4 +1,30 @@
11 // Package neferclient is a Wayland client toolkit library for Go: connection,
22 // outputs, surface roles, seat and DMA-BUF presentation. It does not render
33 // 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.
430 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 render2 // 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 neferclient30 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
22
33 import (
44 "context"
5+ "runtime"
56 "testing"
67 "time"
8+ "unsafe"
79
810 "github.com/stretchr/testify/mock"
911 "github.com/stretchr/testify/require"
12+ "golang.org/x/sys/unix"
1013
1114 "github.com/bnema/neferclient"
1215 neferclientmocks "github.com/bnema/neferclient/mocks"
@@ -135,3 +138,135 @@ func TestHeadlessSeat(t *testing.T) {
135138 require.NoError(t, seat.SetCursor(neferclient.CursorPointer))
136139 require.NoError(t, s.Close())
137140 }
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 {
140140 return nil
141141 }
142142
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+
143181 // Present commits an imported buffer. It refuses before the first configure,
144182 // before the first complete dmabuf feedback and while the previous frame
145183 // callback is pending.
@@ -140,6 +140,44 @@ func (s *Surface) DestroyTimeline(id uint64) error {
140 return nil140 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 frame182 // 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,
168168
169169 // Feedback returns the latest complete dmabuf feedback, or nil before the
170170 // 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.
172173 func (s *Surface) Feedback() *Feedback {
173174 if !s.fbDone {
174175 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 the169 // Feedback returns the latest complete dmabuf feedback, or nil before the
170 // first Handler.FeedbackDone. The value and its slice stay valid until the170 // 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 nil175 return nil
M surface_types.go +25 -0
@@ -1,5 +1,7 @@
11 package neferclient
22
3+import "slices"
4+
35 // SurfaceID identifies a [Surface] in Handler callbacks. IDs are never
46 // reused within a connection.
57 type SurfaceID uint32
@@ -16,6 +18,29 @@ type Feedback struct {
1618 Formats []Format // every tranche's formats, flattened in tranche order
1719 }
1820
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+
1944 // Rect is an integer rectangle: surface-local logical pixels for input
2045 // regions, buffer pixels for damage.
2146 type Rect struct{ X, Y, Width, Height int32 }
@@ -1,5 +1,7 @@
1 package neferclient1 package neferclient
2 2
3+import "slices"
4+
3 // SurfaceID identifies a [Surface] in Handler callbacks. IDs are never5 // SurfaceID identifies a [Surface] in Handler callbacks. IDs are never
4 // reused within a connection.6 // reused within a connection.
5 type SurfaceID uint327 type SurfaceID uint32
@@ -16,6 +18,29 @@ type Feedback struct {
16 Formats []Format // every tranche's formats, flattened in tranche order18 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 input44 // 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 }

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 1

Tags 4

↑↓ moveenter openesc close