Files
cthulhu/internal/speech/autospawn_test.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")
}
})
}
}