Files
fenrir/internal/speech/hardware_linux_test.go

383 lines
11 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build linux && cgo
package speech
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"unsafe"
)
func TestHardwarePreviewKeepsTemporaryPortUntilCanceledAndReleasesIt(t *testing.T) {
master, path := hardware_pty(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
done <- Test_hardware(ctx, "dectalk", Options{Hardware: HardwareOptions{Device: path}}, "test sample")
}()
want := "[:ra 180][:dv ap 122][:dv g5 73]test sample\x0b"
if got := string(read_hardware(t, master, len(want))); got != want {
t.Fatal(got)
}
if other, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}}); err == nil {
other.Close()
t.Fatal("preview closed before device could speak")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("preview did not cancel")
}
client, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
if err != nil {
t.Fatal("preview retained port", err)
}
client.Close()
}
func hardware_pty(t *testing.T) (*os.File, string) {
t.Helper()
fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
if err != nil {
t.Fatal(err)
}
master := os.NewFile(uintptr(fd), "hardware fixture")
t.Cleanup(func() { master.Close() })
var unlock int32
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlock))); err != 0 {
t.Fatal(err)
}
var number uint32
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); err != 0 {
t.Fatal(err)
}
return master, fmt.Sprintf("/dev/pts/%d", number)
}
func read_hardware(t *testing.T, master *os.File, count int) []byte {
t.Helper()
var result []byte
deadline := time.Now().Add(2 * time.Second)
for len(result) < count && time.Now().Before(deadline) {
buffer := make([]byte, count-len(result))
n, err := syscall.Read(int(master.Fd()), buffer)
if n > 0 {
result = append(result, buffer[:n]...)
}
if err != nil && err != syscall.EAGAIN && err != syscall.EINTR {
t.Fatal(err)
}
if n <= 0 {
time.Sleep(time.Millisecond)
}
}
if len(result) != count {
t.Fatalf("read %q (%d of %d)", result, len(result), count)
}
return result
}
func assert_hardware_idle(t *testing.T, master *os.File) {
t.Helper()
var buffer [128]byte
if n, err := syscall.Read(int(master.Fd()), buffer[:]); n > 0 || err != syscall.EAGAIN {
t.Fatalf("unexpected bytes %q, %v", buffer[:max(n, 0)], err)
}
}
func hardware_termios(t *testing.T, fd int) syscall.Termios {
t.Helper()
var value syscall.Termios
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&value))); err != 0 {
t.Fatal(err)
}
return value
}
func TestNativeSerialHardwareLifecycle(t *testing.T) {
for _, id := range []string{"dectalk", "litetalk", "doubletalk", "tripletalk"} {
t.Run(id, func(t *testing.T) {
master, path := hardware_pty(t)
slave, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
if err != nil {
t.Fatal(err)
}
defer syscall.Close(slave)
original := hardware_termios(t, slave)
options := Options{Hardware: HardwareOptions{Device: path, BaudRate: 19200}}
transport, err := Open_driver(id, options)
if err != nil {
t.Fatal(err)
}
defer transport.Close()
configured := hardware_termios(t, slave)
if configured.Cflag&syscall.CS8 == 0 || configured.Lflag&(syscall.ICANON|syscall.ECHO) != 0 || configured.Iflag&(syscall.IXOFF|syscall.IXANY) != 0 || (configured.Iflag&syscall.IXON != 0) != (id == "dectalk") {
t.Fatal("serial mode", configured)
}
assert_hardware_idle(t, master) // Opening a candidate never speaks.
if other, err := Open_driver(id, options); err == nil {
other.Close()
t.Fatal("another reader acquired an owned serial device")
}
if err := transport.Configure(Parameters{Volume: 100}); err != nil {
t.Fatal(err)
}
if err := transport.Say("Hello\nworld ☃"); err != nil {
t.Fatal(err)
}
want := "\x014S\x0150P\x019VHello world \r"
cancel := "\x18"
if id == "dectalk" {
want = "[:ra 180][:dv ap 122][:dv g5 86]Hello world \x0b"
cancel = "\x03"
}
if got := string(read_hardware(t, master, len(want))); got != want {
t.Fatalf("%q != %q", got, want)
}
if err := transport.Configure(Parameters{Volume: 100}); err != nil {
t.Fatal(err)
}
assert_hardware_idle(t, master)
if err := transport.Cancel(); err != nil {
t.Fatal(err)
}
if got := string(read_hardware(t, master, 1)); got != cancel {
t.Fatal(got)
}
transport.Close()
if restored := hardware_termios(t, slave); restored != original {
t.Fatal("termios not restored", restored, original)
}
reopened, err := Open_driver(id, options)
if err != nil {
t.Fatal("serial lock survived close", err)
}
reopened.Close()
})
}
}
func TestSerialCancellationStopsLargeWriteAndAllowsFreshSpeech(t *testing.T) {
master, path := hardware_pty(t)
transport, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
if err != nil {
t.Fatal(err)
}
defer transport.Close()
if err := transport.Configure(Parameters{}); err != nil {
t.Fatal(err)
}
read_hardware(t, master, len("[:ra 180][:dv ap 122][:dv g5 73]"))
done := make(chan error, 1)
go func() { done <- transport.Say(strings.Repeat("x", 1<<20)) }()
if string(read_hardware(t, master, 1)) != "x" {
t.Fatal("text write did not start")
}
if err := transport.Cancel(); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("cancellation waited behind speech")
}
// Discard already transmitted fixture bytes, then ensure nothing from the
// canceled utterance can appear after a fresh request.
var buffer [32768]byte
for {
if _, err := syscall.Read(int(master.Fd()), buffer[:]); err == syscall.EAGAIN {
break
} else if err != nil {
t.Fatal(err)
}
}
if err := transport.Say("fresh"); err != nil {
t.Fatal(err)
}
want := "[:ra 180][:dv ap 122][:dv g5 73]fresh\x0b"
if got := string(read_hardware(t, master, len(want))); got != want {
t.Fatalf("stale output %q", got)
}
assert_hardware_idle(t, master)
}
func TestSerialDisconnectReturnsError(t *testing.T) {
master, path := hardware_pty(t)
transport, err := Open_driver("litetalk", Options{Hardware: HardwareOptions{Device: path}})
if err != nil {
t.Fatal(err)
}
defer transport.Close()
master.Close()
if err := transport.Say("after disconnect"); err == nil {
t.Fatal("disconnected serial device accepted speech")
}
}
func TestSerialXoffPauseRemainsCancelable(t *testing.T) {
master, path := hardware_pty(t)
transport, err := Open_driver("dectalk", Options{Hardware: HardwareOptions{Device: path}})
if err != nil {
t.Fatal(err)
}
defer transport.Close()
if err := transport.Configure(Parameters{}); err != nil {
t.Fatal(err)
}
read_hardware(t, master, len("[:ra 180][:dv ap 122][:dv g5 73]"))
// Packet mode acknowledges that the terminal processed XOFF before the
// write starts; this does not rely on a sleep or on filling a PTY buffer.
packetMode := int32(1)
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCPKT, uintptr(unsafe.Pointer(&packetMode))); err != 0 {
t.Fatal(err)
}
if _, err := syscall.Write(int(master.Fd()), []byte{0x13}); err != nil {
t.Fatal(err)
}
if got := read_hardware(t, master, 1)[0]; got&syscall.TIOCPKT_STOP == 0 {
t.Fatal("XOFF was not processed", got)
}
packetMode = 0
if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, master.Fd(), syscall.TIOCPKT, uintptr(unsafe.Pointer(&packetMode))); err != 0 {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- transport.Say("paused") }()
select {
case err := <-done:
t.Fatal("normal flow pause terminated speech", err)
case <-time.After(time.Second):
}
if err := transport.Cancel(); err != nil {
t.Fatal(err)
}
slave, err := syscall.Open(path, syscall.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
t.Fatal(err)
}
configured := hardware_termios(t, slave)
syscall.Close(slave)
if configured.Iflag&syscall.IXON == 0 {
t.Fatal("cancellation did not restore XON/XOFF")
}
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("cancel failed during flow pause")
}
if got := string(read_hardware(t, master, 1)); got != "\x03" {
t.Fatal(got)
}
if err := transport.Say("resumed"); err != nil {
t.Fatal(err)
}
want := "[:ra 180][:dv ap 122][:dv g5 73]resumed\x0b"
if got := string(read_hardware(t, master, len(want))); got != want {
t.Fatal(got)
}
}
func TestUSBCancelBypassesBlockedPacketAndDiscardsRemainingText(t *testing.T) {
started, release := make(chan struct{}), make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
var normal, immediate atomic.Int32
port := &usbPort{send: func(endpoint byte, data []byte) error {
if endpoint == 0x04 {
if !bytes.Equal(data, []byte{0x06}) {
return fmt.Errorf("bad cancellation %x", data)
}
immediate.Add(1)
} else if bytes.HasPrefix(data, []byte("xxx")) {
if normal.Add(1) == 1 {
close(started)
<-release
}
}
return nil
}}
client := new_hardware_client("dectalk-usb", Options{}, port)
done := make(chan error, 1)
go func() { done <- client.Say(strings.Repeat("x", 200)) }()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("packet not started")
}
if err := client.Cancel(); err != nil {
t.Fatal(err)
}
if immediate.Load() != 1 {
t.Fatal("immediate endpoint was blocked")
}
unblock()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("stale send stuck")
}
if normal.Load() != 1 {
t.Fatal("more stale packets transmitted", normal.Load())
}
client.Close()
}
func TestUSBPacketLimitsFailureAndCleanup(t *testing.T) {
var sizes []int
closed := 0
port := &usbPort{send: func(endpoint byte, data []byte) error {
if endpoint != 2 {
t.Fatal(endpoint)
}
sizes = append(sizes, len(data))
return nil
}, close: func() { closed++ }}
if err := port.Write(make([]byte, 130), func() bool { return false }); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(sizes, []int{64, 64, 2}) {
t.Fatal(sizes)
}
failure := errors.New("short transfer")
port.send = func(byte, []byte) error { return failure }
if err := port.Write(make([]byte, 70), func() bool { return false }); !errors.Is(err, failure) {
t.Fatal(err)
}
port.Close()
port.Close()
if closed != 1 {
t.Fatal(closed)
}
if err := port.Write([]byte{1}, func() bool { return false }); err == nil {
t.Fatal("closed USB device accepted write")
}
}