1package screensaver
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/bnema/neferwl/internal/adapters/logging"
15 "github.com/godbus/dbus/v5"
16)
17
18// privateBus starts a dbus-daemon of its own, so tests never touch the
19// desktop's session bus.
20func privateBus(t *testing.T) string {
21 t.Helper()
22 address, _ := startBus(t)
23 return address
24}
25
26// startBus also returns the daemon, for tests that kill it.
27func startBus(t *testing.T) (string, *exec.Cmd) {
28 t.Helper()
29 return startBusAt(t, filepath.Join(t.TempDir(), "bus"))
30}
31
32// startBusAt listens on socket, so a test can restart a bus at the same
33// address. CI must run these tests: there, a missing daemon fails.
34func startBusAt(t *testing.T, socket string) (string, *exec.Cmd) {
35 t.Helper()
36 bin, err := exec.LookPath("dbus-daemon")
37 if err != nil {
38 if os.Getenv("CI") != "" {
39 t.Fatal("dbus-daemon not installed")
40 }
41 t.Skip("dbus-daemon not installed")
42 }
43 _ = os.Remove(socket)
44 cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1", "--address=unix:path="+socket)
45 out, err := cmd.StdoutPipe()
46 if err != nil {
47 t.Fatal(err)
48 }
49 if err := cmd.Start(); err != nil {
50 t.Fatal(err)
51 }
52 t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
53 line, err := bufio.NewReader(out).ReadString('\n')
54 if err != nil {
55 t.Fatal(err)
56 }
57 return strings.TrimSpace(line), cmd
58}
59
60func connect(t *testing.T, address string) *dbus.Conn {
61 t.Helper()
62 c, err := dbus.Connect(address)
63 if err != nil {
64 t.Fatal(err)
65 }
66 t.Cleanup(func() { _ = c.Close() })
67 return c
68}
69
70// served is a running service and what it reports.
71type served struct {
72 s *Service
73 held chan bool
74 activity chan struct{}
75}
76
77// serve runs the service on address until the test ends.
78func serve(t *testing.T, address string) served {
79 t.Helper()
80 ctx, cancel := context.WithCancel(context.Background())
81 s, err := New(ctx, address, logging.For(ctx, "screensaver"))
82 if err != nil {
83 cancel()
84 t.Fatal(err)
85 }
86 v := served{s: s, held: make(chan bool, 8), activity: make(chan struct{}, 1)}
87 done := make(chan error, 1)
88 go func() { done <- s.Run(ctx, Reports{Held: v.held, Activity: v.activity}) }()
89 t.Cleanup(func() {
90 cancel()
91 if err := <-done; err != nil {
92 t.Error(err)
93 }
94 })
95 // Wait for the name: Run requests it after exporting.
96 waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" })
97 return v
98}
99
100// waitOwner polls the owner of busName ("" when none) until ok accepts it.
101func waitOwner(t *testing.T, probe *dbus.Conn, ok func(string) bool) {
102 t.Helper()
103 deadline := time.Now().Add(2 * time.Second)
104 for {
105 var owner string
106 _ = probe.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner)
107 if ok(owner) {
108 return
109 }
110 if time.Now().After(deadline) {
111 t.Fatalf("owner %q not accepted", owner)
112 }
113 time.Sleep(5 * time.Millisecond)
114 }
115}
116
117func next(t *testing.T, held <-chan bool, want bool) {
118 t.Helper()
119 select {
120 case got := <-held:
121 if got != want {
122 t.Fatalf("held %v, want %v", got, want)
123 }
124 case <-time.After(2 * time.Second):
125 t.Fatalf("no held %v", want)
126 }
127}
128
129func none(t *testing.T, held <-chan bool) {
130 t.Helper()
131 select {
132 case got := <-held:
133 t.Fatalf("unexpected held %v", got)
134 case <-time.After(100 * time.Millisecond):
135 }
136}
137
138func inhibit(t *testing.T, c *dbus.Conn, path dbus.ObjectPath) uint32 {
139 t.Helper()
140 var cookie uint32
141 if err := c.Object(busName, path).Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err != nil {
142 t.Fatal(err)
143 }
144 return cookie
145}
146
147func TestInhibitAndUnInhibit(t *testing.T) {
148 address := privateBus(t)
149 held := serve(t, address).held
150 a, b := connect(t, address), connect(t, address)
151 ca := inhibit(t, a, "/org/freedesktop/ScreenSaver")
152 next(t, held, true)
153 cb := inhibit(t, b, "/ScreenSaver")
154 none(t, held)
155 // b cannot release a's cookie.
156 if err := b.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil {
157 t.Fatal(err)
158 }
159 if err := a.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil {
160 t.Fatal(err)
161 }
162 none(t, held)
163 if err := b.Object(busName, "/ScreenSaver").Call(iface+".UnInhibit", 0, cb).Err; err != nil {
164 t.Fatal(err)
165 }
166 next(t, held, false)
167 obj := a.Object(busName, "/org/freedesktop/ScreenSaver")
168 var active bool
169 if err := obj.Call(iface+".GetActive", 0).Store(&active); err != nil || active {
170 t.Fatalf("GetActive %v %v", active, err)
171 }
172 var since uint32
173 if err := obj.Call(iface+".GetActiveTime", 0).Store(&since); err != nil || since != 0 {
174 t.Fatalf("GetActiveTime %v %v", since, err)
175 }
176 if err := obj.Call(iface+".SetActive", 0, true).Store(&active); err != nil || active {
177 t.Fatalf("SetActive %v %v", active, err)
178 }
179 var xml string
180 if err := obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil || !strings.Contains(xml, `<method name="UnInhibit">`) || !strings.Contains(xml, `<method name="SimulateUserActivity">`) {
181 t.Fatalf("introspection %q %v", xml, err)
182 }
183}
184
185// SimulateUserActivity reports activity without blocking: calls the
186// reader has not taken yet collapse into one.
187func TestSimulateUserActivity(t *testing.T) {
188 address := privateBus(t)
189 v := serve(t, address)
190 obj := connect(t, address).Object(busName, "/ScreenSaver")
191 for range 3 {
192 if err := obj.Call(iface+".SimulateUserActivity", 0).Err; err != nil {
193 t.Fatal(err)
194 }
195 }
196 <-v.activity
197 select {
198 case <-v.activity:
199 t.Fatal("activity not coalesced")
200 default:
201 }
202}
203
204// A client that disconnects without UnInhibit, as a crash does, releases
205// its inhibitions.
206func TestDisconnectReleases(t *testing.T) {
207 address := privateBus(t)
208 held := serve(t, address).held
209 c, err := dbus.Connect(address)
210 if err != nil {
211 t.Fatal(err)
212 }
213 inhibit(t, c, "/org/freedesktop/ScreenSaver")
214 inhibit(t, c, "/org/freedesktop/ScreenSaver")
215 next(t, held, true)
216 _ = c.Close()
217 next(t, held, false)
218}
219
220// A NameOwnerChanged sent straight to the service by a client is not the
221// bus's: it cannot end another client's inhibition.
222func TestSpoofedDepartureIgnored(t *testing.T) {
223 address := privateBus(t)
224 held := serve(t, address).held
225 victim, attacker := connect(t, address), connect(t, address)
226 inhibit(t, victim, "/org/freedesktop/ScreenSaver")
227 next(t, held, true)
228 var service string
229 if err := attacker.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&service); err != nil {
230 t.Fatal(err)
231 }
232 gone := victim.Names()[0]
233 msg := &dbus.Message{
234 Type: dbus.TypeSignal,
235 Headers: map[dbus.HeaderField]dbus.Variant{
236 dbus.FieldPath: dbus.MakeVariant(dbus.ObjectPath("/org/freedesktop/DBus")),
237 dbus.FieldInterface: dbus.MakeVariant("org.freedesktop.DBus"),
238 dbus.FieldMember: dbus.MakeVariant("NameOwnerChanged"),
239 dbus.FieldDestination: dbus.MakeVariant(service),
240 dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf("", "", "")),
241 },
242 Body: []any{gone, gone, ""},
243 }
244 if call := attacker.Send(msg, nil); call != nil && call.Err != nil {
245 t.Fatal(call.Err)
246 }
247 // A call after the signal: the service has read the signal by its reply.
248 inhibit(t, attacker, "/ScreenSaver")
249 none(t, held)
250 _ = attacker.Close()
251 none(t, held)
252}
253
254// A client that leaves right after its Inhibit, before the service handles
255// the call, holds nothing: its departure may come first.
256func TestEarlyDepartureHoldsNothing(t *testing.T) {
257 address := privateBus(t)
258 held := serve(t, address).held
259 for range 20 {
260 c, err := dbus.Connect(address)
261 if err != nil {
262 t.Fatal(err)
263 }
264 c.Object(busName, "/ScreenSaver").Go(iface+".Inhibit", dbus.FlagNoReplyExpected, nil, "test", "video")
265 _ = c.Close()
266 }
267 // Each one may hold briefly; once all are handled, none does.
268 state := false
269 deadline := time.After(3 * time.Second)
270 for quiet := false; !quiet; {
271 select {
272 case state = <-held:
273 case <-time.After(300 * time.Millisecond):
274 quiet = true
275 case <-deadline:
276 t.Fatal("never settled")
277 }
278 }
279 if state {
280 t.Fatal("a departed client still holds an inhibition")
281 }
282}
283
284// One connection holds at most maxPerSender cookies; the next is refused.
285func TestPerSenderLimit(t *testing.T) {
286 address := privateBus(t)
287 held := serve(t, address).held
288 c := connect(t, address)
289 obj := c.Object(busName, "/ScreenSaver")
290 for range maxPerSender {
291 inhibit(t, c, "/ScreenSaver")
292 }
293 next(t, held, true)
294 var cookie uint32
295 if err := obj.Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err == nil {
296 t.Fatalf("cookie %d past the limit", cookie)
297 }
298 // Another connection is not limited by the first.
299 inhibit(t, connect(t, address), "/ScreenSaver")
300}
301
302// A call that reaches the handler after Run returned fails at once
303// instead of waiting for a loop that is gone.
304func TestCallAfterStop(t *testing.T) {
305 address := privateBus(t)
306 ctx, cancel := context.WithCancel(context.Background())
307 s, err := New(ctx, address, logging.For(ctx, "screensaver"))
308 if err != nil {
309 cancel()
310 t.Fatal(err)
311 }
312 done := make(chan error, 1)
313 go func() { done <- s.Run(ctx, Reports{Held: make(chan bool, 1), Activity: make(chan struct{}, 1)}) }()
314 cancel()
315 if err := <-done; err != nil {
316 t.Fatal(err)
317 }
318 got := make(chan *dbus.Error, 1)
319 go func() { _, err := (handler{s: s}).Inhibit(":1.9", "test", "video"); got <- err }()
320 select {
321 case err := <-got:
322 if err == nil {
323 t.Fatal("Inhibit succeeded on a stopped service")
324 }
325 case <-time.After(2 * time.Second):
326 t.Fatal("Inhibit waits on a stopped service")
327 }
328}
329
330// The bus going away while inhibited reports false so idle resumes.
331func TestBusLossReleases(t *testing.T) {
332 address, cmd := startBus(t)
333 ctx, cancel := context.WithCancel(context.Background())
334 defer cancel()
335 s, err := New(ctx, address, logging.For(ctx, "screensaver"))
336 if err != nil {
337 t.Fatal(err)
338 }
339 held := make(chan bool, 8)
340 done := make(chan error, 1)
341 go func() { done <- s.Run(ctx, Reports{Held: held, Activity: make(chan struct{}, 1)}) }()
342 c := connect(t, address)
343 waitOwner(t, c, func(owner string) bool { return owner != "" })
344 inhibit(t, c, "/ScreenSaver")
345 next(t, held, true)
346 _ = cmd.Process.Kill()
347 next(t, held, false)
348 select {
349 case err := <-done:
350 if err == nil {
351 t.Fatal("bus loss is not an error")
352 }
353 case <-time.After(2 * time.Second):
354 t.Fatal("Run did not return")
355 }
356}
357
358// Another owner keeps the name: Run stands aside at once, so it holds no
359// bus connection.
360func TestNameTaken(t *testing.T) {
361 address := privateBus(t)
362 other := connect(t, address)
363 if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner {
364 t.Fatal(r, err)
365 }
366 ctx, cancel := context.WithCancel(context.Background())
367 s, err := New(ctx, address, logging.For(ctx, "screensaver"))
368 if err != nil {
369 cancel()
370 t.Fatal(err)
371 }
372 defer cancel()
373 done := make(chan error, 1)
374 go func() { done <- s.Run(ctx, Reports{Held: make(chan bool), Activity: make(chan struct{}, 1)}) }()
375 select {
376 case err := <-done:
377 if !errors.Is(err, ErrNameTaken) {
378 t.Fatalf("err %v, want ErrNameTaken", err)
379 }
380 case <-time.After(2 * time.Second):
381 t.Fatal("Run waits instead of standing aside")
382 }
383 var owner string
384 if err := other.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner); err != nil || owner != other.Names()[0] {
385 t.Fatalf("owner %q, want %q: %v", owner, other.Names()[0], err)
386 }
387}
388
389// Serve takes the name once its owner lets it go, and serves again after
390// the bus restarts.
391func TestServeRetries(t *testing.T) {
392 socket := filepath.Join(t.TempDir(), "bus")
393 address, cmd := startBusAt(t, socket)
394 other := connect(t, address)
395 if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner {
396 t.Fatal(r, err)
397 }
398 ctx, cancel := context.WithCancel(context.Background())
399 held := make(chan bool, 8)
400 stopped := make(chan struct{})
401 go func() {
402 defer close(stopped)
403 Serve(ctx, address, Reports{Held: held, Activity: make(chan struct{}, 1)}, 10*time.Millisecond, logging.For(ctx, "screensaver"))
404 }()
405 t.Cleanup(func() { cancel(); <-stopped })
406 if _, err := other.ReleaseName(busName); err != nil {
407 t.Fatal(err)
408 }
409 mine := other.Names()[0]
410 waitOwner(t, other, func(owner string) bool { return owner != "" && owner != mine })
411 inhibit(t, other, "/ScreenSaver")
412 next(t, held, true)
413
414 // The bus restarts: the inhibition is gone with it, the name comes back.
415 _ = cmd.Process.Kill()
416 _ = cmd.Wait()
417 next(t, held, false)
418 address, _ = startBusAt(t, socket)
419 waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" })
420}