1package wayland
2
3import (
4 "context"
5 "errors"
6 "slices"
7 "testing"
8 "time"
9
10 "github.com/bnema/go-wayland-bindings/server/xdgshell"
11 "github.com/bnema/neferwl/internal/adapters/logging"
12 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/stretchr/testify/mock"
16)
17
18// castLayout: two small outputs, so frames are cheap and a flip of the other
19// output can be told from a flip of the cast's.
20var castLayout = ports.Layout{
21 {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32}, Width: 64, Height: 32, Scale: 1},
22 {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 32, Height: 32}, X: 64, Width: 32, Height: 32, Scale: 1},
23}
24
25// castHarness runs a server with the cast channels wired: the commands go in
26// through casts, the events and inventories the server publishes are recorded
27// by the generated CastSink mock into got and invs, and the capture requests
28// and replies are the test's.
29type castHarness struct {
30 t *testing.T
31 s *Server
32 dir string
33 events chan ports.ClientEvent
34 commands chan ports.ClientCommand
35 requests chan ports.CaptureRequest
36 replies chan ports.CaptureDone
37 casts chan ports.CastCommand
38 got chan ports.CastEvent
39 invs chan ports.CastInventory
40 changes chan ports.SecurityState
41}
42
43// newCastHarness starts the server; withSink false leaves Options.Casts nil
44// (the allocation test measures the server alone).
45func newCastHarness(t *testing.T, withSink bool) *castHarness {
46 t.Helper()
47 h := &castHarness{
48 t: t,
49 dir: t.TempDir(),
50 events: make(chan ports.ClientEvent, 256),
51 commands: make(chan ports.ClientCommand, 16),
52 requests: make(chan ports.CaptureRequest, maxCaptureInflight),
53 replies: make(chan ports.CaptureDone, maxCaptureInflight+2),
54 casts: make(chan ports.CastCommand, 16),
55 got: make(chan ports.CastEvent, 4096),
56 invs: make(chan ports.CastInventory, 64),
57 changes: make(chan ports.SecurityState, 16),
58 }
59 opts := Options{RuntimeDir: h.dir, Outputs: castLayout, Security: &sessionsecurity.Gate{}}
60 if withSink {
61 sink := portsmocks.NewMockCastSink(t)
62 sink.EXPECT().Push(mock.Anything).RunAndReturn(func(ev ports.CastEvent) { h.got <- ev }).Maybe()
63 sink.EXPECT().Inventory(mock.Anything).RunAndReturn(func(inv ports.CastInventory) { h.invs <- inv }).Maybe()
64 opts.Casts = sink
65 }
66 ch := Channels{
67 Events: h.events, Commands: h.commands, Captures: h.requests, Captured: h.replies, CastCommands: h.casts,
68 SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16),
69 }
70 s, err := New(opts, ch, logging.For(context.Background(), "wayland"))
71 if err != nil {
72 t.Fatal(err)
73 }
74 h.s = s
75 relayContents(t, s)
76 ctx, cancel := context.WithCancel(context.Background())
77 done := make(chan error, 1)
78 go func() { done <- s.Run(ctx) }()
79 t.Cleanup(func() {
80 cancel()
81 select {
82 case <-done:
83 case <-time.After(3 * time.Second):
84 t.Fatal("server did not stop")
85 }
86 })
87 return h
88}
89
90// sync returns once the display goroutine ran everything posted before.
91func (h *castHarness) sync() {
92 h.t.Helper()
93 ran := make(chan struct{})
94 h.s.display.Post(func() { close(ran) })
95 select {
96 case <-ran:
97 case <-time.After(3 * time.Second):
98 h.t.Fatal("display goroutine stalled")
99 }
100}
101
102// next is the next event pushed to the sink, which must have that kind.
103func (h *castHarness) next(kind ports.CastEventKind) ports.CastEvent {
104 h.t.Helper()
105 select {
106 case ev := <-h.got:
107 if ev.Kind != kind {
108 h.t.Fatalf("event %+v, want kind %d", ev, kind)
109 }
110 return ev
111 case <-time.After(3 * time.Second):
112 h.t.Fatalf("no cast event of kind %d", kind)
113 return ports.CastEvent{}
114 }
115}
116
117func (h *castHarness) nextCastFrameDone() ports.CastEvent {
118 h.t.Helper()
119 timer := time.NewTimer(3 * time.Second)
120 defer timer.Stop()
121 for {
122 select {
123 case ev := <-h.got:
124 if ev.Kind == ports.CastFrameDone {
125 return ev
126 }
127 if ev.Kind != ports.CastDamage {
128 h.t.Fatalf("unexpected event %+v", ev)
129 }
130 case <-timer.C:
131 h.t.Fatal("no frame completion")
132 return ports.CastEvent{}
133 }
134 }
135}
136
137// none says that no event is pending once the display goroutine caught up.
138func (h *castHarness) none() {
139 h.t.Helper()
140 h.sync()
141 select {
142 case ev := <-h.got:
143 h.t.Fatalf("unexpected cast event %+v", ev)
144 default:
145 }
146}
147
148func (h *castHarness) request() ports.CaptureRequest {
149 h.t.Helper()
150 select {
151 case r := <-h.requests:
152 return r
153 case <-time.After(3 * time.Second):
154 h.t.Fatal("no capture request")
155 return ports.CaptureRequest{}
156 }
157}
158
159// noRequest says that no capture was requested.
160func (h *castHarness) noRequest(wait time.Duration) {
161 h.t.Helper()
162 select {
163 case r := <-h.requests:
164 h.t.Fatalf("unexpected capture request for cast session %d on %s", r.Session, r.Output)
165 case <-time.After(wait):
166 }
167}
168
169// castClientEvent waits for the next ClientEvent of type T, skipping others.
170func castClientEvent[T ports.ClientEvent](h *castHarness) T {
171 h.t.Helper()
172 for {
173 select {
174 case ev := <-h.events:
175 if v, ok := ev.(T); ok {
176 return v
177 }
178 case <-time.After(3 * time.Second):
179 var zero T
180 h.t.Fatalf("no %T", zero)
181 return zero
182 }
183 }
184}
185
186// complete answers a capture request the way an output does.
187func (h *castHarness) complete(req ports.CaptureRequest, err error) {
188 h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: time.Unix(5, 6)}
189}
190
191func (h *castHarness) open(id uint64, output string) (ports.CastEvent, uint64) {
192 h.t.Helper()
193 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: output, Cursor: true}
194 size := h.next(ports.CastSize)
195 return size, castClientEvent[ports.CaptureSessionOpen](h).ID
196}
197
198// state sends core's decision and waits until the display goroutine took it.
199func (h *castHarness) state(core uint64, st ports.CaptureSessionState) {
200 h.t.Helper()
201 h.commands <- st
202 for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
203 var seen bool
204 h.s.display.Do(func() { seen = h.s.castByCore[core].haveState })
205 if seen {
206 return
207 }
208 }
209 h.t.Fatal("state not applied")
210}
211
212func castFrame(id uint64, buf int, data []byte, w, hgt int) ports.CastCommand {
213 return ports.CastCommand{Kind: ports.CastFrame, ID: id, Buf: buf, Data: data, Stride: w * 4, Width: w, Height: hgt, Format: 1}
214}
215
216func (h *castHarness) flip(output string) {
217 h.t.Helper()
218 h.s.Presentation().Push(ports.OutputPresented{Output: output, Flip: &ports.FlipInfo{When: 3 * time.Second, Seq: 1}})
219 h.sync()
220}
221
222// An output cast: open registers it with core and reports its size; a frame is
223// captured into the lent mapping, indicated once, and reported done.
224func TestCastOpenSizeFrameDone(t *testing.T) {
225 h := newCastHarness(t, true)
226 size, core := h.open(7, "HEADLESS-1")
227 if size.ID != 7 || size.Width != 64 || size.Height != 32 {
228 t.Fatalf("size %+v", size)
229 }
230 if core == 0 {
231 t.Fatal("cast not registered with core")
232 }
233 data := make([]byte, 64*32*4)
234 h.casts <- castFrame(7, 3, data, 64, 32)
235 req := h.request()
236 if req.Output != "HEADLESS-1" || req.Session != core || !req.Indicate || !req.Cursor || req.Dst.File != nil ||
237 len(req.Mapped) != len(data) || &req.Mapped[0] != &data[0] || req.Stride != 256 || req.Format != 1 || req.Region.Dx() != 64 || req.Region.Dy() != 32 {
238 t.Fatalf("request %+v", req)
239 }
240 taken := castClientEvent[ports.CaptureFrameTaken](h)
241 if taken.Session != core || taken.Output != "HEADLESS-1" {
242 t.Fatalf("taken %+v", taken)
243 }
244 h.complete(req, nil)
245 done := h.next(ports.CastFrameDone)
246 if done.ID != 7 || done.Buf != 3 || done.Err != nil || !done.Time.Equal(time.Unix(5, 6)) {
247 t.Fatalf("done %+v", done)
248 }
249 // Later frames are not indicated again: the cast keeps the indicator on.
250 h.casts <- castFrame(7, 4, data, 64, 32)
251 h.complete(h.request(), nil)
252 if done = h.next(ports.CastFrameDone); done.Buf != 4 || done.Err != nil {
253 t.Fatalf("second done %+v", done)
254 }
255 h.sync()
256 for len(h.events) > 0 {
257 if ev := <-h.events; ev != nil {
258 if _, again := ev.(ports.CaptureFrameTaken); again {
259 t.Fatalf("second frame indicated again: %+v", ev)
260 }
261 }
262 }
263 // CastClose ends the cast without a reply and tells core.
264 h.casts <- ports.CastCommand{Kind: ports.CastClose, ID: 7}
265 if c := castClientEvent[ports.CaptureSessionClose](h); c.ID != core {
266 t.Fatalf("close %+v", c)
267 }
268 h.none()
269 // A frame for a closed cast gives its buffer back.
270 h.casts <- castFrame(7, 5, data, 64, 32)
271 if done = h.next(ports.CastFrameDone); done.Buf != 5 || done.Err == nil {
272 t.Fatalf("frame of a closed cast: %+v", done)
273 }
274}
275
276// A target that is missing refuses the cast.
277func TestCastOpenRefused(t *testing.T) {
278 h := newCastHarness(t, true)
279 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "NOPE-1"}
280 if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Err == nil {
281 t.Fatalf("stopped %+v", ev)
282 }
283 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Window: 99}
284 if ev := h.next(ports.CastStopped); ev.ID != 2 || ev.Reason != ports.CaptureReasonWindowGone {
285 t.Fatalf("stopped %+v", ev)
286 }
287 for id := uint64(10); id < 10+maxCasts+1; id++ {
288 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: "HEADLESS-1"}
289 }
290 for range maxCasts {
291 h.next(ports.CastSize)
292 }
293 if ev := h.next(ports.CastStopped); ev.ID != 10+maxCasts || !errors.Is(ev.Err, errCastLimit) {
294 t.Fatalf("over the limit: %+v", ev)
295 }
296}
297
298// A window cast follows the window and stops when it closes.
299func TestCastWindowCloseStops(t *testing.T) {
300 h := newCastHarness(t, true)
301 c := protocolClient(t, h.s, h.dir)
302 w, _, xdg := surfaceMapper(t, c, h.events)()
303 registerProtocol(t, c, xdg)
304 top := xdg + 1
305 placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
306 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 4, Window: w.ID}
307 if size := h.next(ports.CastSize); size.ID != 4 || size.Width != 100 || size.Height != 100 {
308 t.Fatalf("size %+v", size)
309 }
310 core := castClientEvent[ports.CaptureSessionOpen](h)
311 if core.Window != w.ID {
312 t.Fatalf("open %+v", core)
313 }
314 requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
315 roundtrip(t, c)
316 ev := h.next(ports.CastStopped)
317 if ev.ID != 4 || ev.Reason != ports.CaptureReasonWindowGone {
318 t.Fatalf("stopped %+v", ev)
319 }
320 if cl := castClientEvent[ports.CaptureSessionClose](h); cl.ID != core.ID {
321 t.Fatalf("close %+v", cl)
322 }
323 h.none()
324}
325
326// A session lock ends every cast and refuses new ones.
327func TestCastSessionLockStops(t *testing.T) {
328 h := newCastHarness(t, true)
329 h.open(1, "HEADLESS-1")
330 data := make([]byte, 64*32*4)
331 h.casts <- castFrame(1, 0, data, 64, 32)
332 req := h.request()
333 c := protocolClient(t, h.s, h.dir)
334 if _, err := bindLockManager(t, c).Lock(); err != nil {
335 t.Fatal(err)
336 }
337 roundtrip(t, c)
338 // The capture still writes into the lent buffer: its reply comes first.
339 h.none()
340 h.complete(req, nil)
341 if done := h.next(ports.CastFrameDone); done.Buf != 0 || !errors.Is(done.Err, errCastStopped) {
342 t.Fatalf("done %+v", done)
343 }
344 if ev := h.next(ports.CastStopped); ev.ID != 1 || !errors.Is(ev.Err, errCastProtected) {
345 t.Fatalf("stopped %+v", ev)
346 }
347 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Output: "HEADLESS-1"}
348 if ev := h.next(ports.CastStopped); ev.ID != 2 || !errors.Is(ev.Err, errCastProtected) {
349 t.Fatalf("open under lock: %+v", ev)
350 }
351}
352
353// An unplugged output ends the casts on it, after the capture in flight.
354func TestCastOutputUnplugStops(t *testing.T) {
355 h := newCastHarness(t, true)
356 h.open(1, "HEADLESS-1")
357 h.open(2, "HEADLESS-2")
358 data := make([]byte, 64*32*4)
359 h.casts <- castFrame(1, 9, data, 64, 32)
360 req := h.request()
361 // Apply on the owner before completing the capture. Posting a barrier
362 // does not fence the independent Commands forwarding goroutine.
363 if !h.s.display.Do(func() {
364 h.s.apply(ports.SetOutputs{Outputs: castLayout[1:], Focused: "HEADLESS-2"})
365 }) {
366 t.Fatal("display stopped")
367 }
368 h.none()
369 h.complete(req, nil)
370 if done := h.next(ports.CastFrameDone); done.ID != 1 || done.Buf != 9 || done.Err == nil {
371 t.Fatalf("a frame written for a dead cast must not be queued: %+v", done)
372 }
373 if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Reason != ports.CaptureReasonOutputGone {
374 t.Fatalf("stopped %+v", ev)
375 }
376 // The cast on the other output lives.
377 h.casts <- castFrame(2, 1, make([]byte, 32*32*4), 32, 32)
378 h.complete(h.request(), nil)
379 if done := h.next(ports.CastFrameDone); done.ID != 2 || done.Err != nil {
380 t.Fatalf("done %+v", done)
381 }
382}
383
384// A new mode renegotiates (CastSize); a frame of the old size is refused.
385func TestCastSizeChange(t *testing.T) {
386 h := newCastHarness(t, true)
387 h.open(1, "HEADLESS-1")
388 bigger := ports.Layout{castLayout[0], castLayout[1]}
389 bigger[0].Info.Width, bigger[0].Info.Height, bigger[0].Width, bigger[0].Height = 128, 64, 128, 64
390 h.commands <- ports.SetOutputs{Outputs: bigger, Focused: "HEADLESS-1"}
391 if size := h.next(ports.CastSize); size.Width != 128 || size.Height != 64 {
392 t.Fatalf("size %+v", size)
393 }
394 h.casts <- castFrame(1, 2, make([]byte, 64*32*4), 64, 32)
395 if done := h.next(ports.CastFrameDone); done.Buf != 2 || !errors.Is(done.Err, errCastSize) {
396 t.Fatalf("done %+v", done)
397 }
398 h.noRequest(20 * time.Millisecond)
399 h.casts <- castFrame(1, 2, make([]byte, 128*64*4), 128, 64)
400 h.complete(h.request(), nil)
401 if done := h.next(ports.CastFrameDone); done.Err != nil {
402 t.Fatalf("done %+v", done)
403 }
404}
405
406// A transient failure is retried on the cadence of an ext frame; past the
407// deadline the frame fails with the error.
408func TestCastTransientRetry(t *testing.T) {
409 h := newCastHarness(t, true)
410 h.open(1, "HEADLESS-1")
411 data := make([]byte, 64*32*4)
412 h.casts <- castFrame(1, 0, data, 64, 32)
413 first := h.request()
414 h.complete(first, ports.ErrCaptureBusy)
415 start := time.Now()
416 retried := h.request()
417 if retried.ID == first.ID || time.Since(start) < captureRetryEvery/2 {
418 t.Fatalf("retry %+v after %v", retried, time.Since(start))
419 }
420 h.complete(retried, nil)
421 if done := h.next(ports.CastFrameDone); done.Err != nil {
422 t.Fatalf("done %+v", done)
423 }
424
425 // Never ready: retried until the deadline, then reported.
426 h.casts <- castFrame(1, 1, data, 64, 32)
427 attempts := 0
428 for {
429 select {
430 case req := <-h.requests:
431 attempts++
432 h.complete(req, ports.ErrCaptureBusy)
433 continue
434 case ev := <-h.got:
435 if ev.Kind != ports.CastFrameDone || ev.Buf != 1 || !errors.Is(ev.Err, ports.ErrCaptureTransient) {
436 t.Fatalf("event %+v", ev)
437 }
438 case <-time.After(5 * time.Second):
439 t.Fatal("frame neither retried nor failed")
440 }
441 break
442 }
443 if attempts < 10 {
444 t.Fatalf("%d attempts in %v", attempts, captureRetryFor)
445 }
446}
447
448// Until core says the cast is active the frame waits, then goes out.
449func TestCastWaitsForActiveState(t *testing.T) {
450 h := newCastHarness(t, true)
451 _, core := h.open(1, "HEADLESS-1")
452 h.state(core, ports.CaptureSessionState{ID: core, Output: "HEADLESS-1"})
453 h.casts <- castFrame(1, 0, make([]byte, 64*32*4), 64, 32)
454 h.noRequest(60 * time.Millisecond)
455 h.commands <- ports.CaptureSessionState{ID: core, Output: "HEADLESS-1", Active: true}
456 h.complete(h.request(), nil)
457 if done := h.next(ports.CastFrameDone); done.Err != nil {
458 t.Fatalf("done %+v", done)
459 }
460 // A terminal decision of core ends the cast without telling core.
461 h.commands <- ports.CaptureSessionState{ID: core, Reason: ports.CaptureReasonOutputGone}
462 if ev := h.next(ports.CastStopped); ev.Reason != ports.CaptureReasonOutputGone {
463 t.Fatalf("stopped %+v", ev)
464 }
465 h.sync()
466 for len(h.events) > 0 {
467 if _, closed := (<-h.events).(ports.CaptureSessionClose); closed {
468 t.Fatal("core told of an end it decided")
469 }
470 }
471}
472
473// A flip of the cast's output damages an output cast, and only that flip.
474func TestCastFlipDamagesOutputCast(t *testing.T) {
475 h := newCastHarness(t, true)
476 h.open(1, "HEADLESS-1")
477 h.open(2, "HEADLESS-2")
478 h.flip("HEADLESS-1")
479 ev := h.next(ports.CastDamage)
480 if ev.ID != 1 || !ev.Time.Equal(time.Unix(3, 0)) {
481 t.Fatalf("damage %+v", ev)
482 }
483 h.none()
484 h.flip("HEADLESS-2")
485 if ev = h.next(ports.CastDamage); ev.ID != 2 {
486 t.Fatalf("damage %+v", ev)
487 }
488 // Every flip damages an output cast, committed or not.
489 h.flip("HEADLESS-1")
490 h.flip("HEADLESS-1")
491 h.next(ports.CastDamage)
492 h.next(ports.CastDamage)
493 h.none()
494}
495
496// A window cast is damaged by a flip of its output only after the window
497// committed since the last damage.
498func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) {
499 h := newCastHarness(t, true)
500 c := protocolClient(t, h.s, h.dir)
501 w, _, _ := surfaceMapper(t, c, h.events)()
502 placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
503 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
504 h.next(ports.CastSize)
505
506 h.flip("HEADLESS-1")
507 h.none() // nothing committed since the cast began
508 commit := func() {
509 if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID}, damage{full: true}) }) {
510 t.Fatal("display stopped")
511 }
512 }
513 commit()
514 h.flip("HEADLESS-2")
515 h.none() // not the window's output
516 h.flip("HEADLESS-1")
517 if ev := h.next(ports.CastDamage); ev.ID != 1 {
518 t.Fatalf("damage %+v", ev)
519 }
520 h.flip("HEADLESS-1")
521 h.none() // the damage consumed the commit
522 commit()
523 commit()
524 h.flip("HEADLESS-1")
525 h.next(ports.CastDamage)
526 h.none() // two commits, one damage
527}
528
529// A popup is drawn with its toplevel: a static toplevel whose popup (or a
530// popup of that popup) keeps committing, mapping, unmapping or moving still
531// damages the window cast. The popups are windows of the server, as an
532// xdg_popup makes them.
533func TestCastWindowDamagedByPopupChanges(t *testing.T) {
534 h := newCastHarness(t, true)
535 c := protocolClient(t, h.s, h.dir)
536 w, _, _ := surfaceMapper(t, c, h.events)()
537 placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
538 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
539 h.next(ports.CastSize)
540
541 var menu, sub ports.WindowID
542 if !h.s.display.Do(func() {
543 top := h.s.windows[w.ID]
544 m := &window{id: h.s.nextWindow, xdg: top.xdg, mapped: true}
545 m.popup = &popup{w: m, parent: top}
546 h.s.windows[m.id] = m
547 s2 := &window{id: m.id + 1, xdg: top.xdg, mapped: true}
548 s2.popup = &popup{w: s2, parent: m}
549 h.s.windows[s2.id] = s2
550 h.s.nextWindow += 2
551 menu, sub = m.id, s2.id
552 }) {
553 t.Fatal("display stopped")
554 }
555 t.Cleanup(func() {
556 h.s.display.Do(func() { delete(h.s.windows, menu); delete(h.s.windows, sub) })
557 })
558 h.flip("HEADLESS-1")
559 h.none() // nothing changed yet
560 change := func(name string, f func()) {
561 t.Helper()
562 if !h.s.display.Do(f) {
563 t.Fatal("display stopped")
564 }
565 h.flip("HEADLESS-1")
566 if ev := h.next(ports.CastDamage); ev.ID != 1 {
567 t.Fatalf("%s: damage %+v", name, ev)
568 }
569 h.flip("HEADLESS-1")
570 h.none() // the damage consumed the change
571 }
572 content := func(id ports.WindowID) func() {
573 return func() { h.s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true}) }
574 }
575 change("popup commit", content(menu))
576 change("popup commit again", content(menu))
577 change("nested popup commit", content(sub))
578 change("popup unmap", func() { h.s.windows[menu].mapped = false; content(menu)() })
579 change("popup map", func() { h.s.windows[menu].mapped = true; h.s.castCommitted(menu) })
580 change("popup reposition", func() { h.s.castCommitted(menu) })
581 // A window nobody casts leaves the cast alone.
582 if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID + 1000}, damage{full: true}) }) {
583 t.Fatal("display stopped")
584 }
585 h.flip("HEADLESS-1")
586 h.none()
587}
588
589// The inventory is published once per change, not once per frame or flip, and
590// changes of one display loop turn share one publication.
591func TestCastInventoryPublishedOncePerChange(t *testing.T) {
592 h := newCastHarness(t, true)
593 inv := func() ports.CastInventory {
594 t.Helper()
595 select {
596 case v := <-h.invs:
597 return v
598 case <-time.After(3 * time.Second):
599 t.Fatal("no inventory")
600 return ports.CastInventory{}
601 }
602 }
603 noInv := func() {
604 t.Helper()
605 h.sync()
606 h.sync()
607 select {
608 case v := <-h.invs:
609 t.Fatalf("unexpected inventory %+v", v)
610 default:
611 }
612 }
613 first := inv()
614 if len(first.Outputs) != 2 || len(first.Windows) != 0 || first.Outputs[0].Info.Name != "HEADLESS-1" || first.Outputs[0].Width != 64 {
615 t.Fatalf("first inventory %+v", first)
616 }
617 for _, o := range first.Outputs {
618 if o.Current < 0 || o.Current >= len(o.Modes) || o.Modes[o.Current].Width != o.Info.Width {
619 t.Fatalf("current mode of %s: %+v", o.Info.Name, o)
620 }
621 }
622 noInv()
623
624 c := protocolClient(t, h.s, h.dir)
625 w, _, xdg := surfaceMapper(t, c, h.events)()
626 registerProtocol(t, c, xdg)
627 top := xdg + 1
628 if got := inv(); len(got.Windows) != 1 || got.Windows[0].ID != w.ID {
629 t.Fatalf("mapped inventory %+v", got)
630 }
631 noInv()
632 requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
633 requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor")
634 roundtrip(t, c)
635 var got ports.CastInventory
636 for got = inv(); got.Windows[0].Title != "editor" || got.Windows[0].AppID != "app.editor"; got = inv() {
637 }
638 noInv()
639 // The same title again changes nothing.
640 requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
641 roundtrip(t, c)
642 noInv()
643
644 // Frames and flips do not publish.
645 h.open(1, "HEADLESS-1")
646 data := make([]byte, 64*32*4)
647 for i := range 20 {
648 h.flip("HEADLESS-1")
649 h.casts <- castFrame(1, i%3, data, 64, 32)
650 h.complete(h.request(), nil)
651 // Flips may also publish damage; settle this buffer before lending
652 // the next one, independently of the reply-forwarding goroutine.
653 if ev := h.nextCastFrameDone(); ev.Err != nil {
654 t.Fatalf("frame %d: %+v", i, ev)
655 }
656 }
657 noInv()
658
659 // Several changes in one turn: one publication, of the last state. An
660 // unchanged inventory is not published at all.
661 h.s.display.Do(func() { h.s.castInventoryChanged(); h.s.castInventoryChanged() })
662 noInv()
663 h.s.display.Do(func() {
664 for _, title := range []string{"a", "b", "c"} {
665 h.s.windows[w.ID].title = title
666 h.s.castInventoryChanged()
667 }
668 })
669 if got = inv(); len(got.Windows) != 1 || got.Windows[0].Title != "c" {
670 t.Fatalf("inventory %+v", got)
671 }
672 noInv()
673
674 // An output moving is a change; the focused output too.
675 moved := slices.Clone(castLayout)
676 moved[1].X = 200
677 h.commands <- ports.SetOutputs{Outputs: moved, Focused: "HEADLESS-2"}
678 if got = inv(); got.Outputs[1].X != 200 || got.Focused != "HEADLESS-2" {
679 t.Fatalf("inventory %+v", got)
680 }
681 noInv()
682
683 // Under a session lock the windows are not listed.
684 lc := protocolClient(t, h.s, h.dir)
685 if _, err := bindLockManager(t, lc).Lock(); err != nil {
686 t.Fatal(err)
687 }
688 roundtrip(t, lc)
689 for got = inv(); len(got.Windows) != 0; got = inv() {
690 }
691}
692
693// Pushing a cast frame, capturing it and answering the flip allocates nothing
694// in steady state: the cast command rides the post ring, the request and the
695// reply are plain values, the reply function is bound once. The count
696// includes the display goroutine (AllocsPerRun reads the global malloc
697// counter). Nothing in the closure is a testify mock; the sink is left out so
698// the measured code is the server's.
699func TestCastFrameAllocations(t *testing.T) {
700 if raceEnabled {
701 t.Skip("the race detector makes the purego wake callback allocate")
702 }
703 h := newCastHarness(t, false)
704 s := h.s
705 s.castRing.Push(ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "HEADLESS-1"})
706 core := castClientEvent[ports.CaptureSessionOpen](h).ID
707 data := make([]byte, 64*32*4)
708 frame := castFrame(1, 0, data, 64, 32)
709 flip := ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: time.Second, Seq: 1}}
710 ran := make(chan struct{}, 1)
711 marker := func() { ran <- struct{}{} }
712 at := time.Unix(1, 2)
713 cycle := func() {
714 s.castRing.Push(frame)
715 req := <-h.requests
716 s.capturedRing.Push(ports.CaptureDone{ID: req.ID, Output: req.Output, Time: at})
717 s.Presentation().Push(flip)
718 s.display.Post(marker)
719 <-ran
720 }
721 for range 4 {
722 cycle()
723 }
724 if taken := castClientEvent[ports.CaptureFrameTaken](h); taken.Session != core {
725 t.Fatalf("taken %+v", taken)
726 }
727 var live int
728 s.display.Do(func() { live = len(s.castSessions) + len(s.captureInflight) + len(s.captureReplies) })
729 if live != 1 {
730 t.Fatalf("cast %d, inflight and replies must be empty after a cycle", live)
731 }
732 if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
733 t.Errorf("cast frame cycle: %.1f allocs, want 0", allocs)
734 }
735}