1package headless
2
3import (
4 "context"
5 "errors"
6 "image"
7 "os"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/bnema/neferwl/internal/adapters/outputkit/capture"
14 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
15 "github.com/bnema/neferwl/internal/ports"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18)
19
20type events struct {
21 mu sync.Mutex
22 log []string
23}
24
25func (e *events) add(s string) {
26 e.mu.Lock()
27 defer e.mu.Unlock()
28 e.log = append(e.log, s)
29}
30
31func (e *events) get() []string {
32 e.mu.Lock()
33 defer e.mu.Unlock()
34 return append([]string(nil), e.log...)
35}
36
37func sessionCaptureFile(t *testing.T) ports.SHMBuffer {
38 t.Helper()
39 f, err := os.CreateTemp(t.TempDir(), "capture")
40 require.NoError(t, err)
41 t.Cleanup(func() { _ = f.Close() })
42 require.NoError(t, f.Truncate(16))
43 return ports.SHMBuffer{File: f}
44}
45
46// runSession drives one frame with the given requests and returns the
47// renderer event order and the replies.
48func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wantReplies int) ([]string, []ports.CaptureDone) {
49 t.Helper()
50 ev := &events{}
51 r := portsmocks.NewMockRenderer(t)
52 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
53 // Excluded and plain scenes are renumbered 0; the tests' displayed scenes are not.
54 if s.Seq == 0 {
55 ev.add("render:excluded")
56 } else {
57 ev.add("render:full")
58 }
59 return nil, nil
60 }).Maybe()
61 frame := portsmocks.NewMockCaptureFrame(t)
62 frame.EXPECT().Done().Return(nil).Maybe()
63 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
64 r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
65 ev.add("begin")
66 return frame, nil
67 }).Maybe()
68 r.EXPECT().EndCapture(frame).Return().Maybe()
69 r.EXPECT().Close().Return().Once()
70 ctx, cancel := context.WithCancel(context.Background())
71 defer cancel()
72 scenes := make(chan ports.Scene, 1)
73 incoming := make(chan ports.CaptureRequest)
74 replies := make(chan ports.CaptureDone, 8)
75 done := make(chan error, 1)
76 go func() {
77 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
78 }()
79 // Requests wait for a scene: all of them belong to its first frame.
80 for _, q := range reqs {
81 q.Region, q.Width, q.Height, q.Stride, q.Format = image.Rect(0, 0, 2, 2), 2, 2, 8, 1
82 q.Dst = sessionCaptureFile(t)
83 incoming <- q
84 }
85 scenes <- scene
86 var got []ports.CaptureDone
87 for range wantReplies {
88 select {
89 case d := <-replies:
90 got = append(got, d)
91 case <-time.After(3 * time.Second):
92 t.Fatal("missing capture reply")
93 }
94 }
95 cancel()
96 require.NoError(t, <-done)
97 return ev.get(), got
98}
99
100func sessionScene() ports.Scene {
101 return ports.Scene{Seq: 2, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}},
102 Capture: &ports.SceneCapture{Session: 7, Revision: 1, Excluded: []ports.WindowID{10}}}
103}
104
105func TestExcludeCaptureOrderedBeforeDisplayedFrame(t *testing.T) {
106 order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
107 // The Exclude composition and its copy come first; the displayed frame and
108 // the standard capture (HUD and border included) follow.
109 require.Equal(t, []string{"render:excluded", "begin", "render:full", "begin"}, order)
110 for _, d := range replies {
111 require.NoError(t, d.Err)
112 }
113}
114
115func TestExcludeCaptureFailsClosedWithoutMatchingSession(t *testing.T) {
116 scene := sessionScene()
117 scene.Capture = nil
118 order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
119 require.Equal(t, []string{"render:full", "begin"}, order) // one frame, the standard capture only
120 byID := map[uint64]error{}
121 for _, d := range replies {
122 byID[d.ID] = d.Err
123 }
124 require.ErrorIs(t, byID[1], capture.ErrSessionInactive)
125 require.NoError(t, byID[2])
126}
127
128func TestStandardCaptureDuringSessionUsesOneComposition(t *testing.T) {
129 order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 2}}, 1)
130 require.Equal(t, []string{"render:full", "begin"}, order)
131 require.NoError(t, replies[0].Err)
132}
133
134// A hidden workspace is drawn by the child renderer (made by the factory),
135// not by the display's; the display still renders the scene it shows.
136func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
137 read, write, err := os.Pipe()
138 require.NoError(t, err)
139 defer write.Close()
140 display := portsmocks.NewMockRenderer(t)
141 display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
142 display.EXPECT().Close().Return().Once()
143 child := portsmocks.NewMockRenderer(t)
144 frame := portsmocks.NewMockCaptureFrame(t)
145 frame.EXPECT().Done().Return(nil).Maybe()
146 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
147 child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
148 require.Nil(t, s.Capture)
149 require.Len(t, s.Layers, 0, "a workspace rendered off screen has no layers")
150 return read, nil
151 }).Once()
152 child.EXPECT().BeginCapture().Return(frame, nil).Once()
153 child.EXPECT().EndCapture(frame).Return().Maybe()
154 child.EXPECT().Close().Return().Once()
155 scene := sessionScene()
156 scene.OutputWidth, scene.OutputHeight, scene.Scale = 2, 2, 1
157 scene.Capture.Workspace = 9
158 scene.CaptureScene = &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 5}}}
159 ctx, cancel := context.WithCancel(context.Background())
160 defer cancel()
161 scenes := make(chan ports.Scene, 1)
162 incoming := make(chan ports.CaptureRequest)
163 replies := make(chan ports.CaptureDone, 2)
164 done := make(chan error, 1)
165 var made [2]int
166 sink, presented := presentedSink(t, 4)
167 go func() {
168 done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, Presented: sink,
169 NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil },
170 NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil },
171 }, scenes, nil, nil, nil, incoming)
172 }()
173 q := ports.CaptureRequest{ID: 1, Workspace: 9, OffScreen: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
174 incoming <- q
175 scenes <- scene
176 // The owner may report an idle frame before receiving the first scene.
177 deadline := time.After(3 * time.Second)
178waitHold:
179 for {
180 select {
181 case report := <-presented:
182 if len(report.ChildReads) > 0 {
183 require.Equal(t, map[ports.WindowID]uint64{5: 0}, report.ChildReads, "hold published before waiting")
184 break waitHold
185 }
186 case <-deadline:
187 t.Fatal("no child hold report")
188 }
189 }
190 select {
191 case <-presented:
192 t.Fatal("child hold lifted before its fence signalled")
193 case <-time.After(120 * time.Millisecond): // beyond Wayland's 100ms timeout
194 }
195 _, err = write.Write([]byte{1})
196 require.NoError(t, err)
197 select {
198 case report := <-presented:
199 require.Empty(t, report.ChildReads, "completed render lifts child hold")
200 case <-time.After(3 * time.Second):
201 t.Fatal("no completed render report")
202 }
203 select {
204 case d := <-replies:
205 require.NoError(t, d.Err)
206 case <-time.After(3 * time.Second):
207 t.Fatal("no reply")
208 }
209 cancel()
210 require.NoError(t, <-done)
211 require.Equal(t, [2]int{2, 2}, made)
212}
213
214// A hidden request whose scene carries no CaptureScene fails closed instead of
215// being dropped or served from the displayed frame.
216func TestHiddenWorkspaceCaptureWithoutCaptureSceneFails(t *testing.T) {
217 scene := sessionScene()
218 scene.Capture.Workspace = 9
219 _, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Workspace: 9, OffScreen: true}}, 1)
220 require.ErrorIs(t, replies[0].Err, capture.ErrOffscreenUnavailable)
221}
222
223// GO007: a render error while an Exclude request was already handed to a worker
224// must not answer it a second time as request 0.
225func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
226 display := portsmocks.NewMockRenderer(t)
227 boom := errors.New("boom")
228 display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq == 0 }), mock.Anything).Return(nil, nil).Once() // excluded frame
229 display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq != 0 }), mock.Anything).Return(nil, boom).Once()
230 frame := portsmocks.NewMockCaptureFrame(t)
231 frame.EXPECT().Done().Return(nil).Maybe()
232 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
233 display.EXPECT().BeginCapture().Return(frame, nil).Once()
234 display.EXPECT().EndCapture(frame).Return().Maybe()
235 display.EXPECT().Close().Return().Once()
236 ctx, cancel := context.WithCancel(context.Background())
237 defer cancel()
238 scenes := make(chan ports.Scene, 1)
239 incoming := make(chan ports.CaptureRequest)
240 replies := make(chan ports.CaptureDone, 8)
241 done := make(chan error, 1)
242 go func() {
243 done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies,
244 NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, nil, incoming)
245 }()
246 incoming <- ports.CaptureRequest{ID: 1, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
247 scenes <- sessionScene()
248 select {
249 case err := <-done:
250 require.ErrorIs(t, err, boom)
251 case <-time.After(3 * time.Second):
252 t.Fatal("output did not stop on the render error")
253 }
254 var ids []uint64
255 for len(replies) > 0 {
256 ids = append(ids, (<-replies).ID)
257 }
258 require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")
259}
260
261// A scene that shows the capture indicator is never the source of a capture:
262// the standard capture is copied from a frame drawn without it, before the
263// displayed frame.
264func TestStandardCaptureNeverHoldsTheIndicator(t *testing.T) {
265 scene := sessionScene()
266 scene.Capture = nil
267 scene.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
268 order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 2}}, 1)
269 require.Equal(t, []string{"render:excluded", "begin", "render:full"}, order)
270 require.NoError(t, replies[0].Err)
271 // An Exclude request of the session is served from its own excluded frame.
272 scene = sessionScene()
273 scene.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
274 order, replies = runSession(t, scene, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
275 require.Equal(t, []string{"render:excluded", "begin", "render:excluded", "begin", "render:full"}, order)
276 for _, d := range replies {
277 require.NoError(t, d.Err)
278 }
279}
280
281// A capture is requested before core published the scene that shows its
282// indicator: it is held, not served from the scene the output already has, and
283// served from the first scene that shows the border, with the indicator on
284// screen in that same frame.
285func TestCaptureWaitsForItsIndicatorScene(t *testing.T) {
286 ev := &events{}
287 r := portsmocks.NewMockRenderer(t)
288 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
289 switch {
290 case s.Seq == 0:
291 ev.add("render:plain")
292 case len(s.CaptureIndicators) > 0:
293 ev.add("render:indicated")
294 default:
295 ev.add("render:bare")
296 }
297 return nil, nil
298 }).Maybe()
299 frame := portsmocks.NewMockCaptureFrame(t)
300 frame.EXPECT().Done().Return(nil).Maybe()
301 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
302 r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
303 ev.add("begin")
304 return frame, nil
305 }).Maybe()
306 r.EXPECT().EndCapture(frame).Return().Maybe()
307 r.EXPECT().Close().Return().Once()
308 ctx, cancel := context.WithCancel(context.Background())
309 defer cancel()
310 scenes := make(chan ports.Scene, 2)
311 incoming := make(chan ports.CaptureRequest)
312 replies := make(chan ports.CaptureDone, 4)
313 done := make(chan error, 1)
314 go func() {
315 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
316 }()
317 bare := ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}}}
318 scenes <- bare
319 require.Eventually(t, func() bool { return len(ev.get()) == 1 }, 2*time.Second, time.Millisecond)
320 require.Equal(t, []string{"render:bare"}, ev.get())
321
322 incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
323 select {
324 case d := <-replies:
325 t.Fatalf("served from a scene without the indicator: %+v", d)
326 case <-time.After(50 * time.Millisecond):
327 }
328 require.Equal(t, []string{"render:bare"}, ev.get(), "nothing is copied, nothing is redrawn for a held request")
329
330 indicated := bare
331 indicated.Seq = 3
332 indicated.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
333 scenes <- indicated
334 select {
335 case d := <-replies:
336 require.NoError(t, d.Err)
337 require.Equal(t, uint64(1), d.ID)
338 case <-time.After(2 * time.Second):
339 t.Fatal("not served once the indicator scene exists")
340 }
341 require.Equal(t, []string{"render:bare", "render:plain", "begin", "render:indicated"}, ev.get(), "copied from the frame without the indicator, which is on screen in the same frame")
342 cancel()
343 require.NoError(t, <-done)
344}
345
346// A request whose indicator never comes (core marks nothing) fails after the
347// bounded wait, and is never served.
348func TestCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
349 r := portsmocks.NewMockRenderer(t)
350 r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
351 r.EXPECT().Close().Return().Once()
352 ctx, cancel := context.WithCancel(context.Background())
353 defer cancel()
354 scenes := make(chan ports.Scene, 1)
355 incoming := make(chan ports.CaptureRequest)
356 replies := make(chan ports.CaptureDone, 4)
357 done := make(chan error, 1)
358 go func() {
359 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
360 }()
361 scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2}
362 start := time.Now()
363 incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
364 select {
365 case d := <-replies:
366 require.ErrorIs(t, d.Err, capture.ErrIndicatorMissing)
367 require.GreaterOrEqual(t, time.Since(start), capture.HoldFor-20*time.Millisecond)
368 case <-time.After(3 * time.Second):
369 t.Fatal("a held request was never answered")
370 }
371 cancel()
372 require.NoError(t, <-done)
373}
374
375// The capture of a frame is delivered once that frame, with its indicator, is
376// presented. When its render fails, the capture fails and no pixel is written
377// (the mock frame counts reads); when it succeeds it is delivered.
378func TestCaptureDeliveredOnlyWhenTheIndicatedFramePresents(t *testing.T) {
379 boom := errors.New("boom")
380 for _, tc := range []struct {
381 name string
382 render error
383 }{{"presented", nil}, {"render error", boom}} {
384 t.Run(tc.name, func(t *testing.T) {
385 var reads atomic.Int32
386 r := portsmocks.NewMockRenderer(t)
387 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
388 if s.Seq != 0 {
389 return nil, tc.render // the indicated frame
390 }
391 return nil, nil // the plain frame
392 }).Maybe()
393 frame := portsmocks.NewMockCaptureFrame(t)
394 frame.EXPECT().Done().Return(nil).Maybe()
395 frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
396 reads.Add(1)
397 return nil
398 }).Maybe()
399 r.EXPECT().BeginCapture().Return(frame, nil).Maybe()
400 r.EXPECT().EndCapture(frame).Return().Maybe()
401 r.EXPECT().Close().Return().Once()
402 ctx, cancel := context.WithCancel(context.Background())
403 defer cancel()
404 scenes := make(chan ports.Scene, 1)
405 incoming := make(chan ports.CaptureRequest)
406 replies := make(chan ports.CaptureDone, 4)
407 done := make(chan error, 1)
408 go func() {
409 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
410 }()
411 incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
412 scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}},
413 CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}}
414 var d ports.CaptureDone
415 select {
416 case d = <-replies:
417 case <-time.After(3 * time.Second):
418 t.Fatal("no reply")
419 }
420 if tc.render == nil {
421 require.NoError(t, d.Err)
422 require.Equal(t, int32(1), reads.Load())
423 cancel()
424 require.NoError(t, <-done)
425 return
426 }
427 require.ErrorIs(t, d.Err, capture.ErrIndicatorMissing)
428 require.Zero(t, reads.Load(), "no pixel delivered")
429 require.ErrorIs(t, <-done, boom)
430 })
431 }
432}