Files
cthulhu/internal/speech/serial_native_test.go

121 lines
3.4 KiB
Go

//go:build linux && cgo
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package speech
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"syscall"
"testing"
"time"
"unsafe"
)
func serial_fixture(t *testing.T) (*os.File, string) {
t.Helper()
fd, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_NONBLOCK, 0600)
if err != nil {
t.Fatal(err)
}
master := os.NewFile(uintptr(fd), "serial-test")
t.Cleanup(func() { master.Close() })
var unlocked int32
var number uint32
if _, _, code := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCSPTLCK, uintptr(unsafe.Pointer(&unlocked))); code != 0 {
t.Fatal(code)
}
if _, _, code := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), syscall.TIOCGPTN, uintptr(unsafe.Pointer(&number))); code != 0 {
t.Fatal(code)
}
return master, fmt.Sprintf("/dev/pts/%d", number)
}
func TestHardwareSerialWritesCancelsAndCloses(t *testing.T) {
for _, synth := range []string{"litetalk", "doubletalk", "tripletalk", "dectalk"} {
t.Run(synth, func(t *testing.T) {
master, device := serial_fixture(t)
client, err := NewHardware(device, synth, 9600)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { client.Close(context.Background()) })
if err := client.Speak(context.Background(), "Serial test", HardwareVoice{}); err != nil {
t.Fatal(err)
}
want := hardware_bytes(synth, "Serial test", HardwareVoice{})
master.SetReadDeadline(time.Now().Add(time.Second))
buffer := make([]byte, 256)
n, err := master.Read(buffer)
if err != nil || !bytes.Equal(buffer[:n], want) {
t.Fatalf("bytes=%q error=%v", buffer[:n], err)
}
if err := client.Cancel(context.Background()); err != nil {
t.Fatal(err)
}
n, err = master.Read(buffer)
if err != nil || !bytes.Equal(buffer[:n], []byte{0x18}) {
t.Fatalf("cancel=%q error=%v", buffer[:n], err)
}
if err := client.Close(context.Background()); err != nil {
t.Fatal(err)
}
if err := client.Speak(context.Background(), "Closed", HardwareVoice{}); err == nil {
t.Fatal("closed serial owner accepted speech")
}
})
}
if client, err := NewHardware("/dev/null", "dectalk", 9600); err == nil {
client.Close(context.Background())
t.Fatal("non-terminal device accepted")
}
}
func TestHardwareCancellationRetiresBlockedSerialWrite(t *testing.T) {
_, device := serial_fixture(t)
client, err := NewHardware(device, "litetalk", 9600)
if err != nil {
t.Fatal(err)
}
defer client.Close(context.Background())
done := make(chan error, 1)
go func() { done <- client.Speak(context.Background(), strings.Repeat("x", 1024*1024), HardwareVoice{}) }()
// Wait until the PTY output queue fills, then use the same cancellation
// path used by keyboard input. No reader drains that queue.
deadline := time.Now().Add(time.Second)
for {
client.mu.Lock()
locked := !client.ioMu.TryLock()
if !locked {
client.ioMu.Unlock()
}
client.mu.Unlock()
if locked {
break
}
if time.Now().After(deadline) {
t.Fatal("serial write did not start")
}
time.Sleep(time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := client.Cancel(ctx); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != context.Canceled {
t.Fatalf("blocked write: %v", err)
}
case <-ctx.Done():
t.Fatal("cancellation did not release serial writer")
}
}