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}