// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package speech import ( "context" "errors" "io" "net" "os" "os/exec" "os/user" "path/filepath" "reflect" "strconv" "strings" "sync/atomic" "syscall" "testing" "time" "git.stormux.org/storm/fenrir/internal/audio" ) type disconnectedTransport struct { *recordingTransport reopen func(context.Context) (Transport, error) closed atomic.Bool } func (f *disconnectedTransport) Say(string) error { return io.EOF } func (f *disconnectedTransport) Cancel() error { return io.EOF } func (f *disconnectedTransport) Close() { f.closed.Store(true) } func (f *disconnectedTransport) Reopen(ctx context.Context) (Transport, error) { return f.reopen(ctx) } func TestRecoveryKeepsAdmissionResponsiveAndDropsOutageSpeech(t *testing.T) { entered, release := make(chan struct{}), make(chan struct{}) fresh := new_recording_transport() broken := &disconnectedTransport{recordingTransport: new_recording_transport(), reopen: func(ctx context.Context) (Transport, error) { close(entered) select { case <-release: return fresh, nil case <-ctx.Done(): return nil, ctx.Err() } }} service := New(broken) defer service.Close() service.Configure(Parameters{Rate: 40, Pitch: 20, Volume: 80}) service.Say("lost") <-entered admitted := make(chan struct{}) go func() { service.Interrupt() service.Configure(Parameters{Rate: 70, Pitch: 10, Volume: 90}) service.Say("discarded during outage") service.Toggle_mute() service.Say("discarded while muted") close(admitted) }() select { case <-admitted: case <-time.After(time.Second): t.Fatal("audio recovery blocked input admission") } close(release) wait_event(t, fresh.changed, "configure:70:10:90") service.Toggle_mute() service.Say_with_pitch("fresh", 60) wait_event(t, fresh.changed, "sent:fresh") fresh.mu.Lock() joined := strings.Join(fresh.events, "|") fresh.mu.Unlock() if strings.Contains(joined, "lost") || strings.Contains(joined, "discarded") || !strings.Contains(joined, "pitch:60|say:fresh|sent:fresh|pitch:10") || !broken.closed.Load() { t.Fatal(joined, broken.closed.Load()) } select { case err := <-service.Errors(): t.Fatal("successful recovery was fatal", err) default: } } func TestRecoveryFailureIsFatalAndShutdownCancelsReopen(t *testing.T) { for _, failure := range []bool{true, false} { t.Run(strconv.FormatBool(failure), func(t *testing.T) { entered := make(chan struct{}) broken := &disconnectedTransport{recordingTransport: new_recording_transport(), reopen: func(ctx context.Context) (Transport, error) { close(entered) if failure { return nil, syscall.EACCES } <-ctx.Done() return nil, ctx.Err() }} service := New(broken) service.Say("trigger recovery") <-entered if failure { select { case err := <-service.Errors(): if !errors.Is(err, syscall.EACCES) { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("permanent recovery failure was hidden") } } closed := make(chan struct{}) go func() { service.Close(); close(closed) }() select { case <-closed: case <-time.After(time.Second): t.Fatal("shutdown waited for unavailable speech") } }) } } func TestSpeechRecoveryDeadlineReportsFailure(t *testing.T) { broken := &disconnectedTransport{recordingTransport: new_recording_transport(), reopen: func(ctx context.Context) (Transport, error) { <-ctx.Done() return nil, ctx.Err() }} service := New(broken) defer service.Close() ctx, cancel := context.WithTimeout(service.recoveryContext, 40*time.Millisecond) defer cancel() service.recoveryContext = ctx service.Say("trigger unavailable daemon") select { case err := <-service.Errors(): if !errors.Is(err, context.DeadlineExceeded) { t.Fatal(err) } case <-time.After(time.Second): t.Fatal("exhausted recovery left the reader silently running") } } func TestOpenContextCancelsSilentHandshakeAndDoesNotOwnLiveClient(t *testing.T) { listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "silent.sock")) if err != nil { t.Fatal(err) } defer listener.Close() accepted := make(chan net.Conn, 1) go func() { conn, _ := listener.Accept(); accepted <- conn }() ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) defer cancel() started := time.Now() client, err := Open_driver_context(ctx, "speech-dispatcher", Options{Socket: listener.Addr().String()}) if client != nil || err == nil || time.Since(started) > time.Second { t.Fatal("silent handshake ignored cancellation", client, err) } (<-accepted).Close() server := new_fake_server(t, false) ctx, cancel = context.WithCancel(context.Background()) client, err = Open_driver_context(ctx, "speech-dispatcher", Options{Socket: server.path}) if err != nil { t.Fatal(err) } defer client.Close() cancel() if err := client.Say("live client survives startup context"); err != nil { t.Fatal(err) } } func TestSudoSpawnDropsChildCredentialsAndFiltersRootEnvironment(t *testing.T) { t.Setenv("HOME", "/root") t.Setenv("XDG_RUNTIME_DIR", "/root/runtime") t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/root/session-bus") t.Setenv("FENRIR_TEST_SECRET", "must-not-reach-child") t.Setenv("PULSE_SERVER", "unix:/tmp/selected-audio.sock") command := exec.Command("speech-dispatcher", "--spawn") account := &user.User{Uid: "2000", Gid: "2001", Username: "Username", HomeDir: "/home/Username"} if err := configure_sudo_spawn(command, account, []string{"2001", "2002"}); err != nil { t.Fatal(err) } want := &syscall.Credential{Uid: 2000, Gid: 2001, Groups: []uint32{2001, 2002}} if !reflect.DeepEqual(command.SysProcAttr.Credential, want) || command.Dir != account.HomeDir { t.Fatal(command.SysProcAttr, command.Dir) } environment := strings.Join(command.Env, "\n") if strings.Contains(environment, "/root") || strings.Contains(environment, "FENRIR_TEST_SECRET") || !strings.Contains(environment, "HOME=/home/Username") || !strings.Contains(environment, "PULSE_SERVER=unix:/tmp/selected-audio.sock") { t.Fatal(environment) } account.Uid = "0" if err := configure_sudo_spawn(command, account, nil); err == nil { t.Fatal("invalid invoking user selected root") } } func TestStartupWithoutSudoRetriesUntilPrivateDaemonAppears(t *testing.T) { t.Setenv("SUDO_UID", "") t.Setenv("SPEECHD_CMD", "/nonexistent-fixture-speech-dispatcher") socket := filepath.Join(t.TempDir(), "speech.sock") t.Setenv("SPEECHD_ADDRESS", "unix_socket:"+socket) t.Setenv("SPEECHD_SOCKET", "") uid := os.Geteuid() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() failed := make(chan struct{}, 1) result := make(chan error, 1) var client Transport go func() { result <- audio.Retry(ctx, func(ctx context.Context) error { var err error client, err = Open_driver_context(ctx, "speech-dispatcher", Options{}) if err != nil { select { case failed <- struct{}{}: default: } } return err }, audio.Temporary) }() select { case <-failed: case <-ctx.Done(): t.Fatal("initial absent daemon was not detected") } listener, err := net.Listen("unix", socket) if err != nil { t.Fatal(err) } server := new_fake_server_listener(t, false, listener) if err := <-result; err != nil { t.Fatal(err) } defer client.Close() cancel() if os.Geteuid() != uid { t.Fatal("startup changed reader credentials") } if err := client.Say("late daemon ready"); err != nil { t.Fatal(err) } wait_event(t, server.events, "say:late daemon ready") } func TestClientReconnectsAfterPrivateServerDropsBothConnections(t *testing.T) { server := new_fake_server(t, false) client, err := Open(Options{Socket: server.path}) if err != nil { t.Fatal(err) } service := New(client) defer service.Close() service.Configure(Parameters{Rate: 30, Pitch: 10, Volume: 70}) service.Say("before disconnect") wait_event(t, server.events, "say:before disconnect") server.mu.Lock() for _, conn := range server.connections { conn.Close() } server.mu.Unlock() service.Say("failed request") deadline := time.NewTimer(time.Second) defer deadline.Stop() tick := time.NewTicker(time.Millisecond) defer tick.Stop() for { service.mu.Lock() service.transportMu.RLock() restored := service.transport != nil && service.transport != client && !service.recovering service.transportMu.RUnlock() service.mu.Unlock() if restored { break } select { case err := <-service.Errors(): t.Fatal("reconnection failed", err) case <-deadline.C: t.Fatal("both Speech Dispatcher sockets were not restored") case <-tick.C: } } service.Say("after reconnect") wait_event(t, server.events, "say:after reconnect") }