184 lines
6.4 KiB
Go
184 lines
6.4 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"
|
|
"time"
|
|
)
|
|
|
|
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 := connectWithAutospawn(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 := connectWithAutospawn(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()
|
|
_, _ = connectWithAutospawn(ctx, serverAddress{}, dial, spawn)
|
|
if starts != 1 {
|
|
t.Fatal("canceled connection started a daemon")
|
|
}
|
|
ctx, cancel = context.WithCancel(context.Background())
|
|
_, err = connectWithAutospawn(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 := spawnArguments(context.Background(), test.endpoint)
|
|
if err != nil || !reflect.DeepEqual(args, test.want) {
|
|
t.Fatalf("arguments = %v, %v", args, err)
|
|
}
|
|
}
|
|
if _, err := spawnArguments(context.Background(), serverAddress{"tcp4", "192.0.2.1:6560"}); err == nil {
|
|
t.Fatal("remote server allowed local autospawn")
|
|
}
|
|
}
|
|
|
|
func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) {
|
|
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 := spawnServer(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")
|
|
path, _ := newSettingsServer(t, func(string) string { return "" })
|
|
client := New(path)
|
|
if err := client.Connect(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer client.Close(context.Background())
|
|
}
|
|
|
|
// Run against a separate dummy-output daemon, never the user's speech server.
|
|
func TestNativeSpeechDispatcherColdStart(t *testing.T) {
|
|
if os.Getenv("CTHULHU_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 \"$CTHULHU_TEST_SPEECH_CONFIG\" --pid-file \"$CTHULHU_TEST_SPEECH_PID\" \"$@\"\n"
|
|
if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("CTHULHU_TEST_SPEECH_CONFIG", directory)
|
|
t.Setenv("CTHULHU_TEST_SPEECH_PID", pidPath)
|
|
t.Setenv("SPEECHD_CMD", wrapper)
|
|
t.Setenv("XDG_RUNTIME_DIR", directory)
|
|
t.Setenv("SPEECHD_ADDRESS", "")
|
|
t.Setenv("SPEECHD_SOCKET", "")
|
|
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 := DefaultSocketAddress()
|
|
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()
|
|
}
|
|
client := New("")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
defer client.Close(context.Background())
|
|
if err := client.Connect(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if client.clientID <= 0 || client.cancelConn == nil {
|
|
t.Fatal("cold-start client did not initialize both channels")
|
|
}
|
|
})
|
|
}
|
|
}
|