main 5d6fa26

screencast_test.go RawHistoryFolder

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}

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 23

Tags 10

↑↓ moveenter openesc close