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) }