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}