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// GO-007: one lock surface per output.
130func TestLockRefusesSecondSurfacePerOutput(t *testing.T) {
131 c, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
132 l, err := c.Lock()
133 require.NoError(t, err)
134 first, err := l.NewSurface(7)
135 require.NoError(t, err)
136 _, err = l.NewSurface(7)
137 require.ErrorContains(t, err, "already has a lock surface")
138 require.NoError(t, first.Close())
139 again, err := l.NewSurface(7)
140 require.NoError(t, err, "after closing the first one the output is free again")
141 require.NoError(t, again.Close())
142}
143
144// watchLock records the ext_session_lock_v1 id the peer saw requested and
145// counts its destroy requests.
146func watchLock(t *testing.T, srv *wireServer) (id func() uint32, destroys *atomic.Int32) {
147 t.Helper()
148 var lockID atomic.Uint32
149 destroys = new(atomic.Int32)
150 srv.mu.Lock()
151 srv.hook = func(obj uint32, op uint16, body []byte) {
152 srv.mu.Lock()
153 manager := srv.bound[6].id // the lock manager is bound by the first Lock call
154 srv.mu.Unlock()
155 switch {
156 case obj == manager && op == 1 && len(body) == 4: // lock(new_id)
157 lockID.Store(binary.LittleEndian.Uint32(body))
158 case obj == lockID.Load() && obj != 0 && op == 0: // destroy
159 destroys.Add(1)
160 }
161 }
162 srv.mu.Unlock()
163 return lockID.Load, destroys
164}
165
166// GO-006: a locked event already on the wire when Close runs wins: destroying
167// a locked lock is a protocol error, so Close reports it instead.
168func TestLockCloseSeesLockedInFlight(t *testing.T) {
169 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
170 lockID, destroys := watchLock(t, srv)
171
172 l, err := c.Lock()
173 require.NoError(t, err)
174 require.NoError(t, c.Roundtrip()) // the peer saw the lock request
175 srv.write(frame(lockID(), 0, nil)) // ext_session_lock_v1.locked, not yet dispatched
176
177 require.ErrorContains(t, l.Close(), "use Unlock")
178 require.Zero(t, destroys.Load(), "no destroy request was sent")
179 require.True(t, l.Locked(), "treated as locked")
180 require.NoError(t, c.Roundtrip())
181 require.Zero(t, destroys.Load())
182}
183
184func TestLockCloseDestroysUnlockedLock(t *testing.T) {
185 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
186 _, destroys := watchLock(t, srv)
187 l, err := c.Lock()
188 require.NoError(t, err)
189 require.NoError(t, l.Close())
190 require.NoError(t, c.Roundtrip())
191 require.Equal(t, int32(1), destroys.Load())
192 require.NoError(t, l.Close(), "idempotent")
193}
194
195// Closing a surface with a pending frame callback must not turn the
196// compositor's later callback done and delete_id into a transport error.
197func TestCloseWithPendingFrame(t *testing.T) {
198 c, srv := connectWire(t, surfaceGlobals, surfaceOutputs)
199 s, err := c.NewToplevel("close", 64, 48)
200 require.NoError(t, err)
201 s.MarkPresentable(64, 48)
202 surfaceID := s.SurfaceObjectID()
203 var callback atomic.Uint32
204 srv.mu.Lock()
205 srv.hook = func(obj uint32, op uint16, body []byte) {
206 if obj == surfaceID && op == 3 && len(body) == 4 {
207 callback.Store(binary.LittleEndian.Uint32(body))
208 }
209 }
210 srv.mu.Unlock()
211 memfd, err := unix.MemfdCreate("close", unix.MFD_CLOEXEC)
212 require.NoError(t, err)
213 defer unix.Close(memfd)
214 require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: 64, Height: 48, FourCC: 1, PlaneCount: 1,
215 Planes: [4]neferclient.Plane{{FD: memfd, Stride: 256}}}))
216 require.NoError(t, s.ImportTimeline(1, memfd))
217 require.NoError(t, s.ImportTimeline(2, memfd))
218 require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2, AcquirePoint: 1, ReleasePoint: 2}))
219 require.NoError(t, c.Roundtrip())
220 require.NoError(t, s.Close())
221
222 srv.write(frame(callback.Load(), 0, appendU32(nil, 1)))
223 srv.write(frame(1, 1, appendU32(nil, callback.Load())))
224 require.NoError(t, c.Roundtrip())
225 require.NoError(t, c.Dispatch(nil))
226}