Use shared Speech Dispatcher client v0.3.0
Adapt console speech and discovery to the shared SSIP transport while retaining worker recovery, queue admission, and hardware drivers. Validated race-enabled speech, application, and preferences tests and the standalone build with GOWORK=off. Private native startup checks passed; physical keyboard, audible speech, and braille acceptance remain separate live checks.
This commit is contained in:
1 parent
a941e39f60
commit
0dd8e2f9c7
12 files changed
+99
-645
No files matched your search
@@ -3,7 +3,7 @@ module git.stormux.org/storm/fenrir
|
||||
go 1.27.2
|
||||
|
||||
require (
|
||||
git.stormux.org/storm/screenreader-common v0.2.0
|
||||
git.stormux.org/storm/screenreader-common v0.3.0
|
||||
github.com/jfreymuth/pulse v0.1.3
|
||||
github.com/leonelquinteros/gotext v1.8.0
|
||||
github.com/pelletier/go-toml/v2 v2.4.3
|
||||
|
||||
@@ -2,6 +2,8 @@ git.stormux.org/storm/screenreader-common v0.1.1 h1:r/qcWUv4ODZ+XzXw1J+qe4hVnyr7
|
||||
git.stormux.org/storm/screenreader-common v0.1.1/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
|
||||
git.stormux.org/storm/screenreader-common v0.2.0 h1:PbGwyIi/nT0qwvscje4pvIIsAxDLBYwLV1eTXT36hxk=
|
||||
git.stormux.org/storm/screenreader-common v0.2.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
|
||||
git.stormux.org/storm/screenreader-common v0.3.0 h1:eWuSMf3lM8fB5RyegnsyHmdz1uP0CzPqMfFP8wonV3k=
|
||||
git.stormux.org/storm/screenreader-common v0.3.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
|
||||
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
|
||||
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
|
||||
github.com/leonelquinteros/gotext v1.8.0 h1:wwpxINWmWq3JN3WFI9BoSupBFrvAqxZTtf3qppijYkU=
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package speech
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"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 parsed, err := strconv.ParseUint(uid, 10, 32); err == nil {
|
||||
runtimeDirectory := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if !owned_directory(runtimeDirectory, uint32(parsed)) {
|
||||
runtimeDirectory = filepath.Join("/run/user", uid)
|
||||
}
|
||||
if !owned_directory(runtimeDirectory, uint32(parsed)) {
|
||||
if account, err := user.LookupId(uid); err == nil {
|
||||
runtimeDirectory = filepath.Join(account.HomeDir, ".cache")
|
||||
}
|
||||
}
|
||||
return filepath.Join(runtimeDirectory, "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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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") == ""
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
func TestResolveServerAddressPrecedence(t *testing.T) {
|
||||
@@ -34,8 +36,8 @@ func TestResolveServerAddressPrecedence(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 {
|
||||
got, err := sd.ResolveAddress(test.override)
|
||||
if err != nil || got.Network != "unix" || got.Address != test.want {
|
||||
t.Fatalf("address = %+v, error = %v; want %q", got, err, test.want)
|
||||
}
|
||||
})
|
||||
@@ -51,13 +53,13 @@ func TestParseServerAddress(t *testing.T) {
|
||||
{"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 {
|
||||
got, err := sd.ParseAddress(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 {
|
||||
if _, err := sd.ParseAddress(address); err == nil {
|
||||
t.Fatalf("accepted invalid address %q", address)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +120,7 @@ func TestDefaultSocketWithoutRuntimeOrCacheEnvironment(t *testing.T) {
|
||||
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 {
|
||||
if got := sd.DefaultSocketAddress(); got != want {
|
||||
t.Fatalf("socket %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -126,19 +128,19 @@ func TestDefaultSocketWithoutRuntimeOrCacheEnvironment(t *testing.T) {
|
||||
func TestAutomaticServerAddressHonorsOverrides(t *testing.T) {
|
||||
t.Setenv("SPEECHD_ADDRESS", "")
|
||||
t.Setenv("SPEECHD_SOCKET", "")
|
||||
if !automatic_server_address("") {
|
||||
if !sd.AutomaticAddress("") {
|
||||
t.Fatal("default address disables system fallback")
|
||||
}
|
||||
if automatic_server_address("/custom.sock") {
|
||||
if sd.AutomaticAddress("/custom.sock") {
|
||||
t.Fatal("explicit socket allows system fallback")
|
||||
}
|
||||
t.Setenv("SPEECHD_SOCKET", "/legacy.sock")
|
||||
if automatic_server_address("") {
|
||||
if sd.AutomaticAddress("") {
|
||||
t.Fatal("legacy socket allows system fallback")
|
||||
}
|
||||
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/address.sock")
|
||||
t.Setenv("SPEECHD_SOCKET", "")
|
||||
if automatic_server_address("") {
|
||||
if sd.AutomaticAddress("") {
|
||||
t.Fatal("explicit address allows system fallback")
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
// 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"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
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.
|
||||
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 {
|
||||
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...)
|
||||
if uid := os.Getenv("SUDO_UID"); uid != "" && uid != strconv.Itoa(os.Geteuid()) {
|
||||
// Drop credentials only in the child. Never run a root-owned daemon in
|
||||
// another user's session or change the reader's device privileges.
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("cannot start Speech Dispatcher as the invoking user without root privileges")
|
||||
}
|
||||
account, err := user.LookupId(uid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up invoking user for Speech Dispatcher: %w", err)
|
||||
}
|
||||
groups, err := account.GroupIds()
|
||||
if err != nil {
|
||||
return fmt.Errorf("look up invoking user's groups: %w", err)
|
||||
}
|
||||
if err := configure_sudo_spawn(process, account, groups); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func configure_sudo_spawn(process *exec.Cmd, account *user.User, groups []string) error {
|
||||
uid, uidErr := strconv.ParseUint(account.Uid, 10, 32)
|
||||
gid, gidErr := strconv.ParseUint(account.Gid, 10, 32)
|
||||
if uidErr != nil || gidErr != nil || uid == 0 || !filepath.IsAbs(account.HomeDir) {
|
||||
return fmt.Errorf("invalid invoking user for Speech Dispatcher")
|
||||
}
|
||||
credentials := &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}
|
||||
for _, group := range groups {
|
||||
id, err := strconv.ParseUint(group, 10, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid invoking user's group: %w", err)
|
||||
}
|
||||
credentials.Groups = append(credentials.Groups, uint32(id))
|
||||
}
|
||||
process.SysProcAttr = &syscall.SysProcAttr{Credential: credentials}
|
||||
process.Dir = account.HomeDir
|
||||
// Do not pass root's HOME, XDG paths, D-Bus address, or unrelated secrets
|
||||
// into a user-owned daemon. Preserve explicit audio choices and locale.
|
||||
process.Env = []string{"HOME=" + account.HomeDir, "USER=" + account.Username,
|
||||
"LOGNAME=" + account.Username, "PATH=/usr/local/bin:/usr/bin:/bin"}
|
||||
for _, entry := range os.Environ() {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
if key == "LANG" || strings.HasPrefix(key, "LC_") || key == "PULSE_SERVER" || key == "PULSE_COOKIE" {
|
||||
process.Env = append(process.Env, entry)
|
||||
}
|
||||
}
|
||||
runtimeDir := filepath.Join("/run/user", account.Uid)
|
||||
if candidate := os.Getenv("XDG_RUNTIME_DIR"); owned_directory(candidate, uint32(uid)) {
|
||||
runtimeDir = candidate
|
||||
}
|
||||
if owned_directory(runtimeDir, uint32(uid)) {
|
||||
process.Env = append(process.Env, "XDG_RUNTIME_DIR="+runtimeDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func owned_directory(path string, uid uint32) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.IsDir() {
|
||||
return false
|
||||
}
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
return ok && stat.Uid == uid
|
||||
}
|
||||
@@ -17,18 +17,20 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
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"}
|
||||
endpoint := sd.Endpoint{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 {
|
||||
got, err := sd.ConnectWithAutospawn(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++
|
||||
@@ -36,7 +38,7 @@ func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T)
|
||||
return nil, connectError
|
||||
}
|
||||
return client, nil
|
||||
}, func(_ context.Context, selected serverAddress) error {
|
||||
}, func(_ context.Context, selected sd.Endpoint) error {
|
||||
if selected != endpoint {
|
||||
t.Fatal("spawn address differs from selected server")
|
||||
}
|
||||
@@ -53,24 +55,24 @@ func TestColdSpeechConnectionAutostartsAndRetriesSelectedEndpoint(t *testing.T)
|
||||
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)
|
||||
spawn := func(context.Context, sd.Endpoint) error { starts++; return errors.New("autospawn disabled") }
|
||||
_, err := sd.ConnectWithAutospawn(context.Background(), sd.Endpoint{}, 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)
|
||||
_, _ = sd.ConnectWithAutospawn(ctx, sd.Endpoint{}, 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) {
|
||||
_, err = sd.ConnectWithAutospawn(ctx, sd.Endpoint{}, 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 })
|
||||
}, func(context.Context, sd.Endpoint) error { cancel(); return nil })
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("retry error = %v", err)
|
||||
}
|
||||
@@ -78,18 +80,18 @@ func TestAutospawnKeepsRefusalAndCancellationErrors(t *testing.T) {
|
||||
|
||||
func TestSpeechSpawnArgumentsPreserveEndpointAndRejectRemote(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
endpoint serverAddress
|
||||
endpoint sd.Endpoint
|
||||
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"}},
|
||||
{sd.Endpoint{"unix", "/tmp/custom.sock"}, []string{"--spawn", "--communication-method", "unix_socket", "--socket-path", "/tmp/custom.sock"}},
|
||||
{sd.Endpoint{"tcp4", "127.0.0.1:6561"}, []string{"--spawn", "--communication-method", "inet_socket", "--port", "6561"}},
|
||||
} {
|
||||
args, err := spawn_arguments(context.Background(), test.endpoint)
|
||||
args, err := sd.SpawnArguments(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 {
|
||||
if _, err := sd.SpawnArguments(context.Background(), sd.Endpoint{"tcp4", "192.0.2.1:6560"}); err == nil {
|
||||
t.Fatal("remote server allowed local autospawn")
|
||||
}
|
||||
}
|
||||
@@ -101,7 +103,7 @@ func TestSpeechSpawnUsesConfiguredExecutableAndReportsRefusal(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SPEECHD_CMD", path)
|
||||
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
|
||||
err := sd.SpawnServer(context.Background(), sd.Endpoint{"unix", "/tmp/custom.sock"})
|
||||
if err == nil || !strings.Contains(err.Error(), "autospawn disabled by daemon") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
@@ -119,9 +121,9 @@ func TestExistingSpeechServerDoesNotInvokeSpawn(t *testing.T) {
|
||||
|
||||
func TestPermissionErrorDoesNotSpawn(t *testing.T) {
|
||||
starts := 0
|
||||
_, err := connect_with_autospawn(context.Background(), serverAddress{},
|
||||
_, err := sd.ConnectWithAutospawn(context.Background(), sd.Endpoint{},
|
||||
func(context.Context, string, string) (net.Conn, error) { return nil, syscall.EACCES },
|
||||
func(context.Context, serverAddress) error { starts++; return nil })
|
||||
func(context.Context, sd.Endpoint) error { starts++; return nil })
|
||||
if !errors.Is(err, syscall.EACCES) || starts != 0 {
|
||||
t.Fatalf("error %v, starts %d", err, starts)
|
||||
}
|
||||
@@ -154,7 +156,7 @@ func TestUnprivilegedProcessDoesNotImpersonateSudoUser(t *testing.T) {
|
||||
t.Skip("requires an unprivileged caller")
|
||||
}
|
||||
t.Setenv("SUDO_UID", strconv.Itoa(os.Geteuid()+1))
|
||||
err := spawn_server(context.Background(), serverAddress{"unix", "/tmp/custom.sock"})
|
||||
err := sd.SpawnServer(context.Background(), sd.Endpoint{"unix", "/tmp/custom.sock"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invoking user") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
@@ -200,7 +202,7 @@ func TestNativeSpeechDispatcherColdStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
socket := default_socket_path()
|
||||
socket := sd.DefaultSocketAddress()
|
||||
if staleSocket {
|
||||
if err := os.MkdirAll(filepath.Dir(socket), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -230,7 +232,7 @@ func TestNativeSpeechDispatcherColdStart(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
if client.id == "" || client.control == nil {
|
||||
if client.client == nil {
|
||||
t.Fatal("cold-start client did not initialize both channels")
|
||||
}
|
||||
})
|
||||
@@ -269,7 +271,7 @@ func TestSystemSocketFallback(t *testing.T) {
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
got, err := dial_with_system_fallback(dial)(context.Background(), "unix", "/user/speechd.sock")
|
||||
got, err := sd.WithSystemFallback(dial)(context.Background(), "unix", "/user/speechd.sock")
|
||||
want := []string{"/user/speechd.sock"}
|
||||
if test.wantSystem {
|
||||
want = append(want, "/run/speech-dispatcher/speechd.sock")
|
||||
|
||||
+11
-25
@@ -12,9 +12,11 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
type Voice struct{ Name, Language, Variant string }
|
||||
type Voice = sd.Voice
|
||||
|
||||
type Catalog struct {
|
||||
Modules []string
|
||||
@@ -46,20 +48,11 @@ func Discover(options Options) (Catalog, error) {
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer w.close()
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
query := func(command string) ([]string, error) {
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("speech discovery timed out")
|
||||
}
|
||||
if err := w.conn.SetDeadline(min_time(deadline, time.Now().Add(commandTimeout))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w.conn, "%s\r\n", command); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w.reply()
|
||||
}
|
||||
defer w.Close()
|
||||
ctx, cancel := context.WithTimeout(option_context(options), 4*time.Second)
|
||||
defer cancel()
|
||||
query := func(command string) ([]string, error) { return w.Command(ctx, command) }
|
||||
|
||||
modules, err := query("LIST OUTPUT_MODULES")
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -97,13 +90,6 @@ func Discover(options Options) (Catalog, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func min_time(a, b time.Time) time.Time {
|
||||
if a.Before(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func parse_voices(rows []string) ([]Voice, error) {
|
||||
voices := make([]Voice, 0, len(rows))
|
||||
seen := map[string]bool{}
|
||||
@@ -239,13 +225,13 @@ func discover_voices(options Options) ([]Voice, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer w.close()
|
||||
defer w.Close()
|
||||
if options.Module != "" {
|
||||
if _, err = w.command("SET SELF OUTPUT_MODULE " + options.Module); err != nil {
|
||||
if _, err = w.Command(option_context(options), "SET SELF OUTPUT_MODULE "+options.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rows, err := w.command("LIST SYNTHESIS_VOICES")
|
||||
rows, err := w.Command(option_context(options), "LIST SYNTHESIS_VOICES")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package speech
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -52,16 +51,6 @@ func TestLargeVoiceCatalogAndSilentPreparation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBoundedRepliesAndInjection(t *testing.T) {
|
||||
for _, reply := range []string{
|
||||
"249-" + strings.Repeat("a", 5000) + "\n249 OK\n",
|
||||
strings.Repeat("249-"+strings.Repeat("a", 3000)+"\n", 3000) + "249 OK\n",
|
||||
"249-one\n250 OK\n",
|
||||
} {
|
||||
w := wire{reader: bufio.NewReaderSize(strings.NewReader(reply), 4096)}
|
||||
if _, err := w.reply(); err == nil {
|
||||
t.Fatal("accepted unbounded or malformed reply")
|
||||
}
|
||||
}
|
||||
for _, row := range []string{"Missing tabs", "name\ten\tvariant\textra", "name\ten\rCANCEL ALL", "bad\x00name\ten"} {
|
||||
if _, err := parse_voices([]string{row}); err == nil {
|
||||
t.Fatal("accepted malformed catalog row", row)
|
||||
|
||||
+40
-278
@@ -15,140 +15,24 @@
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
// Package speech implements the Speech Dispatcher SSIP boundary and admission.
|
||||
// Package speech adapts shared Speech Dispatcher transport to console output.
|
||||
package speech
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/diagnostic"
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
// Speech Dispatcher serializes some module queries with speech commands.
|
||||
// Large voice catalogs can delay acknowledgements beyond a fraction of a second.
|
||||
// Keep the wait bounded while allowing those queries to finish.
|
||||
const commandTimeout = 5 * time.Second
|
||||
|
||||
type Rejection struct{ Code string }
|
||||
|
||||
func (r *Rejection) Error() string { return "Speech Dispatcher rejected a command: " + r.Code }
|
||||
|
||||
type wire struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
stopContext func() bool
|
||||
}
|
||||
|
||||
func (w *wire) detach_context() {
|
||||
if w.stopContext != nil {
|
||||
w.stopContext()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *wire) close() {
|
||||
w.detach_context()
|
||||
_ = w.conn.Close()
|
||||
}
|
||||
|
||||
func (w *wire) reply() ([]string, error) {
|
||||
var data []string
|
||||
expected := ""
|
||||
total := 0
|
||||
for count := 0; count < 65536; count++ {
|
||||
part, err := w.reader.ReadSlice('\n')
|
||||
line := string(part)
|
||||
total += len(line)
|
||||
if total > 8<<20 {
|
||||
return nil, fmt.Errorf("SSIP reply exceeds byte limit")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if len(line) < 4 || line[3] != '-' && line[3] != ' ' {
|
||||
return nil, fmt.Errorf("malformed SSIP reply")
|
||||
}
|
||||
if _, err := strconv.Atoi(line[:3]); err != nil {
|
||||
return nil, fmt.Errorf("invalid SSIP reply code")
|
||||
}
|
||||
if expected == "" {
|
||||
expected = line[:3]
|
||||
}
|
||||
if expected != line[:3] {
|
||||
return nil, fmt.Errorf("inconsistent SSIP reply codes")
|
||||
}
|
||||
if line[3] == ' ' {
|
||||
if line[0] != '2' {
|
||||
return nil, &Rejection{Code: line[:3]}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
data = append(data, line[4:])
|
||||
}
|
||||
return nil, fmt.Errorf("SSIP reply exceeds line limit")
|
||||
}
|
||||
|
||||
func (w *wire) command(command string) ([]string, error) {
|
||||
if err := w.conn.SetDeadline(time.Now().Add(commandTimeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w.conn, "%s\r\n", command); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w.reply()
|
||||
}
|
||||
|
||||
// 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(options.context, endpoint, options.autospawn, automatic_server_address(options.Socket))
|
||||
}
|
||||
|
||||
func connect_endpoint(parent context.Context, endpoint serverAddress, autospawn, systemFallback bool) (*wire, error) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 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, dial, spawn_server)
|
||||
} else {
|
||||
conn, err = dial(ctx, endpoint.network, endpoint.address)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &wire{conn: conn, reader: bufio.NewReaderSize(conn, 4096), stopContext: context.AfterFunc(parent, func() { _ = conn.Close() })}, nil
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
data, control *wire
|
||||
id string
|
||||
sendMu sync.Mutex
|
||||
controlMu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
options Options
|
||||
}
|
||||
type Rejection = sd.Rejection
|
||||
|
||||
type Options struct {
|
||||
// Only Prepare enables startup during its prerequisite selection queries.
|
||||
@@ -175,6 +59,18 @@ func (p Parameters) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
client *sd.Client
|
||||
options Options
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func option_context(options Options) context.Context {
|
||||
if options.context != nil {
|
||||
return options.context
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
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")
|
||||
@@ -187,179 +83,45 @@ func Open(options Options) (*Client, error) {
|
||||
if err := Validate_selection(options.Module, options.Language, options.Voice); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint, err := resolve_server_address(options.Socket)
|
||||
if err != nil {
|
||||
backend := sd.NewWithSettings(options.Socket, sd.Settings{
|
||||
ClientName: "fenrir", SystemFallback: true,
|
||||
Rate: &options.Rate, Pitch: options.Pitch, Volume: options.Volume,
|
||||
OutputModule: options.Module, Language: options.Language, SynthesisVoice: options.Voice,
|
||||
Punctuation: "NONE", Capitalization: "NONE",
|
||||
})
|
||||
if err := backend.Connect(option_context(options)); err != nil {
|
||||
backend.Close(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
data, err := connect_endpoint(options.context, endpoint, true, automatic_server_address(options.Socket))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect Speech Dispatcher at %s: %w", endpoint.address, err)
|
||||
}
|
||||
client := &Client{data: data, options: options}
|
||||
fail := func(err error) (*Client, error) {
|
||||
data.close()
|
||||
if client.control != nil {
|
||||
client.control.close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
commands := []string{
|
||||
`SET SELF CLIENT_NAME "fenrir:fenrir:main"`,
|
||||
"SET SELF PRIORITY MESSAGE",
|
||||
"SET SELF PUNCTUATION NONE",
|
||||
"SET SELF CAP_LET_RECOGN NONE",
|
||||
fmt.Sprintf("SET SELF RATE %d", options.Rate),
|
||||
}
|
||||
if options.Module != "" {
|
||||
commands = append(commands, "SET SELF OUTPUT_MODULE "+options.Module)
|
||||
}
|
||||
if options.Pitch != nil {
|
||||
commands = append(commands, fmt.Sprintf("SET SELF PITCH %d", *options.Pitch))
|
||||
}
|
||||
if options.Volume != nil {
|
||||
commands = append(commands, fmt.Sprintf("SET SELF VOLUME %d", *options.Volume))
|
||||
}
|
||||
if options.Language != "" {
|
||||
commands = append(commands, "SET SELF LANGUAGE "+options.Language)
|
||||
}
|
||||
if options.Voice != "" {
|
||||
commands = append(commands, "SET SELF SYNTHESIS_VOICE "+options.Voice)
|
||||
}
|
||||
for _, command := range commands {
|
||||
if _, err := data.command(command); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
identifiers, err := data.command("HISTORY GET CLIENT_ID")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if len(identifiers) != 1 {
|
||||
return fail(fmt.Errorf("missing Speech Dispatcher client ID"))
|
||||
}
|
||||
id, err := strconv.ParseUint(identifiers[0], 10, 64)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("invalid Speech Dispatcher client ID"))
|
||||
}
|
||||
client.id = strconv.FormatUint(id, 10)
|
||||
// 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(options.context, endpoint, false, false)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if _, err := client.control.command(`SET SELF CLIENT_NAME "fenrir:fenrir:control"`); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
data.detach_context()
|
||||
client.control.detach_context()
|
||||
if options.context != nil && options.context.Err() != nil {
|
||||
client.abort()
|
||||
return nil, options.context.Err()
|
||||
}
|
||||
client.options.context = nil
|
||||
return client, nil
|
||||
options.context = nil
|
||||
return &Client{client: backend, options: options}, nil
|
||||
}
|
||||
|
||||
// Reopen reconnects only the selected server and synthesis options.
|
||||
func (c *Client) Reopen(ctx context.Context) (Transport, error) {
|
||||
options := c.options
|
||||
options.context = ctx
|
||||
client, err := Open(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
return Open(options)
|
||||
}
|
||||
|
||||
// Configure shares the speaking connection, so subsequent utterances use the
|
||||
// new values. The service worker calls this away from the input loop.
|
||||
func (c *Client) Configure(parameters Parameters) error {
|
||||
if err := parameters.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
for _, setting := range []struct {
|
||||
name string
|
||||
value int
|
||||
}{{"RATE", parameters.Rate}, {"PITCH", parameters.Pitch}, {"VOLUME", parameters.Volume}} {
|
||||
if _, err := c.data.command(fmt.Sprintf("SET SELF %s %d", setting.name, setting.value)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return c.client.Configure(context.Background(), ¶meters.Rate, ¶meters.Pitch, ¶meters.Volume)
|
||||
}
|
||||
|
||||
func (c *Client) Say(text string) error {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
started := time.Now()
|
||||
if char, size := utf8.DecodeRuneInString(text); size == len(text) && size > 0 && utf8.ValidString(text) && !unicode.IsControl(char) && !unicode.IsSpace(char) {
|
||||
// Single-letter feedback needs one command/acknowledgement, rather
|
||||
// than a SPEAK handshake followed by a separate text transaction.
|
||||
_, err := c.data.command("CHAR " + text)
|
||||
diagnostic.Timing("speech CHAR acknowledgement", started)
|
||||
return err
|
||||
}
|
||||
if _, err := c.data.command("SPEAK"); err != nil {
|
||||
return err
|
||||
}
|
||||
diagnostic.Timing("speech SPEAK acknowledgement", started)
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
text = strings.ReplaceAll(text, "\x00", "")
|
||||
lines := strings.Split(text, "\n")
|
||||
for index, line := range lines {
|
||||
if strings.HasPrefix(line, ".") {
|
||||
lines[index] = "." + line
|
||||
}
|
||||
}
|
||||
if err := c.data.conn.SetDeadline(time.Now().Add(commandTimeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(c.data.conn, "%s\r\n.\r\n", strings.Join(lines, "\r\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.data.reply()
|
||||
return err
|
||||
}
|
||||
|
||||
// Set_pitch is called by the serialized speech worker, never the input loop.
|
||||
func (c *Client) Set_pitch(pitch int) error {
|
||||
if pitch < -100 || pitch > 100 {
|
||||
return fmt.Errorf("speech pitch must be between -100 and 100")
|
||||
return c.client.Configure(context.Background(), nil, &pitch, nil)
|
||||
}
|
||||
func (c *Client) Say(text string) error {
|
||||
started := time.Now()
|
||||
defer diagnostic.Timing("speech acknowledgement", started)
|
||||
if char, size := utf8.DecodeRuneInString(text); size == len(text) && size > 0 && utf8.ValidString(text) && !unicode.IsControl(char) && !unicode.IsSpace(char) {
|
||||
return c.client.SpeakCharacter(context.Background(), text)
|
||||
}
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
_, err := c.data.command(fmt.Sprintf("SET SELF PITCH %d", pitch))
|
||||
return err
|
||||
return c.client.SpeakText(context.Background(), text)
|
||||
}
|
||||
func (c *Client) Cancel() error { return c.client.Cancel(context.Background()) }
|
||||
func (c *Client) Close() { c.closeOnce.Do(func() { c.client.Shutdown(context.Background()) }) }
|
||||
func (c *Client) abort() { c.client.Abort() }
|
||||
|
||||
// Cancel uses the independent control socket and targets only this client.
|
||||
// Service holds the dispatch barrier for the final cancellation.
|
||||
func (c *Client) Cancel() error {
|
||||
c.controlMu.Lock()
|
||||
defer c.controlMu.Unlock()
|
||||
_, err := c.control.command("CANCEL " + c.id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
// Closing the data socket releases a blocked sender before cancellation.
|
||||
c.data.close()
|
||||
c.Cancel()
|
||||
c.control.close()
|
||||
})
|
||||
}
|
||||
|
||||
// abort releases blocked initialization commands without another SSIP request.
|
||||
func (c *Client) abort() {
|
||||
_ = c.data.conn.Close()
|
||||
if c.control != nil {
|
||||
_ = c.control.conn.Close()
|
||||
}
|
||||
func connect_options(options Options) (*sd.Query, error) {
|
||||
return sd.OpenQuery(option_context(options), options.Socket, options.autospawn, true)
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
func TestSpeechAcknowledgementWaitsForBusyDispatcher(t *testing.T) {
|
||||
@@ -146,7 +148,7 @@ func (s *fakeServer) serve(conn net.Conn) {
|
||||
s.nextID++
|
||||
id := 40 + s.nextID
|
||||
s.mu.Unlock()
|
||||
fmt.Fprintf(conn, "200-%d\r\n200 OK CLIENT ID SENT\r\n", id)
|
||||
fmt.Fprintf(conn, "245-%d\r\n245 OK CLIENT ID SENT\r\n", id)
|
||||
case strings.HasPrefix(line, "CHAR "):
|
||||
s.events <- "char:" + strings.TrimPrefix(line, "CHAR ")
|
||||
fmt.Fprint(conn, "200 OK\r\n")
|
||||
@@ -304,11 +306,11 @@ func TestDefaultSocketForSudo(t *testing.T) {
|
||||
runtimeDirectory := t.TempDir()
|
||||
t.Setenv("SUDO_UID", strconv.Itoa(os.Getuid()))
|
||||
t.Setenv("XDG_RUNTIME_DIR", runtimeDirectory)
|
||||
if got, err := resolve_server_address(""); err != nil || got.address != filepath.Join(runtimeDirectory, "speech-dispatcher/speechd.sock") {
|
||||
if got, err := sd.ResolveAddress(""); err != nil || got.Address != filepath.Join(runtimeDirectory, "speech-dispatcher/speechd.sock") {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
t.Setenv("SPEECHD_ADDRESS", "unix_socket:/tmp/example-speechd.sock")
|
||||
if got, err := resolve_server_address(""); err != nil || got.address != "/tmp/example-speechd.sock" {
|
||||
if got, err := sd.ResolveAddress(""); err != nil || got.Address != "/tmp/example-speechd.sock" {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ func discover_modules(options Options) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer wire.close()
|
||||
modules, err := wire.command("LIST OUTPUT_MODULES")
|
||||
defer wire.Close()
|
||||
modules, err := wire.Command(option_context(options), "LIST OUTPUT_MODULES")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/fenrir/internal/audio"
|
||||
sd "git.stormux.org/storm/screenreader-common/speechdispatcher"
|
||||
)
|
||||
|
||||
type disconnectedTransport struct {
|
||||
@@ -180,7 +181,7 @@ func TestSudoSpawnDropsChildCredentialsAndFiltersRootEnvironment(t *testing.T) {
|
||||
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 {
|
||||
if err := sd.ConfigureSudoSpawn(command, account, []string{"2001", "2002"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := &syscall.Credential{Uid: 2000, Gid: 2001, Groups: []uint32{2001, 2002}}
|
||||
@@ -192,7 +193,7 @@ func TestSudoSpawnDropsChildCredentialsAndFiltersRootEnvironment(t *testing.T) {
|
||||
t.Fatal(environment)
|
||||
}
|
||||
account.Uid = "0"
|
||||
if err := configure_sudo_spawn(command, account, nil); err == nil {
|
||||
if err := sd.ConfigureSudoSpawn(command, account, nil); err == nil {
|
||||
t.Fatal("invalid invoking user selected root")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user