1package neferclient_test
2
3import (
4 "encoding/binary"
5 "os"
6 "sync/atomic"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10 "golang.org/x/sys/unix"
11
12 "github.com/bnema/neferclient"
13)
14
15var surfaceGlobals = []wireGlobal{{1, "wl_compositor", 6}, {2, "zwp_linux_dmabuf_v1", 4},
16 {3, "wp_linux_drm_syncobj_manager_v1", 1}, {4, "wp_viewporter", 1}, {5, "xdg_wm_base", 6},
17 {6, "ext_session_lock_manager_v1", 1}, {7, outputIface, 4}}
18
19var surfaceOutputs = map[uint32]outputSpec{7: {"DP-1", 1, 640, 480}}
20
21func openFDs(t *testing.T) int {
22 t.Helper()
23 ents, err := os.ReadDir("/proc/self/fd")
24 require.NoError(t, err)
25 return len(ents)
26}
27
28// GO-009 and GO-012: failure paths close each duplicated descriptor exactly
29// once, and descriptor 0 is never touched.
30func TestImportErrorPathsCloseDupsOnce(t *testing.T) {
31 c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
32 s, err := c.NewToplevel("fd", 64, 48)
33 require.NoError(t, err)
34 memfd, err := unix.MemfdCreate("fd", unix.MFD_CLOEXEC)
35 require.NoError(t, err)
36 defer unix.Close(memfd)
37 _, err = unix.FcntlInt(0, unix.F_GETFD, 0)
38 require.NoError(t, err, "stdin is open for the test")
39
40 before := openFDs(t)
41 // Plane 0 duplicates, plane 1 cannot: the first duplicate must be closed.
42 err = s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 2,
43 Planes: [4]neferclient.Plane{{FD: memfd}, {FD: 9999}}})
44 require.Error(t, err)
45 require.Equal(t, before, openFDs(t), "no descriptor leaked")
46 _, err = unix.FcntlInt(0, unix.F_GETFD, 0)
47 require.NoError(t, err, "descriptor 0 untouched")
48 require.Error(t, s.ImportTimeline(1, 9999))
49 require.Equal(t, before, openFDs(t))
50
51 // Success paths: the request consumes the duplicates.
52 require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
53 Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}}))
54 require.NoError(t, s.ImportTimeline(1, memfd))
55 require.Equal(t, before, openFDs(t), "sent descriptors are closed, the caller's stays open")
56 require.Error(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
57 Planes: [4]neferclient.Plane{{FD: memfd}}}), "duplicate id")
58 require.Equal(t, before, openFDs(t))
59}
60
61// DestroyTimeline sends wp_linux_drm_syncobj_timeline_v1.destroy (opcode 0) for
62// the imported timeline, refuses an unknown id and allows importing it again.
63func TestDestroyTimeline(t *testing.T) {
64 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
65 s, err := c.NewToplevel("tl", 64, 48)
66 require.NoError(t, err)
67 memfd, err := unix.MemfdCreate("tl", unix.MFD_CLOEXEC)
68 require.NoError(t, err)
69 defer unix.Close(memfd)
70 require.NoError(t, s.ImportTimeline(7, memfd))
71 require.NoError(t, c.Roundtrip())
72
73 var destroys atomic.Int32
74 srv.mu.Lock()
75 srv.hook = func(_ uint32, op uint16, _ []byte) {
76 if op == 0 {
77 destroys.Add(1)
78 }
79 }
80 srv.mu.Unlock()
81 require.NoError(t, s.DestroyTimeline(7))
82 require.NoError(t, c.Roundtrip())
83 require.Equal(t, int32(1), destroys.Load(), "one destroy request")
84 require.ErrorContains(t, s.DestroyTimeline(7), "unknown timeline 7")
85 require.ErrorContains(t, s.DestroyTimeline(99), "unknown timeline 99")
86 require.NoError(t, s.ImportTimeline(7, memfd), "the id is free again")
87}
88
89// requestLog counts the commits the wire peer sees.
90type requestLog struct{ commits atomic.Int32 }
91
92// GO-003: before the first Present the region is staged without a commit; a
93// lock surface refuses it.
94func TestSetInputRegionCommitsOnlyOnceMapped(t *testing.T) {
95 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
96 s, err := c.NewToplevel("input", 64, 48)
97 require.NoError(t, err)
98 require.NoError(t, c.Roundtrip()) // the peer consumed the initial commit
99 var log requestLog
100 surfaceID := s.SurfaceObjectID()
101 srv.mu.Lock()
102 srv.hook = func(obj uint32, op uint16, _ []byte) {
103 if obj == surfaceID && op == 6 {
104 log.commits.Add(1)
105 }
106 }
107 srv.mu.Unlock()
108 s.MarkPresentable(64, 48)
109
110 require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 0, Y: 0, Width: 4, Height: 4}}))
111 require.NoError(t, c.Roundtrip())
112 require.Zero(t, log.commits.Load(), "unmapped surface: staged, not committed")
113
114 s.MarkPresented()
115 require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 1, Y: 1, Width: 4, Height: 4}}))
116 require.NoError(t, c.Roundtrip())
117 require.Equal(t, int32(1), log.commits.Load(), "mapped surface: committed")
118 require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 1, Y: 1, Width: 4, Height: 4}}))
119 require.NoError(t, c.Roundtrip())
120 require.Equal(t, int32(1), log.commits.Load(), "unchanged region sends nothing")
121
122 l, err := c.Lock()
123 require.NoError(t, err)
124 ls, err := l.NewSurface(7)
125 require.NoError(t, err)
126 require.Error(t, ls.SetInputRegion(nil), "lock surfaces take all input")
127}
128
129// WLSurface is the surface's own wl_surface, on the connection's context, so
130// an extension bound with Conn.Bind can pass it as a request argument.
131func TestWLSurfaceIsTheSurfaceProxy(t *testing.T) {
132 c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
133 s, err := c.NewToplevel("wl", 64, 48)
134 require.NoError(t, err)
135 p := s.WLSurface()
136 require.NotNil(t, p)
137 require.Equal(t, s.SurfaceObjectID(), p.ID())
138 require.Same(t, c.Context(), p.Context())
139
140 s.Close()
141 require.Nil(t, s.WLSurface(), "a destroyed proxy's id can be reused")
142}
143
144// GO-007: one lock surface per output.
145func TestLockRefusesSecondSurfacePerOutput(t *testing.T) {
146 c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
147 l, err := c.Lock()
148 require.NoError(t, err)
149 first, err := l.NewSurface(7)
150 require.NoError(t, err)
151 _, err = l.NewSurface(7)
152 require.ErrorContains(t, err, "already has a lock surface")
153 require.NoError(t, first.Close())
154 again, err := l.NewSurface(7)
155 require.NoError(t, err, "after closing the first one the output is free again")
156 require.NoError(t, again.Close())
157}
158
159// watchLock records the ext_session_lock_v1 id the peer saw requested and
160// counts its destroy requests.
161func watchLock(t *testing.T, srv *wireServer) (id func() uint32, destroys *atomic.Int32) {
162 t.Helper()
163 var lockID atomic.Uint32
164 destroys = new(atomic.Int32)
165 srv.mu.Lock()
166 srv.hook = func(obj uint32, op uint16, body []byte) {
167 srv.mu.Lock()
168 manager := srv.bound[6].id // the lock manager is bound by the first Lock call
169 srv.mu.Unlock()
170 switch {
171 case obj == manager && op == 1 && len(body) == 4: // lock(new_id)
172 lockID.Store(binary.LittleEndian.Uint32(body))
173 case obj == lockID.Load() && obj != 0 && op == 0: // destroy
174 destroys.Add(1)
175 }
176 }
177 srv.mu.Unlock()
178 return lockID.Load, destroys
179}
180
181// GO-006: a locked event already on the wire when Close runs wins: destroying
182// a locked lock is a protocol error, so Close reports it instead.
183func TestLockCloseSeesLockedInFlight(t *testing.T) {
184 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
185 lockID, destroys := watchLock(t, srv)
186
187 l, err := c.Lock()
188 require.NoError(t, err)
189 require.NoError(t, c.Roundtrip()) // the peer saw the lock request
190 srv.write(frame(lockID(), 0, nil)) // ext_session_lock_v1.locked, not yet dispatched
191
192 require.ErrorContains(t, l.Close(), "use Unlock")
193 require.Zero(t, destroys.Load(), "no destroy request was sent")
194 require.True(t, l.Locked(), "treated as locked")
195 require.NoError(t, c.Roundtrip())
196 require.Zero(t, destroys.Load())
197}
198
199func TestLockCloseDestroysUnlockedLock(t *testing.T) {
200 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
201 _, destroys := watchLock(t, srv)
202 l, err := c.Lock()
203 require.NoError(t, err)
204 require.NoError(t, l.Close())
205 require.NoError(t, c.Roundtrip())
206 require.Equal(t, int32(1), destroys.Load())
207 require.NoError(t, l.Close(), "idempotent")
208}
209
210// Closing a surface with a pending frame callback must not turn the
211// compositor's later callback done and delete_id into a transport error.
212func TestCloseWithPendingFrame(t *testing.T) {
213 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
214 s, err := c.NewToplevel("close", 64, 48)
215 require.NoError(t, err)
216 s.MarkPresentable(64, 48)
217 surfaceID := s.SurfaceObjectID()
218 var callback atomic.Uint32
219 srv.mu.Lock()
220 srv.hook = func(obj uint32, op uint16, body []byte) {
221 if obj == surfaceID && op == 3 && len(body) == 4 {
222 callback.Store(binary.LittleEndian.Uint32(body))
223 }
224 }
225 srv.mu.Unlock()
226 memfd, err := unix.MemfdCreate("close", unix.MFD_CLOEXEC)
227 require.NoError(t, err)
228 defer unix.Close(memfd)
229 require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
230 Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}}))
231 require.NoError(t, s.ImportTimeline(1, memfd))
232 require.NoError(t, s.ImportTimeline(2, memfd))
233 require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, AcquirePoint: 1, ReleasePoint: 2}))
234 require.NoError(t, c.Roundtrip())
235 require.NoError(t, s.Close())
236
237 srv.write(frame(callback.Load(), 0, appendU32(nil, 1)))
238 srv.write(frame(1, 1, appendU32(nil, callback.Load())))
239 require.NoError(t, c.Roundtrip())
240 require.NoError(t, c.Dispatch(nil))
241}