detached 5f1ad00

headless_surface_test.go RawHistoryFolder

1package neferclient_test
2
3import (
4	"context"
5	"runtime"
6	"testing"
7	"time"
8	"unsafe"
9
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12	"golang.org/x/sys/unix"
13
14	"github.com/bnema/neferclient"
15	neferclientmocks "github.com/bnema/neferclient/mocks"
16)
17
18func connectHeadless(t *testing.T) *neferclient.Conn {
19	t.Helper()
20	socket := startHeadless(t)
21	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
22	defer cancel()
23	for {
24		c, err := neferclient.Connect(ctx, socket)
25		if err == nil {
26			t.Cleanup(func() { _ = c.Close() })
27			for len(c.Outputs()) == 0 { // the virtual output may arrive late
28				select {
29				case <-c.Wake():
30					require.NoError(t, c.Dispatch(nil))
31				case <-ctx.Done():
32					t.Fatal("no output announced")
33				}
34			}
35			return c
36		}
37		if ctx.Err() != nil {
38			t.Fatal(err)
39		}
40		time.Sleep(5 * time.Millisecond)
41	}
42}
43
44func TestHeadlessLayerSurface(t *testing.T) {
45	c := connectHeadless(t)
46	s, err := c.NewLayerSurface(neferclient.LayerConfig{
47		Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft,
48		Width: 200, Height: 50,
49	})
50	require.NoError(t, err)
51
52	var configured, feedback bool
53	var cw, ch int32
54	h := neferclientmocks.NewMockHandler(t)
55	h.EXPECT().Configure(s.ID(), mock.Anything, mock.Anything).Run(func(_ neferclient.SurfaceID, w, hh int32) {
56		configured, cw, ch = true, w, hh
57	}).Return().Maybe()
58	h.EXPECT().FeedbackDone(s.ID()).Run(func(neferclient.SurfaceID) { feedback = true }).Return().Maybe()
59	h.EXPECT().Scale(s.ID(), mock.Anything).Return().Maybe()
60	dispatchUntil(t, c, h, func() bool { return configured && feedback })
61
62	require.Equal(t, int32(200), cw)
63	require.Equal(t, int32(50), ch)
64	fb := s.Feedback()
65	require.NotNil(t, fb)
66	require.NotZero(t, fb.MainDevice)
67	require.NotEmpty(t, fb.Formats)
68	t.Logf("main device %#x, %d formats", fb.MainDevice, len(fb.Formats))
69	pw, ph, err := s.PhysicalSize()
70	require.NoError(t, err)
71	require.Positive(t, pw)
72	require.Positive(t, ph)
73	require.NoError(t, s.SetInputRegion([]neferclient.Rect{{X: 0, Y: 0, Width: 10, Height: 10}}))
74	require.NoError(t, s.Close())
75	require.NoError(t, c.Roundtrip())
76}
77
78func TestHeadlessLockUnlock(t *testing.T) {
79	c := connectHeadless(t)
80	l, err := c.Lock()
81	require.NoError(t, err)
82	require.Error(t, l.Unlock(), "unlock before locked must be refused")
83
84	var locked bool
85	var surfaces []*neferclient.Surface
86	for _, o := range c.Outputs() {
87		s, err := l.NewSurface(o.Global)
88		require.NoError(t, err)
89		surfaces = append(surfaces, s)
90	}
91	h := neferclientmocks.NewMockHandler(t)
92	h.EXPECT().Locked().Run(func() { locked = true }).Return().Maybe()
93	h.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Return().Maybe()
94	h.EXPECT().FeedbackDone(mock.Anything).Return().Maybe()
95	h.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe()
96	dispatchUntil(t, c, h, func() bool { return locked })
97	require.True(t, l.Locked())
98	for _, s := range surfaces {
99		w, hh, _ := s.Size()
100		t.Logf("lock surface %d: %dx%d", s.ID(), w, hh)
101	}
102	require.Error(t, l.Close(), "a locked session is not closed, only unlocked")
103	require.NoError(t, l.Unlock())
104	require.False(t, l.Locked())
105	for _, s := range surfaces {
106		require.NoError(t, s.Close())
107	}
108	require.NoError(t, c.Roundtrip())
109}
110
111// TestHeadlessSeat binds the seat against a real compositor and drives it
112// through a surface: the seat bind, capability handling and cursor request
113// must not fail, whatever devices the headless backend offers.
114func TestHeadlessSeat(t *testing.T) {
115	c := connectHeadless(t)
116	s, err := c.NewLayerSurface(neferclient.LayerConfig{
117		Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft,
118		Width: 100, Height: 40, Keyboard: neferclient.KeyboardOnDemand,
119	})
120	require.NoError(t, err)
121	seat := c.Seat()
122	require.NotNil(t, seat)
123	seat.SetSecret(neferclient.NewSecretBuffer(16))
124	seat.SetSecret(nil)
125
126	var configured bool
127	h := neferclientmocks.NewMockHandler(t)
128	h.EXPECT().Configure(s.ID(), mock.Anything, mock.Anything).Run(func(neferclient.SurfaceID, int32, int32) { configured = true }).Return().Maybe()
129	h.EXPECT().FeedbackDone(mock.Anything).Return().Maybe()
130	h.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe()
131	h.EXPECT().Pointer(mock.Anything).Return().Maybe()
132	h.EXPECT().Key(mock.Anything).Return().Maybe()
133	h.EXPECT().KeyboardFocus(mock.Anything, mock.Anything).Return().Maybe()
134	h.EXPECT().SecretChanged(mock.Anything).Return().Maybe()
135	dispatchUntil(t, c, h, func() bool { return configured })
136	require.NoError(t, c.Roundtrip())
137	require.NoError(t, c.Dispatch(h))
138	require.NoError(t, seat.SetCursor(neferclient.CursorPointer))
139	require.NoError(t, s.Close())
140}
141
142// newUdmabuf returns a linear DMA-BUF made from a udmabuf; the test is skipped
143// when the machine lacks /dev/udmabuf.
144func 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.
173func 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.
220func 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}
273
274// headlessLayer opens a layer surface, waits until it is configured and
275// presents one buffer: compositors only place popups of mapped parents.
276func headlessLayer(t *testing.T, c *neferclient.Conn, hd *neferclientmocks.MockHandler, configured map[neferclient.SurfaceID][2]int32, frames *int) *neferclient.Surface {
277	t.Helper()
278	s, err := c.NewLayerSurface(neferclient.LayerConfig{
279		Level: neferclient.LayerTop, Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 50,
280	})
281	require.NoError(t, err)
282	var feedback bool
283	hd.EXPECT().FeedbackDone(mock.Anything).Run(func(id neferclient.SurfaceID) { feedback = feedback || id == s.ID() }).Return().Maybe()
284	dispatchUntil(t, c, hd, func() bool { _, ok := configured[s.ID()]; return ok && feedback })
285	pw, ph, err := s.PhysicalSize()
286	require.NoError(t, err)
287	dmabuf := newUdmabuf(t, int(pw), int(ph))
288	acquire, signal := newSyncobjFD(t)
289	release, _ := newSyncobjFD(t)
290	require.NoError(t, s.ImportBuffer(1, &neferclient.Buffer{Width: pw, Height: ph, FourCC: 0x34325258, PlaneCount: 1,
291		Planes: [4]neferclient.Plane{{FD: dmabuf, Stride: uint32(pw) * 4}}}))
292	require.NoError(t, s.ImportTimeline(1, acquire))
293	require.NoError(t, s.ImportTimeline(2, release))
294	signal(1)
295	require.NoError(t, s.Present(&neferclient.Present{Buffer: 1, AcquireTimeline: 1, ReleaseTimeline: 2,
296		AcquirePoint: 1, ReleasePoint: 1, Opaque: true}))
297	dispatchUntil(t, c, hd, func() bool { return *frames == 1 })
298	return s
299}
300
301// A popup on a layer surface is configured with the requested size, can be
302// repositioned and is closed before its parent.
303func TestHeadlessPopupConfigure(t *testing.T) {
304	c := connectHeadless(t)
305	configured := map[neferclient.SurfaceID][2]int32{}
306	var frames int
307	hd := neferclientmocks.NewMockHandler(t)
308	hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) {
309		configured[id] = [2]int32{w, h}
310	}).Return().Maybe()
311	hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe()
312	hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe()
313	parent := headlessLayer(t, c, hd, configured, &frames)
314
315	pl := neferclient.PopupPlacement{
316		Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 50},
317		Edge:   neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
318		Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY, Width: 120, Height: 80,
319	}
320	p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: pl})
321	require.NoError(t, err)
322	dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok })
323	require.Equal(t, [2]int32{120, 80}, configured[p.ID()])
324
325	pl.Width, pl.Height = 90, 60
326	delete(configured, p.ID())
327	require.NoError(t, p.Reposition(pl))
328	dispatchUntil(t, c, hd, func() bool { _, ok := configured[p.ID()]; return ok })
329	require.Equal(t, [2]int32{90, 60}, configured[p.ID()])
330
331	require.Error(t, parent.Close(), "parent with an open popup")
332	require.NoError(t, p.Close())
333	require.NoError(t, parent.Close())
334	require.NoError(t, c.Roundtrip())
335	require.NoError(t, c.Dispatch(hd))
336}
337
338// A grab with a serial that is not the latest button press is dismissed.
339func TestHeadlessPopupStaleGrabIsDismissed(t *testing.T) {
340	c := connectHeadless(t)
341	configured := map[neferclient.SurfaceID][2]int32{}
342	var frames int
343	var done []neferclient.SurfaceID
344	hd := neferclientmocks.NewMockHandler(t)
345	hd.EXPECT().Configure(mock.Anything, mock.Anything, mock.Anything).Run(func(id neferclient.SurfaceID, w, h int32) {
346		configured[id] = [2]int32{w, h}
347	}).Return().Maybe()
348	hd.EXPECT().Frame(mock.Anything).Run(func(neferclient.SurfaceID) { frames++ }).Return().Maybe()
349	hd.EXPECT().Scale(mock.Anything, mock.Anything).Return().Maybe()
350	hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Maybe()
351	hd.EXPECT().Pointer(mock.Anything).Return().Maybe()
352	hd.EXPECT().KeyboardFocus(mock.Anything, mock.Anything).Return().Maybe()
353	parent := headlessLayer(t, c, hd, configured, &frames)
354
355	p, err := c.NewPopup(neferclient.PopupConfig{Parent: parent, Grab: true, Serial: 1, Place: neferclient.PopupPlacement{
356		Anchor: neferclient.Rect{Width: 20, Height: 50}, Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
357		Width: 100, Height: 40,
358	}})
359	require.NoError(t, err)
360	dispatchUntil(t, c, hd, func() bool { return len(done) > 0 })
361	require.Equal(t, []neferclient.SurfaceID{p.ID()}, done)
362	require.NoError(t, p.Close())
363	require.NoError(t, parent.Close())
364	require.NoError(t, c.Roundtrip())
365}

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