v0.3.0 ea9abc7

source_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"os"
6	"runtime"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/nefercap/internal/adapters/wayland/testserver"
13	"github.com/bnema/nefercap/internal/ports"
14)
15
16func newSource(t testing.TB, cfg testserver.Config) (*Source, *testserver.Server) {
17	t.Helper()
18	srv := testserver.Start(t, cfg)
19	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
20	defer cancel()
21	s, err := New(ctx, srv.Path)
22	require.NoError(t, err)
23	t.Cleanup(func() { _ = s.Close() })
24	return s, srv
25}
26
27func firstOutput(t testing.TB, s *Source) ports.Output {
28	t.Helper()
29	outs, err := s.Outputs(context.Background())
30	require.NoError(t, err)
31	require.NotEmpty(t, outs)
32	return outs[0]
33}
34
35func fdCount(t *testing.T) int {
36	t.Helper()
37	entries, err := os.ReadDir("/proc/self/fd")
38	require.NoError(t, err)
39	return len(entries)
40}
41
42func TestOutputs(t *testing.T) {
43	s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{
44		{Name: "A-1", Width: 8, Height: 4, Scale: 1}, {Name: "B-2", Width: 16, Height: 8, Scale: 2},
45	}})
46	outs, err := s.Outputs(context.Background())
47	require.NoError(t, err)
48	require.Len(t, outs, 2)
49	require.Equal(t, "A-1", outs[0].Name)
50	require.Equal(t, 16, outs[1].Width)
51	require.Equal(t, 2, outs[1].Scale)
52}
53
54func TestCapabilitiesBaseline(t *testing.T) {
55	s, _ := newSource(t, testserver.Config{})
56	require.Equal(t, ports.Capabilities{}, s.Capabilities())
57}
58
59func TestCaptureFullOutput(t *testing.T) {
60	s, srv := newSource(t, testserver.Config{})
61	out := firstOutput(t, s)
62	f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
63	require.NoError(t, err)
64	require.NoError(t, f.Validate())
65	require.Equal(t, [3]int{8, 4, 32}, [3]int{f.Width, f.Height, f.Stride})
66	require.Equal(t, ports.XRGB8888, f.Format)
67	want := testserver.Pixel(3, 2, 1)
68	require.Equal(t, want[:], f.Row(2)[12:16])
69	require.Equal(t, "output", srv.Stats().LastSource)
70}
71
72func TestCapturePicksSupportedFormat(t *testing.T) {
73	s, _ := newSource(t, testserver.Config{Formats: []uint32{0x34325258 /* XR24 big */, testserver.ARGB8888}})
74	f, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
75	require.NoError(t, err)
76	require.Equal(t, ports.ARGB8888, f.Format)
77}
78
79func TestCaptureUnsupportedFormat(t *testing.T) {
80	s, _ := newSource(t, testserver.Config{Formats: []uint32{0x34325258}})
81	_, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
82	require.ErrorIs(t, err, ports.ErrUnsupportedFormat)
83	_, err = s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
84	require.ErrorIs(t, err, ports.ErrUnsupportedFormat, "a negotiation failure leaves the Source usable")
85}
86
87func TestCaptureNoShmFormat(t *testing.T) {
88	s, _ := newSource(t, testserver.Config{NoShmFormat: true})
89	_, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
90	require.ErrorIs(t, err, ports.ErrUnsupportedFormat)
91}
92
93func TestCaptureUnknownOutputAndInvalidRegion(t *testing.T) {
94	s, _ := newSource(t, testserver.Config{})
95	_, err := s.Capture(context.Background(), ports.Target{OutputID: 999})
96	require.ErrorIs(t, err, ports.ErrOutputNotFound)
97	out := firstOutput(t, s)
98	for _, r := range []ports.Region{{X: -1, Width: 1, Height: 1}, {Width: 0, Height: 1, X: 1}, {Width: 1, Height: -1}, {Width: ports.MaxDimension + 1, Height: 1}} {
99		_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: r})
100		require.ErrorIs(t, err, ports.ErrInvalidRegion, "%+v", r)
101	}
102}
103
104func TestMissingCopyCapture(t *testing.T) {
105	srv := testserver.Start(t, testserver.Config{NoCopyCapture: true})
106	_, err := New(context.Background(), srv.Path)
107	require.ErrorContains(t, err, "ext-image-copy-capture")
108}
109
110func TestOutputRemovedDuringCapture(t *testing.T) {
111	s, srv := newSource(t, testserver.Config{Hold: true})
112	out := firstOutput(t, s)
113	errc := make(chan error, 1)
114	go func() { _, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID}); errc <- err }()
115	require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond)
116	srv.RemoveOutput("TEST-1")
117	srv.Release()
118	require.ErrorIs(t, <-errc, ports.ErrOutputNotFound)
119	_, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
120	require.ErrorIs(t, err, ports.ErrOutputNotFound)
121}
122
123func TestSessionStoppedEndsCapture(t *testing.T) {
124	s, srv := newSource(t, testserver.Config{})
125	target := ports.Target{OutputID: firstOutput(t, s).ID}
126	_, err := s.Capture(context.Background(), target)
127	require.NoError(t, err)
128	srv.StopSessions()
129	_, err = s.Capture(context.Background(), target)
130	require.ErrorIs(t, err, ports.ErrCaptureStopped)
131	_, err = s.Capture(context.Background(), target)
132	require.NoError(t, err, "a stopped session is replaced by the next capture")
133}
134
135func TestFailedStoppedReasonEndsCapture(t *testing.T) {
136	s, srv := newSource(t, testserver.Config{})
137	target := ports.Target{OutputID: firstOutput(t, s).ID}
138	srv.Fail(1, 2)
139	_, err := s.Capture(context.Background(), target)
140	require.ErrorIs(t, err, ports.ErrCaptureStopped)
141}
142
143func TestFailedUnknownRetries(t *testing.T) {
144	s, srv := newSource(t, testserver.Config{})
145	target := ports.Target{OutputID: firstOutput(t, s).ID}
146	srv.Fail(2, 0)
147	_, err := s.Capture(context.Background(), target)
148	require.NoError(t, err, "recoverable failures are retried")
149	s, srv = newSource(t, testserver.Config{})
150	srv.Fail(maxFrameAttempts, 0)
151	_, err = s.Capture(context.Background(), target)
152	require.ErrorIs(t, err, errCompositorFailedCapture, "retries without a last frame are bounded")
153	srv.Fail(0, 0)
154	_, err = s.Capture(context.Background(), target)
155	require.NoError(t, err, "and the Source stays usable")
156}
157
158// With a last frame, a transient unknown failure repeats it, like a pending
159// capture does; the failures are counted across calls and stay bounded.
160func TestFailedUnknownRepeatsLastFrame(t *testing.T) {
161	s, srv := newSource(t, testserver.Config{})
162	target := ports.Target{OutputID: firstOutput(t, s).ID}
163	first, err := s.Capture(context.Background(), target)
164	require.NoError(t, err)
165	want := append([]byte(nil), first.Pixels...)
166	srv.Fail(1, 0)
167	repeated, err := s.Capture(context.Background(), target)
168	require.NoError(t, err, "a transient unknown failure repeats the last frame")
169	require.Equal(t, want, repeated.Pixels)
170	fresh, err := s.Capture(context.Background(), target)
171	require.NoError(t, err, "recording continues")
172	require.NotEqual(t, want, fresh.Pixels, "with a new frame")
173
174	srv.Fail(maxFrameAttempts, 0)
175	// The capture started after the previous frame was answered before Fail.
176	_, err = s.Capture(context.Background(), target)
177	require.NoError(t, err)
178	for range maxFrameAttempts - 1 {
179		_, err = s.Capture(context.Background(), target)
180		require.NoError(t, err)
181	}
182	_, err = s.Capture(context.Background(), target)
183	require.ErrorIs(t, err, errCompositorFailedCapture, "the failure count is kept across calls")
184	srv.Fail(0, 0)
185	_, err = s.Capture(context.Background(), target)
186	require.NoError(t, err, "and the Source stays usable")
187}
188
189// A session the compositor stops before its first frame is a refusal: the
190// error says so, and it is not retried with a second session.
191func TestSessionStoppedBeforeFirstFrameIsRefusal(t *testing.T) {
192	s, srv := newSource(t, testserver.Config{StopAtCreate: true})
193	target := ports.Target{OutputID: firstOutput(t, s).ID}
194	_, err := s.Capture(context.Background(), target)
195	require.ErrorIs(t, err, ports.ErrCaptureStopped)
196	require.ErrorContains(t, err, "compositor refused capture")
197	require.ErrorContains(t, err, "/etc/neferwl/capture-allow")
198	require.Equal(t, 1, srv.Stats().Sessions, "no second session within the call")
199}
200
201// A stop after frames were served is a plain stop, not a refusal.
202func TestSessionStoppedAfterFrameIsNotRefusal(t *testing.T) {
203	s, srv := newSource(t, testserver.Config{})
204	target := ports.Target{OutputID: firstOutput(t, s).ID}
205	_, err := s.Capture(context.Background(), target)
206	require.NoError(t, err)
207	srv.StopSessions()
208	_, err = s.Capture(context.Background(), target)
209	require.ErrorIs(t, err, ports.ErrCaptureStopped)
210	require.NotContains(t, err.Error(), "refused")
211}
212
213func TestFailedBufferConstraintsRenegotiates(t *testing.T) {
214	s, srv := newSource(t, testserver.Config{})
215	out := firstOutput(t, s)
216	target := ports.Target{OutputID: out.ID}
217	_, err := s.Capture(context.Background(), target)
218	require.NoError(t, err)
219	// The constraints change while no frame is in flight; the next frame is
220	// created with a fresh buffer of the new size.
221	srv.Resize("TEST-1", 16, 8)
222	var f ports.Frame
223	require.Eventually(t, func() bool {
224		var err error
225		f, err = s.Capture(context.Background(), target)
226		require.NoError(t, err)
227		return f.Width == 16
228	}, 5*time.Second, time.Millisecond)
229	require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height})
230}
231
232// After a layout change the last frame no longer fits: a failed(unknown)
233// cannot repeat it. The frame is retried, bounded, and never the stale one.
234func TestFailedUnknownAfterLayoutChangeRetriesBounded(t *testing.T) {
235	s, srv := newSource(t, testserver.Config{})
236	target := ports.Target{OutputID: firstOutput(t, s).ID}
237	_, err := s.Capture(context.Background(), target)
238	require.NoError(t, err)
239	srv.Resize("TEST-1", 16, 8)
240	_ = firstOutput(t, s) // a roundtrip: the session's new constraints are read
241	srv.Fail(2, 0)
242	f, err := s.Capture(context.Background(), target)
243	require.NoError(t, err, "recoverable failures are retried")
244	require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height}, "never the repeated 8x4 frame")
245
246	srv.Resize("TEST-1", 32, 16)
247	_ = firstOutput(t, s)
248	srv.Fail(maxFrameAttempts, 0)
249	_, err = s.Capture(context.Background(), target)
250	require.ErrorIs(t, err, errCompositorFailedCapture, "the retries are bounded")
251	srv.Fail(0, 0)
252	f, err = s.Capture(context.Background(), target)
253	require.NoError(t, err, "and the Source stays usable")
254	require.Equal(t, [2]int{32, 16}, [2]int{f.Width, f.Height})
255}
256
257func TestConstraintsChangeWhileFrameInFlight(t *testing.T) {
258	s, srv := newSource(t, testserver.Config{Hold: true})
259	target := ports.Target{OutputID: firstOutput(t, s).ID}
260	type result struct {
261		f   ports.Frame
262		err error
263	}
264	res := make(chan result, 1)
265	go func() { f, err := s.Capture(context.Background(), target); res <- result{f, err} }()
266	require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond)
267	srv.Resize("TEST-1", 16, 8) // the held 8x4 frame now mismatches: failed(buffer_constraints)
268	srv.Release()
269	require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond, "the retry uses the new size")
270	srv.Release()
271	r := <-res
272	require.NoError(t, r.err)
273	require.Equal(t, [2]int{16, 8}, [2]int{r.f.Width, r.f.Height})
274}
275
276func TestReuseAndBuffers(t *testing.T) {
277	s, srv := newSource(t, testserver.Config{})
278	target := ports.Target{OutputID: firstOutput(t, s).ID}
279	for range 5 {
280		_, err := s.Capture(context.Background(), target)
281		require.NoError(t, err)
282	}
283	st := srv.Stats()
284	require.Equal(t, 2, st.Buffers, "two shm buffers alternate: the last frame stays readable while the next is written")
285	require.Equal(t, 1, st.Sessions, "one session serves consecutive frames")
286	require.GreaterOrEqual(t, st.Ready, 5)
287	srv.Resize("TEST-1", 16, 8)
288	var f ports.Frame
289	require.Eventually(t, func() bool { // a resize is seen by the next calls, never mid-poll
290		var err error
291		f, err = s.Capture(context.Background(), target)
292		require.NoError(t, err)
293		return f.Width == 16
294	}, 5*time.Second, time.Millisecond)
295	st = srv.Stats()
296	require.GreaterOrEqual(t, st.Buffers, 3)
297	require.GreaterOrEqual(t, st.DestroyedBuffers, 1, "a buffer of the old size is destroyed")
298}
299
300func TestOneFrameInFlight(t *testing.T) {
301	// The server raises duplicate_frame when a frame is created before the
302	// previous one was destroyed, so successful back to back captures prove it.
303	s, _ := newSource(t, testserver.Config{})
304	target := ports.Target{OutputID: firstOutput(t, s).ID}
305	for range 20 {
306		_, err := s.Capture(context.Background(), target)
307		require.NoError(t, err)
308	}
309}
310
311func TestNoDescriptorLeak(t *testing.T) {
312	s, _ := newSource(t, testserver.Config{})
313	target := ports.Target{OutputID: firstOutput(t, s).ID}
314	_, err := s.Capture(context.Background(), target)
315	require.NoError(t, err)
316	before := fdCount(t)
317	for range 50 {
318		_, err = s.Capture(context.Background(), target)
319		require.NoError(t, err)
320	}
321	require.LessOrEqual(t, fdCount(t), before+1)
322}
323
324func TestCaptureWaitsForReady(t *testing.T) {
325	s, srv := newSource(t, testserver.Config{Hold: true})
326	target := ports.Target{OutputID: firstOutput(t, s).ID}
327	done := make(chan error, 1)
328	go func() { _, err := s.Capture(context.Background(), target); done <- err }()
329	require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond)
330	select {
331	case <-done:
332		t.Fatal("capture returned before the compositor completed the copy")
333	case <-time.After(50 * time.Millisecond):
334	}
335	srv.Release()
336	require.NoError(t, <-done)
337}
338
339func TestCancelInterruptsBlockedCapture(t *testing.T) {
340	s, srv := newSource(t, testserver.Config{Hold: true})
341	target := ports.Target{OutputID: firstOutput(t, s).ID}
342	ctx, cancel := context.WithCancel(context.Background())
343	done := make(chan error, 1)
344	go func() { _, err := s.Capture(ctx, target); done <- err }()
345	require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond)
346	cancel()
347	require.ErrorIs(t, <-done, context.Canceled)
348	_, err := s.Capture(context.Background(), target)
349	require.ErrorIs(t, err, ports.ErrClosed)
350}
351
352func TestCloseInterruptsBlockedCapture(t *testing.T) {
353	s, srv := newSource(t, testserver.Config{Hold: true})
354	target := ports.Target{OutputID: firstOutput(t, s).ID}
355	done := make(chan error, 1)
356	go func() { _, err := s.Capture(context.Background(), target); done <- err }()
357	require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond)
358	require.NoError(t, s.Close())
359	require.ErrorIs(t, <-done, ports.ErrClosed)
360	require.NoError(t, s.Close(), "idempotent")
361}
362
363func TestCancelledContextBeforeCall(t *testing.T) {
364	s, _ := newSource(t, testserver.Config{})
365	ctx, cancel := context.WithCancel(context.Background())
366	cancel()
367	_, err := s.Capture(ctx, ports.Target{OutputID: firstOutput(t, s).ID})
368	require.ErrorIs(t, err, context.Canceled)
369}
370
371func TestConstructorContextIsScopedToConstructor(t *testing.T) {
372	srv := testserver.Start(t, testserver.Config{})
373	ctx, cancel := context.WithCancel(context.Background())
374	s, err := New(ctx, srv.Path)
375	require.NoError(t, err)
376	defer s.Close()
377	cancel()
378	_, err = s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
379	require.NoError(t, err)
380}
381
382func TestConstructorConnectFailure(t *testing.T) {
383	_, err := New(context.Background(), t.TempDir()+"/none")
384	require.Error(t, err)
385}
386
387func TestIdleCancellationDoesNotKillSource(t *testing.T) {
388	s, _ := newSource(t, testserver.Config{})
389	target := ports.Target{OutputID: firstOutput(t, s).ID}
390	ctx, cancel := context.WithCancel(context.Background())
391	_, err := s.Capture(ctx, target)
392	require.NoError(t, err)
393	_, err = s.Capture(ctx, target) // same context: the watcher stays registered
394	require.NoError(t, err)
395	cancel()
396	_, err = s.Capture(context.Background(), target)
397	require.NoError(t, err, "a cancelled context affects only calls given that context")
398}
399
400func TestConcurrentCallsAreSerialized(t *testing.T) {
401	s, _ := newSource(t, testserver.Config{})
402	target := ports.Target{OutputID: firstOutput(t, s).ID}
403	errc := make(chan error, 8)
404	for range 8 {
405		go func() { _, err := s.Capture(context.Background(), target); errc <- err }()
406	}
407	for range 8 {
408		require.NoError(t, <-errc)
409	}
410}
411
412// allocBudget is measured (see the log of TestCaptureAllocations: 17 to 18),
413// with a small margin. The session builds the frame handlers once, so a frame
414// only allocates its proxy and the handler slices of the generated binding.
415const allocBudget = 20
416
417// Steady state cost of one capture over the real transport. The measurement
418// includes the in-process libwayland server, so it is an upper bound for the
419// Source alone; the budget is set from measured values (see the benchmark).
420func TestCaptureAllocations(t *testing.T) {
421	if raceEnabled {
422		t.Skip("race instrumentation changes allocation counts")
423	}
424	s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}})
425	target := ports.Target{OutputID: firstOutput(t, s).ID}
426	ctx := context.Background()
427	for range 3 { // warm pools and buffers
428		_, err := s.Capture(ctx, target)
429		require.NoError(t, err)
430	}
431	runtime.GC()
432	avg := testing.AllocsPerRun(200, func() {
433		if _, err := s.Capture(ctx, target); err != nil {
434			panic(err)
435		}
436	})
437	t.Logf("allocations per capture (client and in-process server): %g", avg)
438	require.LessOrEqual(t, avg, float64(allocBudget))
439}
440
441// BenchmarkCapture measures Capture in a tight loop. The compositor needs
442// longer than the poll to fill a frame, so many calls repeat the last frame;
443// fresh-frames/op says how many were new.
444func BenchmarkCapture(b *testing.B) {
445	s, srv := newSource(b, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "BENCH", Width: 1920, Height: 1080, Scale: 1}}})
446	target := ports.Target{OutputID: firstOutput(b, s).ID}
447	ctx := context.Background()
448	b.ReportAllocs()
449	b.SetBytes(1920 * 1080 * 4)
450	b.ResetTimer()
451	for b.Loop() {
452		if _, err := s.Capture(ctx, target); err != nil {
453			b.Fatal(err)
454		}
455	}
456	b.ReportMetric(float64(srv.Stats().Ready)/float64(b.N), "fresh-frames/op")
457}
458
459// Opt-in: NEFERCAP_WAYLAND_SOCKET names the socket of a disposable headless
460// compositor that supports ext-image-copy-capture. Never point it at a real
461// session.
462func TestHeadlessCompositor(t *testing.T) {
463	socket := os.Getenv("NEFERCAP_WAYLAND_SOCKET")
464	if socket == "" {
465		t.Skip("NEFERCAP_WAYLAND_SOCKET not set")
466	}
467	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
468	defer cancel()
469	s, err := New(ctx, socket)
470	require.NoError(t, err)
471	defer s.Close()
472	out := firstOutput(t, s)
473	for range 3 {
474		f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID})
475		require.NoError(t, err)
476		require.NoError(t, f.Validate())
477	}
478	f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 16, Height: 8}})
479	require.NoError(t, err)
480	require.Equal(t, [2]int{16, 8}, [2]int{f.Width, f.Height})
481}
482
483// A compositor that waits for damage holds every frame after the first. The
484// capture stays pending, at most one frame is in flight, and Capture returns
485// the last frame at once instead of blocking a timed recording.
486func TestStaticScreenRepeatsLastFrame(t *testing.T) {
487	s, srv := newSource(t, testserver.Config{HoldAfterFirst: true})
488	target := ports.Target{OutputID: firstOutput(t, s).ID}
489	first, err := s.Capture(context.Background(), target)
490	require.NoError(t, err)
491	want := testserver.Pixel(2, 1, 1)
492	require.Equal(t, want[:], first.Row(1)[8:12])
493	for range 5 {
494		began := time.Now()
495		f, err := s.Capture(context.Background(), target)
496		require.NoError(t, err)
497		require.Less(t, time.Since(began), time.Second, "a pending capture does not block the caller")
498		require.Equal(t, want[:], f.Row(1)[8:12], "the last frame is repeated")
499	}
500	require.Eventually(t, func() bool { return srv.Held() == 1 }, 5*time.Second, time.Millisecond)
501	st := srv.Stats()
502	require.Equal(t, 2, st.Frames, "one served frame and one pending: a single frame in flight")
503	srv.Release() // the screen changed
504	var f ports.Frame
505	require.Eventually(t, func() bool {
506		f, err = s.Capture(context.Background(), target)
507		require.NoError(t, err)
508		return f.Row(1)[10] == 2
509	}, 5*time.Second, time.Millisecond, "the pending capture completes into the other buffer")
510}
511
512// A timed recording over a static screen keeps its schedule.
513func TestTimedRecordingOverStaticScreen(t *testing.T) {
514	s, _ := newSource(t, testserver.Config{HoldAfterFirst: true})
515	target := ports.Target{OutputID: firstOutput(t, s).ID}
516	_, err := s.Capture(context.Background(), target)
517	require.NoError(t, err)
518	began := time.Now()
519	for range 20 { // a 30 fps recording would wait 20 × 33 ms: each call must be far shorter
520		_, err = s.Capture(context.Background(), target)
521		require.NoError(t, err)
522	}
523	require.Less(t, time.Since(began), 500*time.Millisecond)
524}
525
526// NeferWL sends no new constraint batch after failed(buffer_constraints): the
527// frame is retried with the current constraints after a roundtrip.
528func TestFailedBufferConstraintsRetriesWithoutNewBatch(t *testing.T) {
529	s, srv := newSource(t, testserver.Config{})
530	target := ports.Target{OutputID: firstOutput(t, s).ID}
531	srv.Fail(1, 1)
532	_, err := s.Capture(context.Background(), target)
533	require.NoError(t, err)
534	srv.Fail(maxFrameAttempts, 1)
535	_, err = s.Capture(context.Background(), target)
536	require.ErrorIs(t, err, errCompositorFailedCapture, "the retries are bounded")
537	srv.Fail(0, 0)
538	_, err = s.Capture(context.Background(), target)
539	require.NoError(t, err)
540}
541
542func TestNonNormalTransformIsRejected(t *testing.T) {
543	for _, region := range []ports.Region{{}, {X: 1, Y: 1, Width: 2, Height: 2}} {
544		s, _ := newSource(t, testserver.Config{Transform: 3})
545		_, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, Region: region})
546		require.ErrorIs(t, err, ports.ErrUnsupportedTransform)
547		require.ErrorContains(t, err, "output transform 3 is not supported")
548	}
549}
550
551// A workspace source stops when its workspace goes away.
552func TestWorkspaceRemovedIsUnavailable(t *testing.T) {
553	cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"})
554	cfg.NeferwlSource = true
555	s, srv := newSource(t, cfg)
556	target := ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1}
557	_, err := s.Capture(context.Background(), target)
558	require.NoError(t, err)
559	srv.RemoveWorkspace("b")
560	_, err = s.Capture(context.Background(), target)
561	require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
562}
563
564// A workspace session that stops because its workspace was removed is a plain
565// stop, not a refusal: the allowlist hint is for a live workspace.
566func TestWorkspaceRemovedBeforeFirstFrameIsNotRefusal(t *testing.T) {
567	cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"})
568	cfg.NeferwlSource = true
569	s, srv := newSource(t, cfg)
570	target := ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1}
571	srv.RemoveWorkspace("b") // not read yet: the session is created, then stopped
572	_, err := s.Capture(context.Background(), target)
573	require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
574	require.ErrorIs(t, err, ports.ErrCaptureStopped)
575	require.NotContains(t, err.Error(), "capture-allow")
576	require.NotContains(t, err.Error(), "refused")
577}
578
579// A live workspace whose session is refused keeps the allowlist hint.
580func TestWorkspaceSessionRefusedKeepsAllowlistHint(t *testing.T) {
581	cfg := wsConfig(testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"})
582	cfg.NeferwlSource, cfg.StopAtCreate = true, true
583	s, _ := newSource(t, cfg)
584	_, err := s.Capture(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID, WorkspaceID: 1})
585	require.ErrorIs(t, err, ports.ErrCaptureStopped)
586	require.ErrorContains(t, err, "/etc/neferwl/capture-allow")
587}
588
589// A cancellable context costs no more than a background one.
590func TestCaptureAllocationsCancellable(t *testing.T) {
591	if raceEnabled {
592		t.Skip("race instrumentation changes allocation counts")
593	}
594	s, _ := newSource(t, testserver.Config{Outputs: []testserver.OutputSpec{{Name: "X", Width: 64, Height: 64, Scale: 1}}})
595	target := ports.Target{OutputID: firstOutput(t, s).ID}
596	ctx, cancel := context.WithCancel(context.Background())
597	defer cancel()
598	for range 3 {
599		_, err := s.Capture(ctx, target)
600		require.NoError(t, err)
601	}
602	runtime.GC()
603	avg := testing.AllocsPerRun(200, func() {
604		if _, err := s.Capture(ctx, target); err != nil {
605			panic(err)
606		}
607	})
608	t.Logf("allocations per capture with a cancellable context: %g", avg)
609	require.LessOrEqual(t, avg, float64(allocBudget))
610}
611
612// A constraint batch that repeats the same layout (a compositor may re-send
613// its constraints) must not drop the last frame or the pending capture: on a
614// static screen the next Capture would block for damage that never comes.
615func TestSameGeometryConstraintBatchKeepsPendingFrame(t *testing.T) {
616	s, srv := newSource(t, testserver.Config{HoldAfterFirst: true})
617	target := ports.Target{OutputID: firstOutput(t, s).ID}
618	// Every call is bounded, so a regression fails instead of hanging.
619	capture := func() (ports.Frame, error) {
620		ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
621		defer cancel()
622		return s.Capture(ctx, target)
623	}
624	for range 3 {
625		_, err := capture()
626		require.NoError(t, err)
627	}
628	srv.Resize("TEST-1", 8, 4) // same size: a new batch, same layout
629	f, err := capture()
630	require.NoError(t, err, "the last frame is repeated, not waited for")
631	require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height})
632	require.Equal(t, 2, srv.Stats().Frames, "the pending capture was kept, not replaced")
633}

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 2

Tags 9

↑↓ moveenter openesc close