main f2d166b

screencast_test.go RawHistoryFolder

1//go:build screencast
2
3package app
4
5import (
6	"bytes"
7	"context"
8	"os"
9	"os/exec"
10	"path/filepath"
11	"runtime"
12	"strconv"
13	"strings"
14	"sync"
15	"sync/atomic"
16	"testing"
17	"time"
18
19	"github.com/bnema/neferwl/internal/adapters/config"
20	"github.com/bnema/neferwl/internal/ports"
21	"github.com/bnema/zerobus"
22)
23
24// The screencast tests need a session bus of their own (run them under
25// dbus-run-session), the user's PipeWire and gst-launch-1.0 as the consumer.
26
27const (
28	scName    = "org.gnome.Mutter.ScreenCast"
29	scPath    = "/org/gnome/Mutter/ScreenCast"
30	scSession = "org.gnome.Mutter.ScreenCast.Session"
31	scStream  = "org.gnome.Mutter.ScreenCast.Stream"
32	scDenied  = "org.freedesktop.DBus.Error.AccessDenied"
33)
34
35// castApp is a headless NeferWL serving the D-Bus services.
36type castApp struct {
37	input chan<- ports.InputEvent
38	stop  func()
39}
40
41func requireCastEnv(t *testing.T) {
42	t.Helper()
43	if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
44		t.Skip("no session bus: run under dbus-run-session")
45	}
46	if _, err := exec.LookPath("gst-launch-1.0"); err != nil {
47		t.Skip("gst-launch-1.0 unavailable")
48	}
49	if err := exec.Command("pw-dump").Run(); err != nil {
50		t.Skipf("PipeWire is not running: %v", err)
51	}
52}
53
54// startCastApp runs the compositor with the capture allowlist content. The
55// PipeWire socket stays where it is: only the Wayland socket and the state
56// move to temporary directories.
57func startCastApp(t *testing.T, allow string) *castApp {
58	t.Helper()
59	t.Setenv("PIPEWIRE_RUNTIME_DIR", os.Getenv("XDG_RUNTIME_DIR"))
60	t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
61	t.Setenv("XDG_STATE_HOME", t.TempDir())
62	t.Setenv("HOME", t.TempDir())
63	allowPath := filepath.Join(t.TempDir(), "capture-allow")
64	if err := os.WriteFile(allowPath, []byte(allow), 0o644); err != nil {
65		t.Fatal(err)
66	}
67	ctx, cancel := context.WithCancel(context.Background())
68	inputReady := make(chan chan<- ports.InputEvent, 1)
69	done := make(chan error, 1)
70	go func() {
71		done <- run(ctx, Options{Backend: "headless", DBus: true, NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{320, 240}}, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())},
72			func(in chan<- ports.InputEvent) { inputReady <- in })
73	}()
74	a := &castApp{}
75	select {
76	case a.input = <-inputReady:
77	case <-time.After(10 * time.Second):
78		cancel()
79		t.Fatal("app did not start")
80	}
81	a.stop = func() {
82		cancel()
83		select {
84		case err := <-done:
85			if err != nil && !strings.Contains(err.Error(), "context canceled") {
86				t.Errorf("Run: %v", err)
87			}
88		case <-time.After(10 * time.Second):
89			t.Error("Run did not stop")
90		}
91	}
92	t.Cleanup(a.stop)
93	return a
94}
95
96// castClient is a client of the D-Bus services.
97func castClient(t *testing.T) *zerobus.Conn {
98	t.Helper()
99	c, err := zerobus.SessionBus()
100	if err != nil {
101		t.Fatal(err)
102	}
103	t.Cleanup(func() { _ = c.Close() })
104	return c
105}
106
107func waitOwner(t *testing.T, c *zerobus.Conn, name string) {
108	t.Helper()
109	for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
110		e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
111		e.Str(name)
112		m, err := c.Call()
113		if err != nil {
114			t.Fatal(err)
115		}
116		if m.Body().Bool() {
117			return
118		}
119	}
120	t.Fatalf("%s never appeared", name)
121}
122
123func emptyProps(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }
124
125// call returns the error name, "" on success.
126func call(c *zerobus.Conn, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) {
127	e := c.NewCall(scName, path, iface, member, sig)
128	if body != nil {
129		body(e)
130	}
131	m, err := c.Call()
132	if err == nil {
133		return "", m
134	}
135	if de, ok := err.(*zerobus.Error); ok {
136		return de.Name, m
137	}
138	return err.Error(), m
139}
140
141func recordMonitor(c *zerobus.Conn, session, connector string) (string, string) {
142	name, m := call(c, session, scSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
143		e.Str(connector)
144		a := e.BeginArray('{')
145		e.Struct()
146		e.Str("cursor-mode")
147		e.Variant("u")
148		e.Uint32(1)
149		e.EndArray(a)
150	})
151	if name != "" {
152		return name, ""
153	}
154	return "", strings.Clone(m.Body().ObjectPath())
155}
156
157// A monitor cast streams live to a PipeWire consumer, allocates next to
158// nothing per frame, refuses another client's calls and ends on Stop.
159func TestScreencastStreamsMonitor(t *testing.T) {
160	requireCastEnv(t)
161	app := startCastApp(t, "*\n")
162	c := castClient(t)
163	waitOwner(t, c, scName)
164	// The inventory reaches the service after the outputs appear.
165	var session, stream string
166	for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(50 * time.Millisecond) {
167		name, m := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps)
168		if name != "" {
169			t.Fatalf("CreateSession: %s", name)
170		}
171		session = strings.Clone(m.Body().ObjectPath())
172		var errName string
173		if errName, stream = recordMonitor(c, session, "HEADLESS-1"); errName == "" {
174			break
175		}
176		call(c, session, scSession, "Stop", "", nil)
177		if time.Now().After(deadline) {
178			t.Fatalf("RecordMonitor: %s", errName)
179		}
180	}
181	rule := "type='signal',interface='" + scStream + "'"
182	if err := c.AddMatch(rule); err != nil {
183		t.Fatal(err)
184	}
185	if err := c.AddMatch("type='signal',interface='" + scSession + "'"); err != nil {
186		t.Fatal(err)
187	}
188	if name, _ := call(c, session, scSession, "Start", "", nil); name != "" {
189		t.Fatalf("Start: %s", name)
190	}
191	node := uint32(0)
192	for node == 0 {
193		m, err := c.ReadMessage()
194		if err != nil {
195			t.Fatal(err)
196		}
197		if m.Member == "PipeWireStreamAdded" && m.Path == stream && m.Destination == c.UniqueName() {
198			node = m.Body().Uint32()
199		}
200	}
201
202	// Pointer motion damages the output while the consumer runs. The events
203	// are boxed once: sending them allocates nothing.
204	stopMoving := make(chan struct{})
205	var stopOnce sync.Once
206	stopMotion := func() { stopOnce.Do(func() { close(stopMoving) }) }
207	a := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 40, Y: 40}})
208	b := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 200, Y: 160}})
209	moved := make(chan struct{})
210	go func() {
211		defer close(moved)
212		tick := time.NewTicker(16 * time.Millisecond)
213		defer tick.Stop()
214		for i := 0; ; i++ {
215			ev := a
216			if i%2 == 1 {
217				ev = b
218			}
219			// Neither the send nor the wait may outlive the test: the app
220			// may have stopped reading.
221			select {
222			case app.input <- ev:
223			case <-stopMoving:
224				return
225			}
226			select {
227			case <-tick.C:
228			case <-stopMoving:
229				return
230			}
231		}
232	}()
233	defer func() { stopMotion(); <-moved }()
234
235	gst := exec.Command("gst-launch-1.0", "-v", "pipewiresrc", "path="+itoa(node), "stream-properties=props,media.type=Video", "num-buffers=300", "!", "fakesink", "silent=false")
236	out, err := gst.StdoutPipe()
237	if err != nil {
238		t.Fatal(err)
239	}
240	if err := gst.Start(); err != nil {
241		t.Fatal(err)
242	}
243	var frames atomic.Int64
244	go func() {
245		// Counted on raw reads: a line per frame would allocate a string.
246		buf := make([]byte, 64<<10)
247		for {
248			n, err := out.Read(buf)
249			frames.Add(int64(bytes.Count(buf[:n], []byte("chain "))))
250			if err != nil {
251				return
252			}
253		}
254	}()
255	exited := make(chan error, 1)
256	go func() { exited <- gst.Wait() }()
257
258	// Steady state: 5 seconds after the stream warmed up. Every allocation is
259	// recorded (MemProfileRate 1) and attributed by its stack: the cast path
260	// (screencast, wayland casts, purego-pipewire, zerobus) must allocate
261	// less than once per frame, and so must the renderer capture path it
262	// shares with ext-image-copy-capture (BeginCapture, EndCapture and the
263	// pipeline worker). The headless output's own display frame (Render's
264	// sync file, the flip report, injected input) allocates whether or not
265	// a cast runs: it is reported in the whole-process figure, not asserted.
266	time.Sleep(2 * time.Second)
267	runtime.MemProfileRate = 1
268	f0 := frames.Load()
269	before := allocSnapshot()
270	var m0, m1 runtime.MemStats
271	runtime.ReadMemStats(&m0)
272	time.Sleep(5 * time.Second)
273	runtime.ReadMemStats(&m1)
274	after := allocSnapshot()
275	f1 := frames.Load()
276	runtime.MemProfileRate = 512 * 1024
277	n := f1 - f0
278	if n < 60 {
279		t.Errorf("only %d frames in 5 s", n)
280	} else {
281		cast, other := after.sub(before)
282		per := float64(cast) / float64(n)
283		t.Logf("%d frames: cast path %.3f mallocs per frame, whole process %.2f (renderer and runtime included; %d other profiled)",
284			n, per, float64(m1.Mallocs-m0.Mallocs)/float64(n), other)
285		if per >= 1 {
286			t.Errorf("cast path: %.2f mallocs per frame over %d frames, want < 1", per, n)
287		}
288	}
289
290	// The consumer got its 300 buffers and left.
291	select {
292	case err := <-exited:
293		if err != nil {
294			t.Errorf("gst-launch-1.0: %v", err)
295		}
296	case <-time.After(60 * time.Second):
297		_ = gst.Process.Kill()
298		t.Fatal("gst-launch-1.0 did not finish")
299	}
300	stopMotion()
301
302	// A second client is another unique name: it is allowed (the list is
303	// off) but the session is not its own.
304	other := castClient(t)
305	if name, _ := call(other, session, scSession, "Stop", "", nil); name != scDenied {
306		t.Errorf("Stop by another client: %q", name)
307	}
308	if name, _ := call(other, stream, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str(scStream) }); name != scDenied {
309		t.Errorf("stream GetAll by another client: %q", name)
310	}
311	// A window that is not mapped cannot be recorded.
312	if name, _ := call(c, session, scSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
313		a := e.BeginArray('{')
314		e.Struct()
315		e.Str("window-id")
316		e.Variant("t")
317		e.Uint64(99999)
318		e.EndArray(a)
319	}); name == "" {
320		t.Error("RecordWindow of a closed window succeeded")
321	}
322	if name, _ := call(c, session, scSession, "Stop", "", nil); name != "" {
323		t.Fatalf("Stop: %s", name)
324	}
325	for closed := false; !closed; {
326		m, err := c.ReadMessage()
327		if err != nil {
328			t.Fatal(err)
329		}
330		closed = m.Member == "Closed" && m.Path == session
331	}
332}
333
334// A caller whose executable is not on the list gets AccessDenied from
335// every object, and nothing is captured.
336func TestScreencastAllowlistDenies(t *testing.T) {
337	requireCastEnv(t)
338	startCastApp(t, "/usr/bin/grim\n")
339	c := castClient(t)
340	waitOwner(t, c, scName)
341	if name, _ := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps); name != scDenied {
342		t.Errorf("CreateSession: %q", name)
343	}
344	if name, _ := call(c, scPath, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str("org.gnome.Mutter.ScreenCast") }); name != scDenied {
345		t.Errorf("GetAll: %q", name)
346	}
347	for _, x := range []struct{ dest, path, iface, member string }{
348		{"org.gnome.Mutter.DisplayConfig", "/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig", "GetCurrentState"},
349		{"org.gnome.Shell.Introspect", "/org/gnome/Shell/Introspect", "org.gnome.Shell.Introspect", "GetWindows"},
350		{"org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", "org.gnome.Shell.Screenshot", "InteractiveScreenshot"},
351	} {
352		e := c.NewCall(x.dest, x.path, x.iface, x.member, "")
353		_ = e
354		if _, err := c.Call(); err == nil || !strings.Contains(err.Error(), scDenied) {
355			t.Errorf("%s: %v", x.member, err)
356		}
357	}
358}
359
360func itoa(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
361
362// castPath names the packages whose allocations the cast path owns.
363var castPath = []string{
364	"internal/adapters/screencast.",
365	"purego-pipewire",
366	"bnema/zerobus",
367	"wayland.(*castSession)",
368	"wayland.(*Server).requestCast",
369	"wayland.(*Server).castFlip",
370	"wayland.(*Server).forwardCasts",
371	"wayland.(*Server).forwardCaptured",
372	"wayland.postRing",
373	"outputkit/capture.writeFrame",
374	// The renderer capture path, shared with ext-image-copy-capture and
375	// screencopy: lease, copy, read, return.
376	"vulkan.(*Renderer).BeginCapture",
377	"vulkan.(*Renderer).EndCapture",
378	"outputkit/capture.(*Pipeline).SubmitScoped",
379	"outputkit/capture.(*Pipeline).process",
380	"outputkit/capture.(*Pipeline).Recycle",
381}
382
383// allocs counts profiled allocation objects by stack, for the cast path and
384// for the rest.
385type allocs struct {
386	cast, other map[string]int64
387}
388
389func allocSnapshot() allocs {
390	runtime.GC() // the memory profile is as of the last completed GC
391	var recs []runtime.MemProfileRecord
392	for {
393		recs = make([]runtime.MemProfileRecord, 4096)
394		n, ok := runtime.MemProfile(recs, true)
395		if ok {
396			recs = recs[:n]
397			break
398		}
399		recs = make([]runtime.MemProfileRecord, n+256)
400		if n, ok = runtime.MemProfile(recs, true); ok {
401			recs = recs[:n]
402			break
403		}
404	}
405	a := allocs{cast: map[string]int64{}, other: map[string]int64{}}
406	for i := range recs {
407		pcs := recs[i].Stack()
408		frames := runtime.CallersFrames(pcs)
409		var key strings.Builder
410		isCast := false
411		for {
412			f, more := frames.Next()
413			key.WriteString(f.Function)
414			key.WriteByte(';')
415			for _, p := range castPath {
416				if strings.Contains(f.Function, p) {
417					isCast = true
418				}
419			}
420			if !more {
421				break
422			}
423		}
424		if isCast {
425			a.cast[key.String()] += recs[i].AllocObjects
426		} else {
427			a.other[key.String()] += recs[i].AllocObjects
428		}
429	}
430	return a
431}
432
433// sub returns the objects allocated between two snapshots.
434func (a allocs) sub(b allocs) (cast, other int64) {
435	for k, v := range a.cast {
436		cast += v - b.cast[k]
437	}
438	for k, v := range a.other {
439		other += v - b.other[k]
440	}
441	return cast, other
442}

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 25

Tags 10

↑↓ moveenter openesc close