From 92bf3025c57cc73816fa02c1ee129bb1edf76de7 Mon Sep 17 00:00:00 2001 From: deedra Date: Wed, 28 Oct 2026 10:44:54 -0700 Subject: [PATCH] Fall back to the system Speech Dispatcher socket When using the default Speech Dispatcher address, try the user's Unix socket first and then /run/speech-dispatcher/speechd.sock if the user socket is missing or refuses the connection. This allows Fenrir to use an existing system service before attempting to autospawn a user daemon. Keep explicit Options.Socket, SPEECHD_ADDRESS, and SPEECHD_SOCKET settings authoritative. Do not fall back for permission errors, TCP connections, or a canceled context. If both sockets fail, preserve the user socket's error so autospawn continues to target the user's endpoint. After opening the data connection, use its selected Unix socket for the control connection and disable further fallback there. Both connections must belong to the same Speech Dispatcher server. Add tests for address overrides, user-first selection, missing and stale user sockets, permission errors, and preservation of the original error when neither socket is available. Validation: - go test ./internal/speech -count=1 (GOPROXY=off): passed - git diff --check: passed - gofmt -l on all five changed Go files: no output Live audible speech and physical keyboard behavior were not verified as part of this commit. --- internal/speech/address.go | 5 +++ internal/speech/address_test.go | 20 ++++++++++++ internal/speech/autospawn.go | 20 ++++++++++++ internal/speech/autospawn_test.go | 51 +++++++++++++++++++++++++++++++ internal/speech/client.go | 20 ++++++++---- 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/internal/speech/address.go b/internal/speech/address.go index ceeb75e4..a1d6a687 100644 --- a/internal/speech/address.go +++ b/internal/speech/address.go @@ -88,3 +88,8 @@ func parse_server_address(address string) (serverAddress, error) { return serverAddress{}, fmt.Errorf("unsupported Speech Dispatcher address %q", address) } } + +// Explicit settings select exactly one server; only defaults use fallback. +func automatic_server_address(socket string) bool { + return socket == "" && os.Getenv("SPEECHD_ADDRESS") == "" && os.Getenv("SPEECHD_SOCKET") == "" +} diff --git a/internal/speech/address_test.go b/internal/speech/address_test.go index 041c8617..fcbafea8 100644 --- a/internal/speech/address_test.go +++ b/internal/speech/address_test.go @@ -122,3 +122,23 @@ func TestDefaultSocketWithoutRuntimeOrCacheEnvironment(t *testing.T) { t.Fatalf("socket %q, want %q", got, want) } } + +func TestAutomaticServerAddressHonorsOverrides(t *testing.T) { + t.Setenv("SPEECHD_ADDRESS", "") + t.Setenv("SPEECHD_SOCKET", "") + if !automatic_server_address("") { + t.Fatal("default address disables system fallback") + } + if automatic_server_address("/custom.sock") { + t.Fatal("explicit socket allows system fallback") + } + t.Setenv("SPEECHD_SOCKET", "/legacy.sock") + if automatic_server_address("") { + t.Fatal("legacy socket allows system fallback") + } + t.Setenv("SPEECHD_ADDRESS", "unix_socket:/address.sock") + t.Setenv("SPEECHD_SOCKET", "") + if automatic_server_address("") { + t.Fatal("explicit address allows system fallback") + } +} diff --git a/internal/speech/autospawn.go b/internal/speech/autospawn.go index c6b94db5..52f14aa4 100644 --- a/internal/speech/autospawn.go +++ b/internal/speech/autospawn.go @@ -19,6 +19,26 @@ import ( type serverDialer func(context.Context, string, string) (net.Conn, error) +// Automatic connections prefer the user's server, then the system service. +// Preserve the user's endpoint error if neither server is available so that +// autospawn still targets the user's socket, never the system socket. +func dial_with_system_fallback(dial serverDialer) serverDialer { + return func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := dial(ctx, network, address) + if ctx.Err() != nil || network != "unix" || (!errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED)) { + return conn, err + } + systemConn, systemErr := dial(ctx, "unix", "/run/speech-dispatcher/speechd.sock") + if systemErr == nil { + return systemConn, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, err + } +} + // Like Python speechd, try the existing server first, then ask the daemon to // spawn according to its own configuration. Never start a local daemon for a // remote TCP endpoint, and never stop a daemon owned by the session. diff --git a/internal/speech/autospawn_test.go b/internal/speech/autospawn_test.go index 15c9bc2f..3314c297 100644 --- a/internal/speech/autospawn_test.go +++ b/internal/speech/autospawn_test.go @@ -233,3 +233,54 @@ func TestNativeSpeechDispatcherColdStart(t *testing.T) { }) } } + +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) + } + }) + } +} diff --git a/internal/speech/client.go b/internal/speech/client.go index cb34885f..3a4b3e40 100644 --- a/internal/speech/client.go +++ b/internal/speech/client.go @@ -102,19 +102,23 @@ func connect_options(options Options) (*wire, error) { if err != nil { return nil, err } - return connect_endpoint(endpoint, options.autospawn) + return connect_endpoint(endpoint, options.autospawn, automatic_server_address(options.Socket)) } -func connect_endpoint(endpoint serverAddress, autospawn bool) (*wire, error) { +func connect_endpoint(endpoint serverAddress, autospawn, systemFallback bool) (*wire, error) { ctx, cancel := context.WithTimeout(context.Background(), 3*commandTimeout) defer cancel() dialer := &net.Dialer{Timeout: commandTimeout} + dial := serverDialer(dialer.DialContext) + if systemFallback { + dial = dial_with_system_fallback(dial) + } var conn net.Conn var err error if autospawn { - conn, err = connect_with_autospawn(ctx, endpoint, dialer.DialContext, spawn_server) + conn, err = connect_with_autospawn(ctx, endpoint, dial, spawn_server) } else { - conn, err = dialer.DialContext(ctx, endpoint.network, endpoint.address) + conn, err = dial(ctx, endpoint.network, endpoint.address) } if err != nil { return nil, err @@ -170,7 +174,7 @@ func Open(options Options) (*Client, error) { if err != nil { return nil, err } - data, err := connect_endpoint(endpoint, true) + data, err := connect_endpoint(endpoint, true, automatic_server_address(options.Socket)) if err != nil { return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", endpoint.address, err) } @@ -221,7 +225,11 @@ func Open(options Options) (*Client, error) { return fail(fmt.Errorf("invalid Speech Dispatcher client ID")) } client.id = strconv.FormatUint(id, 10) - client.control, err = connect_endpoint(endpoint, false) + // Bind control to the same server selected for data, including fallback. + if endpoint.network == "unix" { + endpoint.address = data.conn.RemoteAddr().String() + } + client.control, err = connect_endpoint(endpoint, false, false) if err != nil { return fail(err) }