mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-14 23:34:01 -04:00
336 lines
11 KiB
Go
336 lines
11 KiB
Go
package systemd
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
|
"github.com/godbus/dbus/v5"
|
|
"github.com/godbus/dbus/v5/prop"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
func shellReadyBus(t *testing.T) *dbus.Conn {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("dbus-daemon"); err != nil {
|
|
t.Skip("requires dbus-daemon for an isolated session bus")
|
|
}
|
|
cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--nopidfile", "--print-address=1")
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
require.NoError(t, cmd.Start())
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
scanner := bufio.NewScanner(stdout)
|
|
require.True(t, scanner.Scan(), "session bus did not provide an address")
|
|
t.Setenv("DBUS_SESSION_BUS_ADDRESS", scanner.Text())
|
|
conn, err := dbus.ConnectSessionBus()
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { conn.Close() })
|
|
return conn
|
|
}
|
|
|
|
func shellReadyTray(t *testing.T, bus *dbus.Conn) *prop.Properties {
|
|
t.Helper()
|
|
for _, name := range []string{"org.kde.StatusNotifierWatcher", fmt.Sprintf("org.kde.StatusNotifierHost-%d-test", os.Getpid())} {
|
|
reply, err := bus.RequestName(name, dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
require.Equal(t, dbus.RequestNameReplyPrimaryOwner, reply)
|
|
}
|
|
properties, err := prop.Export(bus, "/StatusNotifierWatcher", prop.Map{
|
|
"org.kde.StatusNotifierWatcher": {
|
|
"IsStatusNotifierHostRegistered": {Value: true},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
return properties
|
|
}
|
|
|
|
func TestShellServicesWaitForNotificationsAndTray(t *testing.T) {
|
|
bus := shellReadyBus(t)
|
|
check := func(want bool) {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
ready, _, err := shellServicesReady(ctx, bus, uint32(os.Getpid()))
|
|
require.NoError(t, err)
|
|
require.Equal(t, want, ready)
|
|
}
|
|
check(false)
|
|
properties := shellReadyTray(t, bus)
|
|
check(false)
|
|
_, err := bus.RequestName("org.freedesktop.Notifications", dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
check(true)
|
|
host := fmt.Sprintf("org.kde.StatusNotifierHost-%d-test", os.Getpid())
|
|
_, err = bus.ReleaseName(host)
|
|
require.NoError(t, err)
|
|
check(false)
|
|
_, err = bus.RequestName(host, dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
_, err = bus.ReleaseName("org.kde.StatusNotifierWatcher")
|
|
require.NoError(t, err)
|
|
check(false)
|
|
_, err = bus.RequestName("org.kde.StatusNotifierWatcher", dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
properties.SetMust("org.kde.StatusNotifierWatcher", "IsStatusNotifierHostRegistered", false)
|
|
check(false)
|
|
properties.SetMust("org.kde.StatusNotifierWatcher", "IsStatusNotifierHostRegistered", true)
|
|
check(true)
|
|
}
|
|
|
|
func TestReadinessExternalNotifications(t *testing.T) {
|
|
bus := shellReadyBus(t)
|
|
shellReadyTray(t, bus)
|
|
pid := shellReadyForeignOwner(t, "org.freedesktop.Notifications")
|
|
socket := notifySocket(t)
|
|
readiness := NewReadiness()
|
|
readiness.ShellReady(uint32(os.Getpid()))
|
|
readiness.Wait(context.Background())
|
|
assert.Equal(t, fmt.Sprintf("READY=1\nSTATUS=Ready; notifications handled by external process (PID %d)", pid), readNotification(t, socket))
|
|
}
|
|
|
|
func TestReadinessForeignOwner(t *testing.T) {
|
|
if name := os.Getenv("DMS_TEST_TRAY_HOST"); name != "" {
|
|
bus, err := dbus.ConnectSessionBus()
|
|
require.NoError(t, err)
|
|
defer bus.Close()
|
|
reply, err := bus.RequestName(name, dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
require.Equal(t, dbus.RequestNameReplyPrimaryOwner, reply)
|
|
fmt.Println("host claimed")
|
|
_, _ = io.Copy(io.Discard, os.Stdin)
|
|
return
|
|
}
|
|
bus := readinessSystemdBus(t, 500*time.Millisecond)
|
|
shellReadyTray(t, bus)
|
|
_, err := bus.RequestName("org.freedesktop.Notifications", dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
host := fmt.Sprintf("org.kde.StatusNotifierHost-%d-test", os.Getpid())
|
|
_, err = bus.ReleaseName(host)
|
|
require.NoError(t, err)
|
|
ownerPID := shellReadyForeignOwner(t, host)
|
|
socket := notifySocket(t)
|
|
readiness := NewReadiness()
|
|
readiness.ShellReady(uint32(os.Getpid()))
|
|
readiness.Wait(context.Background())
|
|
assert.Equal(t, fmt.Sprintf("READY=1\nSTATUS=Shell started with a readiness warning: shell readiness timed out: %s is already owned by another process (PID %d)", host, ownerPID), readNotification(t, socket))
|
|
}
|
|
|
|
func TestReadinessCancellationDoesNotNotify(t *testing.T) {
|
|
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent")
|
|
for _, uiReady := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("ui_ready=%t", uiReady), func(t *testing.T) {
|
|
socket := notifySocket(t)
|
|
synctest.Test(t, func(t *testing.T) {
|
|
r := NewReadiness()
|
|
if uiReady {
|
|
r.ShellReady(uint32(os.Getpid()))
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(time.Second)
|
|
cancel()
|
|
}()
|
|
r.Wait(ctx)
|
|
})
|
|
raw, err := socket.SyscallConn()
|
|
require.NoError(t, err)
|
|
require.NoError(t, raw.Control(func(fd uintptr) {
|
|
var buf [1024]byte
|
|
_, _, err := unix.Recvfrom(int(fd), buf[:], unix.MSG_DONTWAIT)
|
|
require.ErrorIs(t, err, unix.EAGAIN, "must not send READY or STATUS while stopping")
|
|
}))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadinessTimeoutReportsPendingService(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
stage string
|
|
reason string
|
|
}{
|
|
{"notifications", "notification service has no D-Bus owner"},
|
|
{"watcher", "tray watcher has no D-Bus owner"},
|
|
{"host", "shell tray host is not registered on D-Bus"},
|
|
{"registration", "tray watcher did not report a registered host"},
|
|
} {
|
|
t.Run(tc.stage, func(t *testing.T) {
|
|
bus := readinessSystemdBus(t, 150*time.Millisecond)
|
|
if tc.stage != "notifications" {
|
|
_, err := bus.RequestName("org.freedesktop.Notifications", dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
}
|
|
if tc.stage == "host" || tc.stage == "registration" {
|
|
props := shellReadyTray(t, bus)
|
|
if tc.stage == "host" {
|
|
_, err := bus.ReleaseName(fmt.Sprintf("org.kde.StatusNotifierHost-%d-test", os.Getpid()))
|
|
require.NoError(t, err)
|
|
} else {
|
|
props.SetMust("org.kde.StatusNotifierWatcher", "IsStatusNotifierHostRegistered", false)
|
|
}
|
|
}
|
|
socket := notifySocket(t)
|
|
r := NewReadiness()
|
|
r.ShellReady(uint32(os.Getpid()))
|
|
r.Wait(context.Background())
|
|
require.Equal(t, "READY=1\nSTATUS=Shell started with a readiness warning: shell readiness timed out: "+tc.reason, readNotification(t, socket))
|
|
require.Eventually(t, func() bool {
|
|
var names []string
|
|
if err := bus.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names); err != nil {
|
|
return false
|
|
}
|
|
for _, name := range names {
|
|
if strings.HasPrefix(name, ":") && name != bus.Names()[0] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}, time.Second, time.Millisecond, "readiness must close both startup and service-check connections")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadinessWithoutSystemd(t *testing.T) {
|
|
t.Setenv("NOTIFY_SOCKET", "")
|
|
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent")
|
|
NewReadiness().Wait(context.Background())
|
|
}
|
|
|
|
func TestReadinessReportsTimeoutWithoutUI(t *testing.T) {
|
|
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent")
|
|
socket := notifySocket(t)
|
|
synctest.Test(t, func(t *testing.T) {
|
|
NewReadiness().Wait(context.Background())
|
|
})
|
|
assert.Equal(t, "READY=1\nSTATUS=Shell started with a readiness warning: shell readiness timed out: UI did not report readiness", readNotification(t, socket))
|
|
}
|
|
|
|
func TestReadinessLogsNotificationFailure(t *testing.T) {
|
|
t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent")
|
|
var output bytes.Buffer
|
|
logger := log.GetLogger()
|
|
level := logger.GetLevel()
|
|
logger.SetOutput(&output)
|
|
log.SetLevel("warn")
|
|
t.Cleanup(func() {
|
|
logger.SetOutput(os.Stderr)
|
|
logger.SetLevel(level)
|
|
})
|
|
for _, tc := range []struct {
|
|
name string
|
|
path string
|
|
want string
|
|
}{
|
|
{"invalid_address", "invalid", "invalid NOTIFY_SOCKET address"},
|
|
{"delivery_failure", filepath.Join(t.TempDir(), "missing"), "shell readiness timed out: UI did not report readiness"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
output.Reset()
|
|
t.Setenv("NOTIFY_SOCKET", tc.path)
|
|
synctest.Test(t, func(t *testing.T) {
|
|
NewReadiness().Wait(context.Background())
|
|
})
|
|
message := output.String()
|
|
require.Equal(t, 1, strings.Count(message, "Could not report shell readiness:"))
|
|
require.Contains(t, message, tc.want)
|
|
if tc.name == "delivery_failure" {
|
|
require.Contains(t, message, "no such file or directory")
|
|
require.NotContains(t, message, "Shell started with a readiness warning:")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type readinessWatcher struct {
|
|
mu sync.Mutex
|
|
senders []dbus.Sender
|
|
}
|
|
|
|
func (w *readinessWatcher) Get(_, _ string, sender dbus.Sender) (dbus.Variant, *dbus.Error) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.senders = append(w.senders, sender)
|
|
return dbus.MakeVariant(len(w.senders) >= 3), nil
|
|
}
|
|
|
|
func TestReadinessReusesBusConnectionWhileWaitingForTray(t *testing.T) {
|
|
bus := shellReadyBus(t)
|
|
shellReadyTray(t, bus)
|
|
_, err := bus.RequestName("org.freedesktop.Notifications", dbus.NameFlagDoNotQueue)
|
|
require.NoError(t, err)
|
|
watcher := &readinessWatcher{}
|
|
require.NoError(t, bus.Export(watcher, "/StatusNotifierWatcher", "org.freedesktop.DBus.Properties"))
|
|
socket := notifySocket(t)
|
|
r := NewReadiness()
|
|
r.ShellReady(uint32(os.Getpid()))
|
|
r.Wait(context.Background())
|
|
require.Equal(t, "READY=1", readNotification(t, socket))
|
|
watcher.mu.Lock()
|
|
defer watcher.mu.Unlock()
|
|
require.Len(t, watcher.senders, 3)
|
|
require.Equal(t, watcher.senders[0], watcher.senders[1])
|
|
require.Equal(t, watcher.senders[0], watcher.senders[2])
|
|
require.Eventually(t, func() bool {
|
|
var owned bool
|
|
err := bus.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, string(watcher.senders[0])).Store(&owned)
|
|
return err == nil && !owned
|
|
}, time.Second, time.Millisecond, "readiness must close its bus connection after success")
|
|
}
|
|
|
|
func notifySocket(t *testing.T) *net.UnixConn {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "notify")
|
|
socket, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { socket.Close() })
|
|
t.Setenv("NOTIFY_SOCKET", path)
|
|
return socket
|
|
}
|
|
|
|
func readNotification(t *testing.T, socket *net.UnixConn) string {
|
|
t.Helper()
|
|
require.NoError(t, socket.SetReadDeadline(time.Now().Add(time.Second)))
|
|
var message [1024]byte
|
|
n, _, err := socket.ReadFromUnix(message[:])
|
|
require.NoError(t, err)
|
|
return string(message[:n])
|
|
}
|
|
|
|
func shellReadyForeignOwner(t *testing.T, name string) int {
|
|
t.Helper()
|
|
cmd := exec.Command(os.Args[0], "-test.run=^TestReadinessForeignOwner$")
|
|
cmd.Env = append(os.Environ(), "DMS_TEST_TRAY_HOST="+name)
|
|
stdin, err := cmd.StdinPipe()
|
|
require.NoError(t, err)
|
|
stdout, err := cmd.StdoutPipe()
|
|
require.NoError(t, err)
|
|
require.NoError(t, cmd.Start())
|
|
t.Cleanup(func() {
|
|
_ = stdin.Close()
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
scanner := bufio.NewScanner(stdout)
|
|
require.True(t, scanner.Scan())
|
|
require.Equal(t, "host claimed", scanner.Text())
|
|
|
|
return cmd.Process.Pid
|
|
}
|