Forward numeric keypad identities with Num Lock enabled and preserve held-key pairing across lock changes. Suppress local lock feedback during remote capture so it cannot interrupt incoming speech. Add debug speech and capture-end diagnostics, expand private keypad checks, and repair the suspension probe source list. Validated affected Go packages, focused remote speech race checks, the private X11 Remote input harness, and a reader build. Live physical keyboard, audible PowerShell speech, Braille, and disconnect-cause verification remain pending; the active reader session was left running.
504 lines
17 KiB
Go
504 lines
17 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
type remoteStreamCall struct {
|
|
ctx context.Context
|
|
utterance remoteSpeechUtterance
|
|
finish chan error
|
|
stopped chan struct{}
|
|
cleanup chan struct{}
|
|
}
|
|
|
|
type remoteStreamFixture struct {
|
|
incoming chan tentacle.RemoteSpeechDelivery
|
|
started chan remoteStreamCall
|
|
done chan error
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
lease tentaclewire.RemoteLease
|
|
hold bool
|
|
allowed atomic.Bool
|
|
}
|
|
|
|
func remote_stream_fixture(t *testing.T, hold bool, admission ...remoteSpeechAdmission) *remoteStreamFixture {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
f := &remoteStreamFixture{ctx: ctx, cancel: cancel, hold: hold,
|
|
incoming: make(chan tentacle.RemoteSpeechDelivery, 128), started: make(chan remoteStreamCall, 128), done: make(chan error, 1),
|
|
lease: tentaclewire.RemoteLease{Lease: strings.Repeat("a", 32), Epoch: 1}}
|
|
f.allowed.Store(true)
|
|
var admit remoteSpeechAdmission
|
|
if len(admission) != 0 {
|
|
admit = admission[0]
|
|
}
|
|
go func() {
|
|
f.done <- run_remote_speech_stream(ctx, f.incoming, func(tentacle.RemoteSpeechDelivery) bool { return f.allowed.Load() }, admit,
|
|
func(ctx context.Context, _ tentacle.RemoteSpeechDelivery, utterance remoteSpeechUtterance) error {
|
|
call := remoteStreamCall{ctx: ctx, utterance: utterance, finish: make(chan error, 1), stopped: make(chan struct{}), cleanup: make(chan struct{})}
|
|
f.started <- call
|
|
select {
|
|
case err := <-call.finish:
|
|
return err
|
|
case <-ctx.Done():
|
|
close(call.stopped)
|
|
if hold {
|
|
<-call.cleanup
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
})
|
|
}()
|
|
t.Cleanup(cancel)
|
|
return f
|
|
}
|
|
|
|
func remote_stream_wait(t *testing.T, done <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Remote stream did not reach its synchronization point")
|
|
}
|
|
}
|
|
|
|
func (f *remoteStreamFixture) send(t *testing.T, effect tentaclewire.RemoteSpeech) <-chan struct{} {
|
|
t.Helper()
|
|
return f.send_delivery(t, f.ctx, f.lease, []tentaclewire.RemoteSpeech{effect})
|
|
}
|
|
|
|
func (f *remoteStreamFixture) send_delivery(t *testing.T, ctx context.Context, lease tentaclewire.RemoteLease, frames []tentaclewire.RemoteSpeech) <-chan struct{} {
|
|
t.Helper()
|
|
released := make(chan struct{})
|
|
var calls atomic.Int32
|
|
f.incoming <- tentacle.RemoteSpeechDelivery{Context: ctx, Lease: lease, Received: time.Now(), Speeches: frames, Release: func() {
|
|
if calls.Add(1) != 1 {
|
|
t.Error("Remote stream released mailbox budget twice")
|
|
return
|
|
}
|
|
close(released)
|
|
}}
|
|
return released
|
|
}
|
|
|
|
func (f *remoteStreamFixture) speak(t *testing.T, text string, priority int) <-chan struct{} {
|
|
return f.send(t, remote_render_frame(priority, tentaclewire.RemoteSpeechPart{Text: text}))
|
|
}
|
|
|
|
func (f *remoteStreamFixture) next(t *testing.T, text string) remoteStreamCall {
|
|
t.Helper()
|
|
select {
|
|
case call := <-f.started:
|
|
if len(call.utterance.fragments) != 1 || call.utterance.fragments[0].Text != text {
|
|
t.Fatalf("unexpected Remote rendering: %#v", call.utterance)
|
|
}
|
|
return call
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Remote stream did not start rendering")
|
|
return remoteStreamCall{}
|
|
}
|
|
}
|
|
|
|
func (f *remoteStreamFixture) barrier(t *testing.T) {
|
|
t.Helper()
|
|
paused := false
|
|
remote_stream_wait(t, f.send(t, tentaclewire.RemoteSpeech{Kind: "pause", Paused: &paused}))
|
|
}
|
|
|
|
func (f *remoteStreamFixture) stop(t *testing.T) {
|
|
t.Helper()
|
|
f.cancel()
|
|
err := remote_render_result(t, f.done)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("stream shutdown = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSpeechStreamPriorityAndCleanup(t *testing.T) {
|
|
f := remote_stream_fixture(t, true)
|
|
firstRelease := f.speak(t, "active", 0)
|
|
first := f.next(t, "active")
|
|
normalRelease := f.speak(t, "normal", 0)
|
|
nextRelease := f.speak(t, "next", 1)
|
|
f.barrier(t)
|
|
select {
|
|
case <-first.stopped:
|
|
t.Fatal("NEXT interrupted the current assembly")
|
|
default:
|
|
}
|
|
nowRelease := f.speak(t, "now", 2)
|
|
remote_stream_wait(t, first.stopped)
|
|
f.barrier(t)
|
|
select {
|
|
case <-firstRelease:
|
|
t.Fatal("active budget was freed before renderer cleanup")
|
|
case <-f.started:
|
|
t.Fatal("replacement rendering overtook cleanup")
|
|
default:
|
|
}
|
|
close(first.cleanup)
|
|
now := f.next(t, "now")
|
|
remote_stream_wait(t, firstRelease)
|
|
secondNowRelease := f.speak(t, "second now", 2)
|
|
f.barrier(t)
|
|
select {
|
|
case <-now.stopped:
|
|
t.Fatal("NOW interrupted equal-priority speech")
|
|
default:
|
|
}
|
|
now.finish <- nil
|
|
second := f.next(t, "second now")
|
|
second.finish <- nil
|
|
next := f.next(t, "next")
|
|
next.finish <- nil
|
|
normal := f.next(t, "normal")
|
|
normal.finish <- nil
|
|
for _, release := range []<-chan struct{}{normalRelease, nextRelease, nowRelease, secondNowRelease} {
|
|
remote_stream_wait(t, release)
|
|
}
|
|
f.stop(t)
|
|
}
|
|
|
|
func TestRemoteSpeechStreamCancelAndPause(t *testing.T) {
|
|
for _, kind := range []string{"cancel", "pause"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
f := remote_stream_fixture(t, true)
|
|
activeRelease := f.speak(t, "active", 0)
|
|
active := f.next(t, "active")
|
|
pendingRelease := f.speak(t, "discard pending", 0)
|
|
effect := tentaclewire.RemoteSpeech{Kind: kind}
|
|
if kind == "pause" {
|
|
paused := true
|
|
effect.Paused = &paused
|
|
}
|
|
remote_stream_wait(t, f.send(t, effect))
|
|
remote_stream_wait(t, active.stopped)
|
|
remote_stream_wait(t, pendingRelease)
|
|
futureRelease := f.speak(t, "future", 0)
|
|
close(active.cleanup)
|
|
future := f.next(t, "future")
|
|
remote_stream_wait(t, activeRelease)
|
|
future.finish <- nil
|
|
remote_stream_wait(t, futureRelease)
|
|
f.stop(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteSpeechStreamFreshSpeechAfterPause(t *testing.T) {
|
|
for _, cancelFirst := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "speak", true: "cancel then speak"}[cancelFirst], func(t *testing.T) {
|
|
f := remote_stream_fixture(t, false)
|
|
paused := true
|
|
remote_stream_wait(t, f.send(t, tentaclewire.RemoteSpeech{Kind: "pause", Paused: &paused}))
|
|
if cancelFirst {
|
|
remote_stream_wait(t, f.send(t, tentaclewire.RemoteSpeech{Kind: "cancel"}))
|
|
}
|
|
released := f.speak(t, "new navigation", 0)
|
|
call := f.next(t, "new navigation")
|
|
call.finish <- nil
|
|
remote_stream_wait(t, released)
|
|
f.stop(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteSpeechStreamRevocationAndReplacement(t *testing.T) {
|
|
f := remote_stream_fixture(t, true)
|
|
leaseContext, revoke := context.WithCancel(f.ctx)
|
|
firstRelease := f.send_delivery(t, leaseContext, f.lease, []tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "old"})})
|
|
first := f.next(t, "old")
|
|
pendingRelease := f.send_delivery(t, leaseContext, f.lease, []tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "old pending"})})
|
|
f.barrier(t)
|
|
revoke()
|
|
remote_stream_wait(t, first.stopped)
|
|
remote_stream_wait(t, pendingRelease)
|
|
remote_stream_wait(t, f.send_delivery(t, leaseContext, f.lease, []tentaclewire.RemoteSpeech{remote_render_frame(2, tentaclewire.RemoteSpeechPart{Text: "stale"})}))
|
|
f.lease.Epoch++
|
|
newRelease := f.speak(t, "replacement", 0)
|
|
f.barrier(t)
|
|
close(first.cleanup)
|
|
replacement := f.next(t, "replacement")
|
|
remote_stream_wait(t, firstRelease)
|
|
replacement.finish <- nil
|
|
remote_stream_wait(t, newRelease)
|
|
f.stop(t)
|
|
}
|
|
|
|
func TestRemoteSpeechStreamEOFRetainsBudgetThroughCleanup(t *testing.T) {
|
|
f := remote_stream_fixture(t, true)
|
|
activeRelease := f.speak(t, "active", 0)
|
|
active := f.next(t, "active")
|
|
pendingRelease := f.speak(t, "pending", 0)
|
|
f.barrier(t)
|
|
close(f.incoming)
|
|
remote_stream_wait(t, active.stopped)
|
|
remote_stream_wait(t, pendingRelease)
|
|
select {
|
|
case <-activeRelease:
|
|
t.Fatal("EOF released active budget before cleanup")
|
|
case <-f.done:
|
|
t.Fatal("EOF returned before renderer cleanup")
|
|
default:
|
|
}
|
|
close(active.cleanup)
|
|
if err := remote_render_result(t, f.done); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
remote_stream_wait(t, activeRelease)
|
|
}
|
|
|
|
func TestRemoteSpeechStreamRechecksPolicyAndFailsClosed(t *testing.T) {
|
|
for _, cause := range []string{"policy", "backend", "invalid", "overflow", "bytes"} {
|
|
t.Run(cause, func(t *testing.T) {
|
|
f := remote_stream_fixture(t, false)
|
|
activeRelease := f.speak(t, "active", 0)
|
|
active := f.next(t, "active")
|
|
pendingRelease := f.speak(t, "pending", 0)
|
|
f.barrier(t)
|
|
switch cause {
|
|
case "policy":
|
|
f.allowed.Store(false)
|
|
active.finish <- nil
|
|
remote_stream_wait(t, pendingRelease)
|
|
remote_stream_wait(t, f.speak(t, "denied", 2))
|
|
f.stop(t)
|
|
case "backend":
|
|
active.finish <- errors.New("backend failure")
|
|
if err := remote_render_result(t, f.done); err == nil || errors.Is(err, context.Canceled) {
|
|
t.Fatalf("backend failure did not retire the stream: %v", err)
|
|
}
|
|
case "invalid":
|
|
remote_stream_wait(t, f.send(t, tentaclewire.RemoteSpeech{Kind: "speak"}))
|
|
// Release precedes error return; wait for the owned result.
|
|
if err := remote_render_result(t, f.done); err == nil {
|
|
t.Fatal("invalid assembly was accepted")
|
|
}
|
|
case "overflow":
|
|
for range tentaclewire.RemoteSpeechQueueSize - 2 {
|
|
f.speak(t, "bounded", 0)
|
|
}
|
|
remote_stream_wait(t, f.speak(t, "overflow", 0))
|
|
if err := remote_render_result(t, f.done); err == nil {
|
|
t.Fatal("owner exceeded retained frame budget")
|
|
}
|
|
case "bytes":
|
|
// Frame count remains below 64 while encoded bytes exceed 256 KiB.
|
|
for range 52 {
|
|
f.speak(t, strings.Repeat("x", 5000), 0)
|
|
}
|
|
remote_stream_wait(t, f.speak(t, strings.Repeat("x", 5000), 0))
|
|
if err := remote_render_result(t, f.done); err == nil {
|
|
t.Fatal("owner exceeded retained byte budget")
|
|
}
|
|
}
|
|
remote_stream_wait(t, activeRelease)
|
|
remote_stream_wait(t, pendingRelease)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteSpeechStreamIsolatedBackendCancellation(t *testing.T) {
|
|
speaker, server := isolated_voice_fixture(t)
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
incoming := make(chan tentacle.RemoteSpeechDelivery, 8)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- run_remote_speech_playback(ctx, speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return true }, nil, nil)
|
|
}()
|
|
lease := tentaclewire.RemoteLease{Lease: strings.Repeat("b", 32), Epoch: 1}
|
|
send := func(effect tentaclewire.RemoteSpeech) <-chan struct{} {
|
|
released := make(chan struct{})
|
|
incoming <- tentacle.RemoteSpeechDelivery{Context: ctx, Lease: lease, Speeches: []tentaclewire.RemoteSpeech{effect}, Release: func() { close(released) }}
|
|
return released
|
|
}
|
|
localDone := make(chan error, 1)
|
|
go func() { localDone <- speaker.SpeakAndWait(ctx, "local status", nil) }()
|
|
local := server.next_utterance(t)
|
|
remoteRelease := send(remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "remote first"}))
|
|
remote := server.next_utterance(t)
|
|
if remote.priority != "TEXT" || local.priority != "MESSAGE" {
|
|
t.Fatal("incoming speech can compete with local output priority")
|
|
}
|
|
if local.id == remote.id {
|
|
t.Fatal("stream acquired the local speaking client")
|
|
}
|
|
remote_stream_wait(t, send(tentaclewire.RemoteSpeech{Kind: "cancel"}))
|
|
remote_stream_wait(t, remoteRelease)
|
|
server.mutex.Lock()
|
|
localPending := server.connections[local.id].pending
|
|
server.mutex.Unlock()
|
|
if localPending == "" {
|
|
t.Fatal("remote cancel removed local status")
|
|
}
|
|
nextRelease := send(remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "remote after cancel"}))
|
|
next := server.next_utterance(t)
|
|
if next.text != "remote after cancel" || next.id == local.id || next.id == remote.id {
|
|
t.Fatal("cancelled renderer was reused or replayed")
|
|
}
|
|
server.end_utterance(t, next)
|
|
remote_stream_wait(t, nextRelease)
|
|
server.end_utterance(t, local)
|
|
if err := remote_render_result(t, localDone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cancel()
|
|
if err := remote_render_result(t, done); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("stream shutdown: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoteSpeechStreamEarlyBackendCompletionKeepsReading(t *testing.T) {
|
|
for _, terminal := range []int{702, 703} {
|
|
t.Run(fmt.Sprint(terminal), func(t *testing.T) {
|
|
speaker, server := isolated_voice_fixture(t)
|
|
server.mutex.Lock()
|
|
server.queryReplies = map[string]string{"SPEAK": fmt.Sprintf(
|
|
"230 OK\r\n%d-1\r\n%d-1\r\n%d DONE\r\n225-1\r\n225 QUEUED\r\n", terminal, terminal, terminal)}
|
|
server.mutex.Unlock()
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
incoming := make(chan tentacle.RemoteSpeechDelivery, 8)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- run_remote_speech_playback(ctx, speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return true }, nil, nil)
|
|
}()
|
|
lease := tentaclewire.RemoteLease{Lease: strings.Repeat("b", 32), Epoch: 1}
|
|
for _, text := range []string{"first", "second", "third"} {
|
|
released := make(chan struct{})
|
|
incoming <- tentacle.RemoteSpeechDelivery{Context: ctx, Lease: lease,
|
|
Speeches: []tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: text})},
|
|
Release: func() { close(released) }}
|
|
if got := server.next_utterance(t); got.text != text {
|
|
t.Fatalf("remote output = %q, want %q", got.text, text)
|
|
}
|
|
remote_stream_wait(t, released)
|
|
}
|
|
cancel()
|
|
if err := remote_render_result(t, done); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("stream shutdown: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRemoteSpeechStreamPolicyLossDuringAcquisition(t *testing.T) {
|
|
speaker, server := isolated_voice_fixture(t)
|
|
held, resume := make(chan struct{}), make(chan struct{})
|
|
server.mutex.Lock()
|
|
server.holdCommand, server.held, server.resume = "HISTORY GET CLIENT_ID", held, resume
|
|
server.mutex.Unlock()
|
|
// Unblock the private server even if an assertion fails.
|
|
defer func() {
|
|
select {
|
|
case <-resume:
|
|
default:
|
|
close(resume)
|
|
}
|
|
}()
|
|
var allowed atomic.Bool
|
|
allowed.Store(true)
|
|
incoming := make(chan tentacle.RemoteSpeechDelivery, 1)
|
|
released := make(chan struct{})
|
|
incoming <- tentacle.RemoteSpeechDelivery{Context: t.Context(),
|
|
Lease: tentaclewire.RemoteLease{Lease: strings.Repeat("c", 32), Epoch: 1},
|
|
Speeches: []tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: "denied after acquisition"})},
|
|
Release: func() { close(released) }}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- run_remote_speech_playback(t.Context(), speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return allowed.Load() }, nil, nil)
|
|
}()
|
|
remote_stream_wait(t, held)
|
|
allowed.Store(false)
|
|
server.mutex.Lock()
|
|
server.holdCommand = ""
|
|
server.mutex.Unlock()
|
|
close(resume)
|
|
if err := remote_render_result(t, done); err == nil {
|
|
t.Fatal("policy loss during acquisition was accepted")
|
|
}
|
|
remote_stream_wait(t, released)
|
|
select {
|
|
case <-server.queued:
|
|
t.Fatal("policy loss exposed speech to the backend")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestRemoteSpeechStreamVoiceRetiresWithOriginalLease(t *testing.T) {
|
|
for _, state := range []string{"active", "idle"} {
|
|
t.Run(state, func(t *testing.T) { test_remote_stream_lease_voice(t, state == "active") })
|
|
}
|
|
}
|
|
|
|
func test_remote_stream_lease_voice(t *testing.T, active bool) {
|
|
t.Helper()
|
|
speaker, server := isolated_voice_fixture(t)
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
leaseContext, revoke := context.WithCancel(ctx)
|
|
defer revoke()
|
|
incoming := make(chan tentacle.RemoteSpeechDelivery, 4)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- run_remote_speech_playback(ctx, speaker, incoming, func(delivery tentacle.RemoteSpeechDelivery) bool { return delivery.Context.Err() == nil }, nil, nil)
|
|
}()
|
|
send := func(lifetime context.Context, epoch uint64, text string) <-chan struct{} {
|
|
released := make(chan struct{})
|
|
incoming <- tentacle.RemoteSpeechDelivery{Context: lifetime,
|
|
Lease: tentaclewire.RemoteLease{Lease: strings.Repeat("d", 32), Epoch: epoch},
|
|
Speeches: []tentaclewire.RemoteSpeech{remote_render_frame(0, tentaclewire.RemoteSpeechPart{Text: text})},
|
|
Release: func() { close(released) }}
|
|
return released
|
|
}
|
|
firstRelease := send(leaseContext, 1, "first lease")
|
|
first := server.next_utterance(t)
|
|
if !active {
|
|
server.end_utterance(t, first)
|
|
remote_stream_wait(t, firstRelease)
|
|
}
|
|
speaker.mu.RLock()
|
|
oldVoice := speaker.remote
|
|
speaker.mu.RUnlock()
|
|
revoke()
|
|
remote_stream_wait(t, oldVoice.closed)
|
|
remote_stream_wait(t, firstRelease)
|
|
nextRelease := send(ctx, 2, "replacement lease")
|
|
next := server.next_utterance(t)
|
|
if next.id == first.id || next.text != "replacement lease" {
|
|
t.Fatal("replacement inherited the retired backend client")
|
|
}
|
|
// Repeated old cleanup cannot cancel the replacement's pending message.
|
|
if err := oldVoice.Close(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server.mutex.Lock()
|
|
pending := server.connections[next.id].pending
|
|
server.mutex.Unlock()
|
|
if pending != "replacement lease" {
|
|
t.Fatal("old lease cleanup removed replacement speech")
|
|
}
|
|
server.end_utterance(t, next)
|
|
remote_stream_wait(t, nextRelease)
|
|
cancel()
|
|
if err := remote_render_result(t, done); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("stream shutdown: %v", err)
|
|
}
|
|
}
|