Files

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
}