capture_admission_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
9	wlr "github.com/bnema/go-wayland-bindings/server/wlrscreencopy"
10	"github.com/bnema/neferwl/internal/logging"
11	"github.com/bnema/neferwl/internal/ports"
12	"github.com/bnema/wlturbo"
13	"github.com/bnema/zerowrap"
14	"golang.org/x/sys/unix"
15)
16
17// admissionHarness runs a real Server with real protocol clients. All fields
18// except the server internals read through display.Do are test-goroutine only.
19type admissionHarness struct {
20	s        *Server
21	dir      string
22	requests chan ports.CaptureRequest
23	replies  chan ports.CaptureDone
24	held     []ports.CaptureRequest // dup'd descriptors handed to the test
25}
26
27type admissionClient struct {
28	c       *wlturbo.Display
29	wlr     uint32
30	output  uint32
31	buf     uint32
32	session uint32 // ext session, created on first use
33	extMgr  uint32
34}
35
36// pendingCapture is one capture request and the events of its frame.
37type pendingCapture struct {
38	frame  uint32
39	events *captureEvents
40	failed uint16 // opcode of the failed event
41	ext    bool
42}
43
44func newAdmissionHarness(t *testing.T, queue int) *admissionHarness {
45	t.Helper()
46	return newAdmissionHarnessWith(t, queue, Options{CaptureAllow: allowStore(t, "*\n")}, logging.For(context.Background(), "wayland"))
47}
48
49// newAdmissionHarnessWith runs the server with extra options (RuntimeDir and
50// Outputs are the harness's) and a logger.
51func newAdmissionHarnessWith(t *testing.T, queue int, opts Options, log zerowrap.Logger) *admissionHarness {
52	t.Helper()
53	h := &admissionHarness{dir: t.TempDir(), requests: make(chan ports.CaptureRequest, queue), replies: make(chan ports.CaptureDone, maxCaptureInflight+2)}
54	// Registered first so it runs after Run stopped and the clients closed:
55	// close every dup'd descriptor, including the ones a failed test left in
56	// the queue or in held.
57	t.Cleanup(func() {
58		for len(h.requests) > 0 {
59			h.held = append(h.held, <-h.requests)
60		}
61		for _, r := range h.held {
62			_ = r.Dst.File.Close()
63		}
64	})
65	out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}}
66	opts.RuntimeDir, opts.Outputs = h.dir, out
67	s, err := New(opts, Channels{Captures: h.requests, Captured: h.replies}, log)
68	if err != nil {
69		t.Fatal(err)
70	}
71	h.s = s
72	ctx, cancel := context.WithCancel(context.Background())
73	done := make(chan error, 1)
74	go func() { done <- s.Run(ctx) }()
75	t.Cleanup(func() {
76		cancel()
77		if err := <-done; err != nil {
78			t.Errorf("Run: %v", err)
79		}
80	})
81	return h
82}
83
84func (h *admissionHarness) client(t *testing.T) *admissionClient {
85	t.Helper()
86	return h.clientOn(t, protocolClient(t, h.s, h.dir))
87}
88
89// clientOn binds the capture globals on an already connected client.
90func (h *admissionHarness) clientOn(t *testing.T, c *wlturbo.Display) *admissionClient {
91	t.Helper()
92	g, ok := c.Registry().FindGlobal("wl_output")
93	if !ok {
94		t.Fatal("output missing")
95	}
96	out, err := bindWireID(c, g.Name, g.Interface, 4)
97	if err != nil {
98		t.Fatal(err)
99	}
100	registerProtocol(t, c, out)
101	_, buf, fd := captureSmallBuffer(t, c)
102	t.Cleanup(func() { _ = unix.Close(fd) })
103	return &admissionClient{c: c, wlr: bindVersion(t, c, "zwlr_screencopy_manager_v1", 3), output: out, buf: buf}
104}
105
106func (a *admissionClient) newFrame() (uint32, *captureEvents) {
107	frame := a.c.AllocateID()
108	ev := &captureEvents{events: make(chan uint16, 8)}
109	ev.SetID(frame)
110	registerWireProxy(a.c, ev)
111	return frame, ev
112}
113
114// captureWlr asks for a wlr capture of the whole output.
115func (a *admissionClient) captureWlr(t *testing.T) pendingCapture {
116	t.Helper()
117	frame, ev := a.newFrame()
118	requestProtocol(t, a.c, a.wlr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), a.output)
119	requestProtocol(t, a.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, a.buf)
120	if err := a.c.Roundtrip(); err != nil {
121		t.Fatal(err)
122	}
123	return pendingCapture{frame: frame, events: ev, failed: uint16(wlr.ZwlrScreencopyFrameV1EventFailed)}
124}
125
126// captureExt asks for an ext capture. The session allows one live frame, so
127// the caller destroys the previous frame first.
128func (a *admissionClient) captureExt(t *testing.T) pendingCapture {
129	t.Helper()
130	if a.session == 0 {
131		a.extMgr, a.session, _ = captureTestExt(t, a.c, a.output)
132	}
133	frame, ev := a.newFrame()
134	requestProtocol(t, a.c, a.session, ext.ExtImageCopyCaptureSessionV1RequestCreateFrame, frame)
135	requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestAttachBuffer, a.buf)
136	requestProtocol(t, a.c, frame, ext.ExtImageCopyCaptureFrameV1RequestCapture)
137	if err := a.c.Roundtrip(); err != nil {
138		t.Fatal(err)
139	}
140	return pendingCapture{frame: frame, events: ev, failed: uint16(ext.ExtImageCopyCaptureFrameV1EventFailed), ext: true}
141}
142
143func (a *admissionClient) destroy(t *testing.T, p pendingCapture) {
144	t.Helper()
145	op := wlr.ZwlrScreencopyFrameV1RequestDestroy
146	if p.ext {
147		op = ext.ExtImageCopyCaptureFrameV1RequestDestroy
148	}
149	requestProtocol(t, a.c, p.frame, op)
150	if err := a.c.Roundtrip(); err != nil {
151		t.Fatal(err)
152	}
153}
154
155func (h *admissionHarness) inflight(t *testing.T) int {
156	t.Helper()
157	n := -1
158	if !h.s.display.Do(func() { n = len(h.s.captureInflight) }) {
159		t.Fatal("display stopped")
160	}
161	return n
162}
163
164func (h *admissionHarness) waitInflight(t *testing.T, want int) {
165	t.Helper()
166	deadline := time.Now().Add(2 * time.Second)
167	for h.inflight(t) != want {
168		if time.Now().After(deadline) {
169			t.Fatalf("inflight %d, want %d", h.inflight(t), want)
170		}
171		time.Sleep(time.Millisecond)
172	}
173}
174
175// accepted reports whether the capture reached the backend queue; a refused
176// capture is answered with failed and queues no request. Accepted requests
177// are kept in held so cleanup closes their descriptors.
178func (h *admissionHarness) accepted(t *testing.T, p pendingCapture) (ports.CaptureRequest, bool) {
179	t.Helper()
180	var last uint16
181	for len(p.events.events) > 0 {
182		last = <-p.events.events
183	}
184	select {
185	case req := <-h.requests:
186		if last == p.failed {
187			t.Fatal("failed frame queued a request")
188		}
189		h.held = append(h.held, req)
190		return req, true
191	default:
192		// A refused wlr frame fails at once; an ext frame waits for a
193		// retry (captureRetryFor) rather than ending the client's stream.
194		if last != p.failed && (!p.ext || last != 0) {
195			t.Fatalf("refused capture not failed: last event %d", last)
196		}
197		return ports.CaptureRequest{}, false
198	}
199}
200
201func (h *admissionHarness) complete(t *testing.T, req ports.CaptureRequest, remaining int) {
202	t.Helper()
203	_ = req.Dst.File.Close()
204	h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Time: time.Now()}
205	h.waitInflight(t, remaining)
206}
207
208// saturate fills the cap with captures whose frames are destroyed right away,
209// and returns their requests.
210func (h *admissionHarness) saturate(t *testing.T, a *admissionClient, capture func(*testing.T) pendingCapture) []ports.CaptureRequest {
211	t.Helper()
212	var reqs []ports.CaptureRequest
213	for i := 0; i < maxCaptureInflight; i++ {
214		p := capture(t)
215		req, ok := h.accepted(t, p)
216		if !ok {
217			t.Fatalf("capture %d refused below the cap", i)
218		}
219		reqs = append(reqs, req)
220		a.destroy(t, p)
221	}
222	if got := h.inflight(t); got != maxCaptureInflight {
223		t.Fatalf("inflight %d after destroying frames", got)
224	}
225	return reqs
226}
227
228func TestCaptureAdmissionSaturation(t *testing.T) {
229	h := newAdmissionHarness(t, maxCaptureInflight+2)
230	a := h.client(t)
231	var frames []pendingCapture
232	var reqs []ports.CaptureRequest
233	for i := 0; i < maxCaptureInflight; i++ {
234		p := a.captureWlr(t)
235		req, ok := h.accepted(t, p)
236		if !ok {
237			t.Fatalf("capture %d refused below the cap", i)
238		}
239		reqs, frames = append(reqs, req), append(frames, p)
240	}
241	if got := h.inflight(t); got != maxCaptureInflight {
242		t.Fatalf("inflight %d", got)
243	}
244	if _, ok := h.accepted(t, a.captureWlr(t)); ok {
245		t.Fatal("capture admitted over the cap")
246	}
247	// Destroying the client frames must not release the backend's credit.
248	for _, p := range frames {
249		a.destroy(t, p)
250	}
251	if got := h.inflight(t); got != maxCaptureInflight {
252		t.Fatalf("inflight %d after destroy", got)
253	}
254	if _, ok := h.accepted(t, a.captureWlr(t)); ok {
255		t.Fatal("destroying frames bypassed the cap")
256	}
257	// A backend completion for a destroyed frame restores exactly one credit.
258	h.complete(t, reqs[0], maxCaptureInflight-1)
259	req, ok := h.accepted(t, a.captureWlr(t))
260	if !ok {
261		t.Fatal("completion did not restore credit")
262	}
263	h.waitInflight(t, maxCaptureInflight)
264	if _, ok := h.accepted(t, a.captureWlr(t)); ok {
265		t.Fatal("cap exceeded after refill")
266	}
267	h.complete(t, req, maxCaptureInflight-1)
268	for i, r := range reqs[1:] {
269		h.complete(t, r, maxCaptureInflight-2-i)
270	}
271	h.waitInflight(t, 0)
272}
273
274func TestExtCaptureAdmissionSaturation(t *testing.T) {
275	h := newAdmissionHarness(t, maxCaptureInflight+2)
276	a := h.client(t)
277	reqs := h.saturate(t, a, a.captureExt)
278	// Every frame is destroyed, yet the cap still holds.
279	p := a.captureExt(t)
280	if _, ok := h.accepted(t, p); ok {
281		t.Fatal("destroyed ext frames bypassed the cap")
282	}
283	a.destroy(t, p)
284	h.complete(t, reqs[0], maxCaptureInflight-1)
285	p = a.captureExt(t)
286	req, ok := h.accepted(t, p)
287	if !ok {
288		t.Fatal("completion did not restore ext credit")
289	}
290	a.destroy(t, p)
291	h.waitInflight(t, maxCaptureInflight)
292	p = a.captureExt(t)
293	if _, ok := h.accepted(t, p); ok {
294		t.Fatal("cap exceeded after ext refill")
295	}
296	// A live refused frame would retry into the freed credit: drop it.
297	a.destroy(t, p)
298	h.complete(t, req, maxCaptureInflight-1)
299	for i, r := range reqs[1:] {
300		h.complete(t, r, maxCaptureInflight-2-i)
301	}
302	h.waitInflight(t, 0)
303}
304
305func TestCaptureAdmissionSurvivesDisconnect(t *testing.T) {
306	h := newAdmissionHarness(t, maxCaptureInflight+2)
307	a := h.client(t)
308	reqs := h.saturate(t, a, a.captureExt)
309	if err := a.c.Close(); err != nil {
310		t.Fatal(err)
311	}
312	// Wait until the display loop dropped the dead client's reply callbacks.
313	deadline := time.Now().Add(2 * time.Second)
314	for {
315		replies := -1
316		if !h.s.display.Do(func() { replies = len(h.s.captureReplies) }) {
317			t.Fatal("display stopped")
318		}
319		if replies == 0 {
320			break
321		}
322		if time.Now().After(deadline) {
323			t.Fatalf("%d reply callbacks retained after disconnect", replies)
324		}
325		time.Sleep(time.Millisecond)
326	}
327	if got := h.inflight(t); got != maxCaptureInflight {
328		t.Fatalf("inflight %d after disconnect", got)
329	}
330	// A new client cannot use the dead client's credits until completion.
331	b := h.client(t)
332	if _, ok := h.accepted(t, b.captureWlr(t)); ok {
333		t.Fatal("disconnect bypassed the cap")
334	}
335	h.complete(t, reqs[0], maxCaptureInflight-1)
336	req, ok := h.accepted(t, b.captureWlr(t))
337	if !ok {
338		t.Fatal("completion after disconnect did not restore credit")
339	}
340	h.complete(t, req, maxCaptureInflight-1)
341	for i, r := range reqs[1:] {
342		h.complete(t, r, maxCaptureInflight-2-i)
343	}
344	h.waitInflight(t, 0)
345}
346
347func TestCaptureAdmissionReleasedOnEnqueueFailure(t *testing.T) {
348	h := newAdmissionHarness(t, 1)
349	a := h.client(t)
350	// The first capture fills the one-slot queue and stays in flight.
351	a.captureWlr(t)
352	h.waitInflight(t, 1)
353	// The second is refused by the full queue and must not keep its credit.
354	p := a.captureWlr(t)
355	h.waitInflight(t, 1)
356	var last uint16
357	for len(p.events.events) > 0 {
358		last = <-p.events.events
359	}
360	if last != p.failed || len(h.requests) != 1 {
361		t.Fatalf("last event %d, queued %d; want failed and 1", last, len(h.requests))
362	}
363	req := <-h.requests
364	h.held = append(h.held, req)
365	h.complete(t, req, 0)
366	// With the queue drained and credit restored a new capture is accepted.
367	req, ok := h.accepted(t, a.captureWlr(t))
368	if !ok {
369		t.Fatal("capture refused after enqueue failure")
370	}
371	h.complete(t, req, 0)
372}

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 25

Tags 10

↑↓ moveenter openesc close