Attempt to make audio and speech discovery more robust.
This commit is contained in:
1 parent
a27f274099
commit
dc945ff392
21 files changed
+1314
-60
No files matched your search
@@ -0,0 +1,288 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in new issue
Block a user