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