Speech-dispatcher handling improved.

This commit is contained in:
Storm Dragon committed 2026-10-28 11:34:57 -04:00
1 parent 60abcc37d9
commit ec32c6e4c4
11 files changed
+630 -214

No files matched your search

-166
View File
@@ -1,166 +0,0 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
+5
View File
@@ -199,6 +199,11 @@ Speech drivers are `speech-dispatcher`, `dectalk`, `dectalk-usb`, `litetalk`,
`doubletalk`, and `tripletalk`. Serial drivers need a device path. The Speech
settings tab includes a **Test synthesizer** button for hardware drivers.
Speech Dispatcher connections support `SPEECHD_ADDRESS` (Unix socket or TCP),
`SPEECHD_SOCKET`, runtime/cache-directory defaults, and bounded daemon autostart.
See [speech synthesizers](docs/user.md#speech-synthesizers) for precedence and
sudo behavior.
Clipboard persistence is optional and off by default. It stores unencrypted
history separately for console and terminal sessions. X clipboard sync is
controlled by `clipboard.sync`.
+19
View File
@@ -369,6 +369,25 @@ See the [keyboard guide](../config/keyboard/Readme.md) for an example.
The default driver is `speech-dispatcher`. Select its module, language, and
voice in Speech settings; empty values use server defaults.
Fenrir uses `SPEECHD_ADDRESS` when set: `unix_socket:/absolute/path` selects a
Unix socket, and `inet_socket:HOST:PORT` selects TCP (default host `127.0.0.1`,
port `6560`). Invalid addresses report an error. Otherwise, `SPEECHD_SOCKET`
selects a Unix socket. Without either variable, Fenrir uses
`$XDG_RUNTIME_DIR/speech-dispatcher/speechd.sock`, falling back to
`$XDG_CACHE_HOME/speech-dispatcher/speechd.sock` or
`~/.cache/speech-dispatcher/speechd.sock` when the runtime directory is unset.
With sudo, the default remains `/run/user/$SUDO_UID/speech-dispatcher/speechd.sock`.
Sudo may remove address variables; preserve them explicitly if needed.
When opening speech, Fenrir first tries the selected server. If its socket is
missing or refuses connections, Fenrir requests `speech-dispatcher --spawn`
and briefly retries. The daemon's configuration controls whether spawning is
allowed. `SPEECHD_CMD` can select another executable. Autostart is limited to
Unix sockets and local loopback TCP servers. When sudo changes the account,
start Speech Dispatcher as the invoking user first; Fenrir will not launch a
root-owned daemon for that user. Settings discovery never starts a daemon or
interrupts the active speaking client.
Hardware driver IDs are `dectalk`, `dectalk-usb`, `litetalk`, `doubletalk`, and
`tripletalk`. Serial drivers require `presentation.speech.hardware_device`,
for example `/dev/ttyUSB0`, and default to 9600 baud when
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
type serverAddress struct {
network string
address string
}
// default_socket_path follows Python speechd, retaining Fenrir's sudo-user
// runtime directory selection for console sessions.
func default_socket_path() string {
if uid := os.Getenv("SUDO_UID"); uid != "" {
if _, err := strconv.ParseUint(uid, 10, 32); err == nil {
return filepath.Join("/run/user", uid, "speech-dispatcher/speechd.sock")
}
}
runtimeDirectory := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDirectory == "" {
runtimeDirectory = os.Getenv("XDG_CACHE_HOME")
}
if runtimeDirectory == "" {
cacheDirectory, err := os.UserCacheDir()
if err == nil {
runtimeDirectory = cacheDirectory
}
}
return filepath.Join(runtimeDirectory, "speech-dispatcher", "speechd.sock")
}
// resolve_server_address gives an internal socket override priority over speechd's
// standard address variables. SPEECHD_SOCKET is the older Unix-only setting.
func resolve_server_address(socket string) (serverAddress, error) {
if socket != "" {
return serverAddress{network: "unix", address: socket}, nil
}
if address := os.Getenv("SPEECHD_ADDRESS"); address != "" {
return parse_server_address(address)
}
if socket := os.Getenv("SPEECHD_SOCKET"); socket != "" {
return serverAddress{network: "unix", address: socket}, nil
}
return serverAddress{network: "unix", address: default_socket_path()}, nil
}
func parse_server_address(address string) (serverAddress, error) {
method, value, _ := strings.Cut(address, ":")
switch method {
case "unix_socket":
if value == "" {
value = default_socket_path()
}
if !filepath.IsAbs(value) {
return serverAddress{}, fmt.Errorf("Speech Dispatcher Unix socket must be an absolute path: %q", value)
}
return serverAddress{network: "unix", address: value}, nil
case "inet_socket":
host, port := "127.0.0.1", 6560
if value != "" {
parts := strings.Split(value, ":")
if len(parts) > 2 {
return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher address %q", address)
}
if parts[0] != "" {
host = parts[0]
}
if len(parts) == 2 {
parsed, err := strconv.Atoi(parts[1])
if err != nil || parsed < 1 || parsed > 65535 {
return serverAddress{}, fmt.Errorf("invalid Speech Dispatcher port in %q", address)
}
port = parsed
}
}
return serverAddress{network: "tcp4", address: net.JoinHostPort(host, strconv.Itoa(port))}, nil
default:
return serverAddress{}, fmt.Errorf("unsupported Speech Dispatcher address %q", address)
}
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"net"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveServerAddressPrecedence(t *testing.T) {
t.Setenv("SUDO_UID", "")
runtimeDirectory := t.TempDir()
cacheDirectory := t.TempDir()
t.Setenv("XDG_RUNTIME_DIR", runtimeDirectory)
t.Setenv("XDG_CACHE_HOME", cacheDirectory)
t.Setenv("SPEECHD_SOCKET", "/tmp/legacy-speech.sock")
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/address-speech.sock")
for _, test := range []struct {
name, override, speechAddress, legacySocket, runtimeDir, want string
}{
{"explicit", "/tmp/explicit-speech.sock", "unix_socket:/tmp/address-speech.sock", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/explicit-speech.sock"},
{"address", "", "unix_socket:/tmp/address-speech.sock", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/address-speech.sock"},
{"legacy", "", "", "/tmp/legacy-speech.sock", runtimeDirectory, "/tmp/legacy-speech.sock"},
{"runtime", "", "", "", runtimeDirectory, filepath.Join(runtimeDirectory, "speech-dispatcher", "speechd.sock")},
{"cache", "", "", "", "", filepath.Join(cacheDirectory, "speech-dispatcher", "speechd.sock")},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("SPEECHD_ADDRESS", test.speechAddress)
t.Setenv("SPEECHD_SOCKET", test.legacySocket)
t.Setenv("XDG_RUNTIME_DIR", test.runtimeDir)
got, err := resolve_server_address(test.override)
if err != nil || got.network != "unix" || got.address != test.want {
t.Fatalf("address = %+v, error = %v; want %q", got, err, test.want)
}
})
}
}
func TestParseServerAddress(t *testing.T) {
for _, test := range []struct {
input, network, address string
}{
{"unix_socket:/tmp/speech.sock", "unix", "/tmp/speech.sock"},
{"inet_socket", "tcp4", "127.0.0.1:6560"},
{"inet_socket:localhost:6561", "tcp4", "localhost:6561"},
{"inet_socket::6561", "tcp4", "127.0.0.1:6561"},
} {
got, err := parse_server_address(test.input)
if err != nil || got.network != test.network || got.address != test.address {
t.Fatalf("parse %q = %+v, %v", test.input, got, err)
}
}
for _, address := range []string{"invalid:/tmp/speech.sock", "unix_socket:relative.sock", "inet_socket:localhost:0", "inet_socket:localhost:bad"} {
if _, err := parse_server_address(address); err == nil {
t.Fatalf("accepted invalid address %q", address)
}
}
}
func TestSpeechAddressUsedByClientAndDiscovery(t *testing.T) {
for _, network := range []string{"unix", "tcp4"} {
t.Run(network, func(t *testing.T) {
var server *fakeServer
if network == "unix" {
server = new_fake_server(t, false)
} else {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
server = new_fake_server_listener(t, false, listener)
}
address := "unix_socket:" + server.path
if network == "tcp4" {
address = "inet_socket:" + server.path
}
t.Setenv("SPEECHD_ADDRESS", address)
t.Setenv("SPEECHD_SOCKET", "/absent.sock")
t.Setenv("SPEECHD_CMD", "/nonexistent/speech-dispatcher")
client, err := Prepare("speech-dispatcher", Options{Module: "fixture", Voice: "Base Voice"})
if err != nil {
t.Fatal(err)
}
defer client.Close()
if _, err := Discover(Options{}); err != nil {
t.Fatal(err)
}
if err := client.Say("connection fixture"); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "say:connection fixture")
if err := client.Cancel(); err != nil {
t.Fatal(err)
}
wait_event(t, server.events, "CANCEL 41")
})
}
}
func TestInvalidAddressDoesNotFallBack(t *testing.T) {
server := new_fake_server(t, false)
t.Setenv("SPEECHD_ADDRESS", "unsupported:address")
t.Setenv("SPEECHD_SOCKET", server.path)
if _, err := Open(Options{}); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("error = %v", err)
}
}
func TestDefaultSocketWithoutRuntimeOrCacheEnvironment(t *testing.T) {
t.Setenv("SUDO_UID", "")
t.Setenv("XDG_RUNTIME_DIR", "")
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("HOME", t.TempDir())
want := filepath.Join(os.Getenv("HOME"), ".cache", "speech-dispatcher", "speechd.sock")
if got := default_socket_path(); got != want {
t.Fatalf("socket %q, want %q", got, want)
}
}
+106
View File
@@ -0,0 +1,106 @@
// 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"
"strconv"
"strings"
"syscall"
"time"
)
type serverDialer func(context.Context, string, string) (net.Conn, error)
// 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.
func connect_with_autospawn(ctx context.Context, endpoint serverAddress, dial serverDialer, spawn func(context.Context, serverAddress) error) (net.Conn, error) {
conn, err := dial(ctx, endpoint.network, endpoint.address)
if ctx.Err() != nil {
if conn != nil {
_ = conn.Close()
}
return nil, ctx.Err()
}
if err == nil {
return conn, err
}
// Only an absent or stale endpoint warrants starting a daemon.
if !errors.Is(err, syscall.ENOENT) && !errors.Is(err, syscall.ECONNREFUSED) {
return nil, err
}
if spawnErr := spawn(ctx, endpoint); spawnErr != nil {
return nil, fmt.Errorf("%w; autostart Speech Dispatcher: %v", err, spawnErr)
}
retryContext, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
for {
conn, err = dial(retryContext, endpoint.network, endpoint.address)
if err == nil {
return conn, nil
}
timer := time.NewTimer(50 * time.Millisecond)
select {
case <-retryContext.Done():
timer.Stop()
return nil, fmt.Errorf("after Speech Dispatcher autostart: %w", err)
case <-timer.C:
}
}
}
func spawn_arguments(ctx context.Context, endpoint serverAddress) ([]string, error) {
args := []string{"--spawn"}
if endpoint.network == "unix" {
return append(args, "--communication-method", "unix_socket", "--socket-path", endpoint.address), nil
}
host, port, err := net.SplitHostPort(endpoint.address)
if err != nil {
return nil, err
}
addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(addresses) == 0 {
return nil, fmt.Errorf("no addresses for %s", host)
}
for _, address := range addresses {
if !address.IP.IsLoopback() {
return nil, fmt.Errorf("cannot autostart a remote Speech Dispatcher at %s", host)
}
}
return append(args, "--communication-method", "inet_socket", "--port", port), nil
}
func spawn_server(ctx context.Context, endpoint serverAddress) error {
// Never create a root-owned server in the invoking user's runtime directory.
if uid := os.Getenv("SUDO_UID"); uid != "" && uid != strconv.Itoa(os.Geteuid()) {
return fmt.Errorf("start Speech Dispatcher as the invoking user before running Fenrir with sudo")
}
spawnContext, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
args, err := spawn_arguments(spawnContext, endpoint)
if err != nil {
return err
}
command := os.Getenv("SPEECHD_CMD")
if command == "" {
command = "speech-dispatcher"
}
process := exec.CommandContext(spawnContext, command, args...)
process.WaitDelay = 100 * time.Millisecond
output, err := process.CombinedOutput()
if err != nil {
return fmt.Errorf("%s --spawn: %w: %s", command, err, strings.TrimSpace(string(output)))
}
return nil
}
+235
View File
@@ -0,0 +1,235 @@
// 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"
)
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 := connect_with_autospawn(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 := connect_with_autospawn(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()
_, _ = connect_with_autospawn(ctx, serverAddress{}, dial, spawn)
if starts != 1 {
t.Fatal("canceled connection started a daemon")
}
ctx, cancel = context.WithCancel(context.Background())
_, err = connect_with_autospawn(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 := spawn_arguments(context.Background(), test.endpoint)
if err != nil || !reflect.DeepEqual(args, test.want) {
t.Fatalf("arguments = %v, %v", args, err)
}
}
if _, err := spawn_arguments(context.Background(), serverAddress{"tcp4", "192.0.2.1:6560"}); err == nil {
t.Fatal("remote server allowed local autospawn")
}
}
func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) {
t.Setenv("SUDO_UID", "")
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 := spawn_server(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")
server := new_fake_server(t, false)
client, err := Open(Options{Socket: server.path})
if err != nil {
t.Fatal(err)
}
defer client.Close()
}
func TestPermissionErrorDoesNotSpawn(t *testing.T) {
starts := 0
_, err := connect_with_autospawn(context.Background(), serverAddress{},
func(context.Context, string, string) (net.Conn, error) { return nil, syscall.EACCES },
func(context.Context, serverAddress) error { starts++; return nil })
if !errors.Is(err, syscall.EACCES) || starts != 0 {
t.Fatalf("error %v, starts %d", err, starts)
}
}
func TestDiscoveryDoesNotSpawn(t *testing.T) {
marker := filepath.Join(t.TempDir(), "started")
command := filepath.Join(t.TempDir(), "fake-speech-dispatcher")
if err := os.WriteFile(command, []byte("#!/bin/sh\ntouch "+marker+"\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("SPEECHD_CMD", command)
options := Options{Socket: filepath.Join(t.TempDir(), "absent.sock")}
if _, err := Discover(options); err == nil {
t.Fatal("expected connection failure")
}
if _, err := discover_modules(options); err == nil {
t.Fatal("expected connection failure")
}
if _, err := discover_voices(options); err == nil {
t.Fatal("expected connection failure")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("discovery started a daemon: %v", err)
}
}
func TestSudoDoesNotSpawnAsAnotherUser(t *testing.T) {
t.Setenv("SUDO_UID", strconv.Itoa(os.Geteuid()+1))
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
if err == nil || !strings.Contains(err.Error(), "invoking user") {
t.Fatalf("error = %v", err)
}
}
// Run against a separate dummy-output daemon, never the user's speech server.
func TestNativeSpeechDispatcherColdStart(t *testing.T) {
if os.Getenv("FENRIR_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 \"$FENRIR_TEST_SPEECH_CONFIG\" --pid-file \"$FENRIR_TEST_SPEECH_PID\" \"$@\"\n"
if err := os.WriteFile(wrapper, []byte(script), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("FENRIR_TEST_SPEECH_CONFIG", directory)
t.Setenv("FENRIR_TEST_SPEECH_PID", pidPath)
t.Setenv("SPEECHD_CMD", wrapper)
t.Setenv("XDG_RUNTIME_DIR", directory)
t.Setenv("SPEECHD_ADDRESS", "")
t.Setenv("SPEECHD_SOCKET", "")
t.Setenv("SUDO_UID", "")
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 := default_socket_path()
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()
}
// Selection preparation must also start a cold daemon before querying
// membership, even when the saved module is no longer installed.
if staleSocket {
candidate, prepareErr := Prepare("speech-dispatcher", Options{Module: "unavailable"})
if candidate != nil {
candidate.Close()
t.Fatal("unexpected candidate")
}
if prepareErr == nil || !strings.Contains(prepareErr.Error(), `speech module "unavailable" is unavailable`) {
t.Fatalf("selection error = %v", prepareErr)
}
}
client, err := Open(Options{})
if err != nil {
t.Fatal(err)
}
defer client.Close()
if client.id == "" || client.control == nil {
t.Fatal("cold-start client did not initialize both channels")
}
})
}
}
+3 -8
View File
@@ -40,11 +40,8 @@ func Validate_selection(module, language, voice string) error {
// deadline and the entire catalog has a four-second budget. Partial failures
// retain successful modules and mark unavailable voice lists explicitly.
func Discover(options Options) (Catalog, error) {
if options.Socket == "" {
options.Socket = Default_socket()
}
result := Catalog{Voices: map[string][]Voice{}, Errors: map[string]error{}}
w, err := connect(options.Socket)
w, err := connect_options(options)
if err != nil {
return result, err
}
@@ -195,6 +192,7 @@ func Prepare(driver string, options Options) (Transport, error) {
if err = d.Availability(); err != nil {
return nil, err
}
options.autospawn = driver == "speech-dispatcher"
if options.Module != "" {
modules, err := d.Modules(options)
if err != nil {
@@ -230,10 +228,7 @@ func discover_voices(options Options) ([]Voice, error) {
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
return nil, err
}
if options.Socket == "" {
options.Socket = Default_socket()
}
w, err := connect(options.Socket)
w, err := connect_options(options)
if err != nil {
return nil, err
}
+31 -23
View File
@@ -20,10 +20,9 @@ package speech
import (
"bufio"
"context"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -93,8 +92,27 @@ func (w *wire) command(command string) ([]string, error) {
return w.reply()
}
func connect(path string) (*wire, error) {
conn, err := net.DialTimeout("unix", path, commandTimeout)
// Queries share the address resolver. Only Prepare opts into daemon startup;
// ordinary settings discovery never starts a service.
func connect_options(options Options) (*wire, error) {
endpoint, err := resolve_server_address(options.Socket)
if err != nil {
return nil, err
}
return connect_endpoint(endpoint, options.autospawn)
}
func connect_endpoint(endpoint serverAddress, autospawn bool) (*wire, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*commandTimeout)
defer cancel()
dialer := &net.Dialer{Timeout: commandTimeout}
var conn net.Conn
var err error
if autospawn {
conn, err = connect_with_autospawn(ctx, endpoint, dialer.DialContext, spawn_server)
} else {
conn, err = dialer.DialContext(ctx, endpoint.network, endpoint.address)
}
if err != nil {
return nil, err
}
@@ -110,6 +128,8 @@ type Client struct {
}
type Options struct {
// Only Prepare enables startup during its prerequisite selection queries.
autospawn bool
Socket string
Rate int
Pitch *int
@@ -131,22 +151,6 @@ func (p Parameters) Validate() error {
return nil
}
func Default_socket() string {
if value := os.Getenv("SPEECHD_ADDRESS"); strings.HasPrefix(value, "unix_socket:") {
return strings.TrimPrefix(value, "unix_socket:")
}
if uid := os.Getenv("SUDO_UID"); uid != "" {
if _, err := strconv.ParseUint(uid, 10, 32); err == nil {
return filepath.Join("/run/user", uid, "speech-dispatcher/speechd.sock")
}
}
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
if runtimeDir == "" {
runtimeDir = filepath.Join("/run/user", strconv.Itoa(os.Getuid()))
}
return filepath.Join(runtimeDir, "speech-dispatcher/speechd.sock")
}
func Open(options Options) (*Client, error) {
if options.Rate < -100 || options.Rate > 100 {
return nil, fmt.Errorf("speech rate must be between -100 and 100")
@@ -159,9 +163,13 @@ func Open(options Options) (*Client, error) {
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
return nil, err
}
data, err := connect(options.Socket)
endpoint, err := resolve_server_address(options.Socket)
if err != nil {
return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", options.Socket, err)
return nil, err
}
data, err := connect_endpoint(endpoint, true)
if err != nil {
return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", endpoint.address, err)
}
client := &Client{data: data}
fail := func(err error) (*Client, error) {
@@ -210,7 +218,7 @@ 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(options.Socket)
client.control, err = connect_endpoint(endpoint, false)
if err != nil {
return fail(err)
}
+15 -9
View File
@@ -43,14 +43,19 @@ type fakeServer struct {
func new_fake_server(t *testing.T, block bool) *fakeServer {
t.Helper()
server := &fakeServer{path: filepath.Join(t.TempDir(), "s"), events: make(chan string, 64), ack: make(chan struct{}), stop: make(chan struct{})}
if !block {
close(server.ack)
}
listener, err := net.Listen("unix", server.path)
listener, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
if err != nil {
t.Fatal(err)
}
return new_fake_server_listener(t, block, listener)
}
func new_fake_server_listener(t *testing.T, block bool, listener net.Listener) *fakeServer {
t.Helper()
server := &fakeServer{path: listener.Addr().String(), events: make(chan string, 64), ack: make(chan struct{}), stop: make(chan struct{})}
if !block {
close(server.ack)
}
server.wg.Add(1)
acceptedDone := make(chan struct{})
go func() {
@@ -274,13 +279,14 @@ func TestLiveParametersUseSpeakingClientAndRejectInvalidValues(t *testing.T) {
func TestDefaultSocketForSudo(t *testing.T) {
t.Setenv("SPEECHD_ADDRESS", "")
t.Setenv("SPEECHD_SOCKET", "")
t.Setenv("SUDO_UID", "1000")
t.Setenv("XDG_RUNTIME_DIR", "/run/user/0")
if got := Default_socket(); got != "/run/user/1000/speech-dispatcher/speechd.sock" {
t.Fatal(got)
if got, err := resolve_server_address(""); err != nil || got.address != "/run/user/1000/speech-dispatcher/speechd.sock" {
t.Fatal(got, err)
}
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
if got := Default_socket(); got != "/tmp/example-speechd.sock" {
t.Fatal(got)
if got, err := resolve_server_address(""); err != nil || got.address != "/tmp/example-speechd.sock" {
t.Fatal(got, err)
}
}
+2 -8
View File
@@ -43,9 +43,6 @@ func (d Driver) Availability() error {
var drivers = append([]Driver{{
ID: "speech-dispatcher", Label: "Speech Dispatcher",
Open: func(options Options) (Transport, error) {
if options.Socket == "" {
options.Socket = Default_socket()
}
return Open(options)
},
Modules: discover_modules,
@@ -100,12 +97,9 @@ func Open_driver(id string, options Options) (Transport, error) {
}
// discover_modules uses a separate, bounded connection. It never changes the
// active speaking client, cancels speech or starts a speech service.
// active speaking client or cancels speech. Only Prepare permits daemon startup.
func discover_modules(options Options) ([]string, error) {
if options.Socket == "" {
options.Socket = Default_socket()
}
wire, err := connect(options.Socket)
wire, err := connect_options(options)
if err != nil {
return nil, err
}