Files
fenrir/internal/speech/autospawn_test.go

290 lines
9.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"syscall"
"testing"
)
func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T) {
for _, connectError := range []error{syscall.ENOENT, syscall.ECONNREFUSED} {
t.Run(connectError.Error(), func(t *testing.T) {
endpoint := serverAddress{network: "unix", address: "/tmp/selected-speech.sock"}
client, server := net.Pipe()
defer client.Close()
defer server.Close()
attempts, starts := 0, 0
got, err := connect_with_autospawn(context.Background(), endpoint, func(_ context.Context, network, address string) (net.Conn, error) {
if network != endpoint.network || address != endpoint.address {
t.Fatal("connection address changed during autostart")
}
attempts++
if attempts == 1 {
return nil, connectError
}
return client, nil
}, func(_ context.Context, selected serverAddress) error {
if selected != endpoint {
t.Fatal("spawn address differs from selected server")
}
starts++
return nil
})
if err != nil || got != client || attempts != 2 || starts != 1 {
t.Fatalf("connection = %v, %v; attempts %d, starts %d", got, err, attempts, starts)
}
})
}
}
func TestAutospawnKeepsRefusalAndCancellationErrors(t *testing.T) {
dial := func(context.Context, string, string) (net.Conn, error) { return nil, syscall.ECONNREFUSED }
starts := 0
spawn := func(context.Context, serverAddress) error { starts++; return errors.New("autospawn disabled") }
_, err := connect_with_autospawn(context.Background(), serverAddress{}, dial, spawn)
if !errors.Is(err, syscall.ECONNREFUSED) || !strings.Contains(err.Error(), "autospawn disabled") || starts != 1 {
t.Fatalf("error = %v, starts = %d", err, starts)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, _ = connect_with_autospawn(ctx, serverAddress{}, dial, spawn)
if starts != 1 {
t.Fatal("canceled connection started a daemon")
}
ctx, cancel = context.WithCancel(context.Background())
_, err = connect_with_autospawn(ctx, serverAddress{}, func(ctx context.Context, _, _ string) (net.Conn, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, syscall.ECONNREFUSED
}, func(context.Context, serverAddress) error { cancel(); return nil })
if !errors.Is(err, context.Canceled) {
t.Fatalf("retry error = %v", err)
}
}
func TestSpeechSpawnArgumentsPreserveEndpointAndRejectRemote(t *testing.T) {
for _, test := range []struct {
endpoint serverAddress
want []string
}{
{serverAddress{"unix", "/tmp/custom.sock"}, []string{"--spawn", "--communication-method", "unix_socket", "--socket-path", "/tmp/custom.sock"}},
{serverAddress{"tcp4", "127.0.0.1:6561"}, []string{"--spawn", "--communication-method", "inet_socket", "--port", "6561"}},
} {
args, err := spawn_arguments(context.Background(), test.endpoint)
if err != nil || !reflect.DeepEqual(args, test.want) {
t.Fatalf("arguments = %v, %v", args, err)
}
}
if _, err := spawn_arguments(context.Background(), serverAddress{"tcp4", "192.0.2.1:6560"}); err == nil {
t.Fatal("remote server allowed local autospawn")
}
}
func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) {
t.Setenv("SUDO_UID", "")
path := filepath.Join(t.TempDir(), "fake-speech-dispatcher")
if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'autospawn disabled by daemon' >&2\nexit 1\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("SPEECHD_CMD", path)
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
if err == nil || !strings.Contains(err.Error(), "autospawn disabled by daemon") {
t.Fatalf("error = %v", err)
}
}
func TestExistingSpeechServerDoesNotInvokeSpawn(t *testing.T) {
t.Setenv("SPEECHD_CMD", "/nonexistent/speech-dispatcher")
server := new_fake_server(t, false)
client, err := Open(Options{Socket: server.path})
if err != nil {
t.Fatal(err)
}
defer client.Close()
}
func TestPermissionErrorDoesNotSpawn(t *testing.T) {
starts := 0
_, err := connect_with_autospawn(context.Background(), serverAddress{},
func(context.Context, string, string) (net.Conn, error) { return nil, syscall.EACCES },
func(context.Context, serverAddress) error { starts++; return nil })
if !errors.Is(err, syscall.EACCES) || starts != 0 {
t.Fatalf("error %v, starts %d", err, starts)
}
}
func TestDiscoveryDoesNotSpawn(t *testing.T) {
marker := filepath.Join(t.TempDir(), "started")
command := filepath.Join(t.TempDir(), "fake-speech-dispatcher")
if err := os.WriteFile(command, []byte("#!/bin/sh\ntouch "+marker+"\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("SPEECHD_CMD", command)
options := Options{Socket: filepath.Join(t.TempDir(), "absent.sock")}
if _, err := Discover(options); err == nil {
t.Fatal("expected connection failure")
}
if _, err := discover_modules(options); err == nil {
t.Fatal("expected connection failure")
}
if _, err := discover_voices(options); err == nil {
t.Fatal("expected connection failure")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("discovery started a daemon: %v", err)
}
}
func TestUnprivilegedProcessDoesNotImpersonateSudoUser(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("requires an unprivileged caller")
}
t.Setenv("SUDO_UID", strconv.Itoa(os.Geteuid()+1))
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
if err == nil || !strings.Contains(err.Error(), "invoking user") {
t.Fatalf("error = %v", err)
}
}
// Run against a separate dummy-output daemon, never the user's speech server.
func TestNativeSpeechDispatcherColdStart(t *testing.T) {
if os.Getenv("FENRIR_NATIVE_SPEECH_STARTUP") != "1" {
t.Skip("requires isolated Speech Dispatcher startup")
}
if _, err := exec.LookPath("speech-dispatcher"); err != nil {
t.Fatal(err)
}
for _, staleSocket := range []bool{false, true} {
t.Run(fmt.Sprintf("stale-socket-%v", staleSocket), func(t *testing.T) {
directory, err := os.MkdirTemp("", "speech-start-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(directory)
configPath := filepath.Join(directory, "speechd.conf")
config := "LogLevel 0\nLogDir \"" + directory + "\"\nAddModule \"dummy\" \"sd_dummy\" \"\"\nDefaultModule dummy\nTimeout 1\n"
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
t.Fatal(err)
}
pidPath := filepath.Join(directory, "speechd.pid")
wrapper := filepath.Join(directory, "spawn")
script := "#!/bin/sh\nexec speech-dispatcher --config-dir \"$FENRIR_TEST_SPEECH_CONFIG\" --pid-file \"$FENRIR_TEST_SPEECH_PID\" \"$@\"\n"
if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("FENRIR_TEST_SPEECH_CONFIG", directory)
t.Setenv("FENRIR_TEST_SPEECH_PID", pidPath)
t.Setenv("SPEECHD_CMD", wrapper)
t.Setenv("XDG_RUNTIME_DIR", directory)
t.Setenv("SPEECHD_ADDRESS", "")
t.Setenv("SPEECHD_SOCKET", "")
t.Setenv("SUDO_UID", "")
defer func() {
if data, err := os.ReadFile(pidPath); err == nil {
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
}
}()
socket := default_socket_path()
if staleSocket {
if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil {
t.Fatal(err)
}
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"})
if err != nil {
t.Fatal(err)
}
listener.SetUnlinkOnClose(false)
listener.Close()
}
// Selection preparation must also start a cold daemon before querying
// membership, even when the saved module is no longer installed.
if staleSocket {
candidate, prepareErr := Prepare("speech-dispatcher", Options{Module: "unavailable"})
if candidate != nil {
candidate.Close()
t.Fatal("unexpected candidate")
}
if prepareErr == nil || !strings.Contains(prepareErr.Error(), `speech module "unavailable" is unavailable`) {
t.Fatalf("selection error = %v", prepareErr)
}
}
client, err := Open(Options{})
if err != nil {
t.Fatal(err)
}
defer client.Close()
if client.id == "" || client.control == nil {
t.Fatal("cold-start client did not initialize both channels")
}
})
}
}
func TestSystemSocketFallback(t *testing.T) {
for _, test := range []struct {
name string
userError error
systemError error
wantSystem bool
}{
{"user first", nil, nil, false},
{"missing user", syscall.ENOENT, nil, true},
{"stale user", syscall.ECONNREFUSED, nil, true},
{"permission denied", syscall.EACCES, nil, false},
{"both absent", syscall.ENOENT, syscall.ENOENT, true},
} {
t.Run(test.name, func(t *testing.T) {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
var addresses []string
dial := func(_ context.Context, network, address string) (net.Conn, error) {
addresses = append(addresses, address)
if network != "unix" {
t.Fatalf("network = %s", network)
}
err := test.userError
if address == "/run/speech-dispatcher/speechd.sock" {
err = test.systemError
}
if err != nil {
return nil, err
}
return client, nil
}
got, err := dial_with_system_fallback(dial)(context.Background(), "unix", "/user/speechd.sock")
want := []string{"/user/speechd.sock"}
if test.wantSystem {
want = append(want, "/run/speech-dispatcher/speechd.sock")
}
if !reflect.DeepEqual(addresses, want) {
t.Fatalf("dialed %v, want %v", addresses, want)
}
if test.userError == nil || test.wantSystem && test.systemError == nil {
if err != nil || got != client {
t.Fatalf("connection = %v, %v", got, err)
}
} else if !errors.Is(err, test.userError) {
t.Fatalf("error = %v, want %v", err, test.userError)
}
})
}
}