Enable isolated Remote speech and protect reading exports
Attach incoming software speech to reader sessions with lease checks, local output priority and isolated cancellation. Preserve every document fragment source for privacy checks and export reviewed browse current-line reading, spelling and phonetic output. Verified focused reading and Remote race tests, complete review and external Remote race suites, private relay integration, speech regressions, affected-package vet, reader build and Tentacles make check. Audible speech, physical input/Braille and live interoperability remain unverified.
This commit is contained in:
1 parent
ac67236eca
commit
49be8309fe
27 files changed
+986
-54
No files matched your search
@@ -4,6 +4,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
@@ -43,6 +44,18 @@ func (handler *eventReviewHandler) present_current_line(result review.Result) {
|
||||
}
|
||||
request := speechRequest{Text: "Could not read current line.", Role: "system", Cause: "current-line"}
|
||||
if result.Err == nil {
|
||||
// Native focused-line reads do not carry complete ancestry. Only
|
||||
// browse reports with captured source metadata opt into observation.
|
||||
if result.CurrentLineBrowse {
|
||||
sources := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: result.DocumentPosition.Snapshot}},
|
||||
SourceContexts: [][]atspi.Snapshot{result.DocumentCaretContext}}
|
||||
request.origin = document_line_speech_origin(sources)
|
||||
if result.DocumentCaretLine != nil {
|
||||
if origin := document_line_speech_origin(*result.DocumentCaretLine); origin != speechOriginLocal {
|
||||
request.origin = origin
|
||||
}
|
||||
}
|
||||
}
|
||||
text := result.DocumentCaretText
|
||||
request.Text = presentation.FlatReviewText(text, false)
|
||||
request.Role = result.DocumentPosition.Snapshot.Role
|
||||
|
||||
@@ -221,6 +221,11 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
|
||||
text = "blank"
|
||||
}
|
||||
request := speechRequest{origin: document_speech_origin(result.DocumentPosition.Snapshot, result.DocumentCaretContext), Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"}
|
||||
if result.DocumentCaretLine != nil {
|
||||
if origin := document_line_speech_origin(*result.DocumentCaretLine); origin != speechOriginLocal {
|
||||
request.origin = origin
|
||||
}
|
||||
}
|
||||
request.PhoneticHint = result.DocumentCaretCommand == review.CaretNextCharacter || result.DocumentCaretCommand == review.CaretPreviousCharacter
|
||||
request.Context = result.DocumentCaretContext
|
||||
if len(request.Context) > 0 && request.Context[0].ID() == result.DocumentPosition.Snapshot.ID() {
|
||||
|
||||
@@ -18,13 +18,32 @@ type remoteSpeechStreamItem struct {
|
||||
delivery tentacle.RemoteSpeechDelivery
|
||||
utterance remoteSpeechUtterance
|
||||
bytes int
|
||||
context context.Context
|
||||
cleanup func()
|
||||
current func() bool
|
||||
}
|
||||
|
||||
type remoteSpeechPermit struct {
|
||||
context context.Context
|
||||
current func() bool // Synchronous generation check, before cancellation callbacks run.
|
||||
release func()
|
||||
}
|
||||
|
||||
type remoteSpeechAdmission func(tentacle.RemoteSpeechDelivery) (remoteSpeechPermit, bool)
|
||||
|
||||
type remoteSpeechStreamError struct {
|
||||
lease tentaclewire.RemoteLease
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *remoteSpeechStreamError) Error() string { return err.cause.Error() }
|
||||
func (err *remoteSpeechStreamError) Unwrap() error { return err.cause }
|
||||
|
||||
// run_remote_speech_playback supplies the isolated backend to the stream owner.
|
||||
// It is deliberately not registered with production sessions yet: the caller
|
||||
// still needs the reader's local-priority and delivery-time policy integration.
|
||||
// The caller supplies original-lease policy and optional local-output admission.
|
||||
func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker,
|
||||
incoming <-chan tentacle.RemoteSpeechDelivery, allow func(tentacle.RemoteSpeechDelivery) bool) error {
|
||||
incoming <-chan tentacle.RemoteSpeechDelivery, allow func(tentacle.RemoteSpeechDelivery) bool,
|
||||
admit remoteSpeechAdmission) error {
|
||||
if speaker == nil || allow == nil {
|
||||
return errors.New("Remote speech playback requires a backend and delivery policy")
|
||||
}
|
||||
@@ -42,7 +61,7 @@ func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker,
|
||||
}
|
||||
}
|
||||
defer closeVoice()
|
||||
return run_remote_speech_stream(ctx, incoming, allow, func(request context.Context, delivery tentacle.RemoteSpeechDelivery, utterance remoteSpeechUtterance) error {
|
||||
return run_remote_speech_stream(ctx, incoming, allow, admit, func(request context.Context, delivery tentacle.RemoteSpeechDelivery, utterance remoteSpeechUtterance) error {
|
||||
if voice == nil || voice.ctx.Err() != nil || voiceLease != delivery.Lease {
|
||||
closeVoice()
|
||||
var err error
|
||||
@@ -72,7 +91,7 @@ func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker,
|
||||
})
|
||||
}
|
||||
|
||||
// run_remote_speech_stream is an unwired reader-owner prerequisite. allow must
|
||||
// run_remote_speech_stream retains complete deliveries until cleanup. allow must
|
||||
// recheck original lease, focus and output policy without backend I/O. render
|
||||
// must honor cancellation, recheck policy after acquisition, and complete scoped
|
||||
// backend cleanup before returning. Neither callback may observe this output.
|
||||
@@ -80,6 +99,7 @@ func run_remote_speech_playback(ctx context.Context, speaker *configuredSpeaker,
|
||||
// until completion/discard, including while a cancelled renderer is closing.
|
||||
func run_remote_speech_stream(ctx context.Context, incoming <-chan tentacle.RemoteSpeechDelivery,
|
||||
allow func(tentacle.RemoteSpeechDelivery) bool,
|
||||
admit remoteSpeechAdmission,
|
||||
render func(context.Context, tentacle.RemoteSpeechDelivery, remoteSpeechUtterance) error) error {
|
||||
if allow == nil || render == nil {
|
||||
return errors.New("Remote speech owner requires delivery policy and renderer")
|
||||
@@ -97,6 +117,7 @@ func run_remote_speech_stream(ctx context.Context, incoming <-chan tentacle.Remo
|
||||
return ctx.Err() == nil && delivery.Context != nil && delivery.Context.Err() == nil && allow(delivery)
|
||||
}
|
||||
release := func(item *remoteSpeechStreamItem) {
|
||||
item.cleanup()
|
||||
items -= len(item.delivery.Speeches)
|
||||
bytes -= item.bytes
|
||||
item.delivery.Release()
|
||||
@@ -128,11 +149,11 @@ func run_remote_speech_stream(ctx context.Context, incoming <-chan tentacle.Remo
|
||||
item := pending[0]
|
||||
pending[0] = nil
|
||||
pending = pending[1:]
|
||||
if !valid(item.delivery) {
|
||||
if !valid(item.delivery) || item.context.Err() != nil || !item.current() {
|
||||
release(item)
|
||||
continue
|
||||
}
|
||||
request, stop := context.WithCancel(item.delivery.Context)
|
||||
request, stop := context.WithCancel(item.context)
|
||||
cancel, active, activeContext, finished = stop, item, request, make(chan error, 1)
|
||||
go func(result chan<- error) { result <- render(request, item.delivery, item.utterance) }(finished)
|
||||
}
|
||||
@@ -145,12 +166,13 @@ func run_remote_speech_stream(ctx context.Context, incoming <-chan tentacle.Remo
|
||||
case err := <-finished:
|
||||
// A deliberate stream interruption is not a backend failure. Never
|
||||
// retry its suffix; the renderer may have spoken a prefix already.
|
||||
interrupted := activeContext.Err() != nil
|
||||
interrupted := activeContext.Err() != nil || !active.current()
|
||||
failedLease := active.delivery.Lease
|
||||
cancel()
|
||||
release(active)
|
||||
active, cancel, finished = nil, nil, nil
|
||||
if err != nil && !interrupted {
|
||||
return errors.New("Remote speech renderer failed")
|
||||
if err != nil && !interrupted && !errors.Is(err, context.Canceled) {
|
||||
return &remoteSpeechStreamError{lease: failedLease, cause: errors.New("Remote speech renderer failed")}
|
||||
}
|
||||
case delivery, ok := <-incoming:
|
||||
if !ok {
|
||||
@@ -183,20 +205,30 @@ func run_remote_speech_stream(ctx context.Context, incoming <-chan tentacle.Remo
|
||||
utterance, err := prepare_remote_utterance(delivery.Speeches)
|
||||
if err != nil {
|
||||
delivery.Release()
|
||||
return err
|
||||
return &remoteSpeechStreamError{lease: delivery.Lease, cause: err}
|
||||
}
|
||||
if paused {
|
||||
delivery.Release()
|
||||
continue
|
||||
}
|
||||
item := &remoteSpeechStreamItem{delivery: delivery, utterance: utterance}
|
||||
permit := remoteSpeechPermit{context: delivery.Context, release: func() {}, current: func() bool { return true }}
|
||||
if admit != nil {
|
||||
admitted, ok := admit(delivery)
|
||||
if !ok {
|
||||
delivery.Release()
|
||||
continue
|
||||
}
|
||||
permit = admitted
|
||||
}
|
||||
item := &remoteSpeechStreamItem{delivery: delivery, utterance: utterance, context: permit.context, cleanup: permit.release, current: permit.current}
|
||||
for _, frame := range delivery.Speeches {
|
||||
encoded, _ := json.Marshal(frame)
|
||||
item.bytes += len(encoded)
|
||||
}
|
||||
if items+len(delivery.Speeches) > tentaclewire.RemoteSpeechQueueSize || bytes+item.bytes > tentaclewire.RemoteSpeechQueueBytes {
|
||||
permit.release()
|
||||
delivery.Release()
|
||||
return errors.New("Remote speech owner budget exceeded")
|
||||
return &remoteSpeechStreamError{lease: delivery.Lease, cause: errors.New("Remote speech owner budget exceeded")}
|
||||
}
|
||||
items += len(delivery.Speeches)
|
||||
bytes += item.bytes
|
||||
|
||||
@@ -35,15 +35,19 @@ type remoteStreamFixture struct {
|
||||
allowed atomic.Bool
|
||||
}
|
||||
|
||||
func remote_stream_fixture(t *testing.T, hold bool) *remoteStreamFixture {
|
||||
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() },
|
||||
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
|
||||
@@ -81,7 +85,7 @@ func (f *remoteStreamFixture) send_delivery(t *testing.T, ctx context.Context, l
|
||||
t.Helper()
|
||||
released := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
f.incoming <- tentacle.RemoteSpeechDelivery{Context: ctx, Lease: lease, Speeches: frames, Release: func() {
|
||||
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
|
||||
@@ -301,7 +305,7 @@ func TestRemoteSpeechStreamIsolatedBackendCancellation(t *testing.T) {
|
||||
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 })
|
||||
done <- run_remote_speech_playback(ctx, speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return true }, nil)
|
||||
}()
|
||||
lease := tentaclewire.RemoteLease{Lease: strings.Repeat("b", 32), Epoch: 1}
|
||||
send := func(effect tentaclewire.RemoteSpeech) <-chan struct{} {
|
||||
@@ -314,6 +318,9 @@ func TestRemoteSpeechStreamIsolatedBackendCancellation(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
@@ -366,7 +373,7 @@ func TestRemoteSpeechStreamPolicyLossDuringAcquisition(t *testing.T) {
|
||||
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() })
|
||||
done <- run_remote_speech_playback(t.Context(), speaker, incoming, func(tentacle.RemoteSpeechDelivery) bool { return allowed.Load() }, nil)
|
||||
}()
|
||||
remote_stream_wait(t, held)
|
||||
allowed.Store(false)
|
||||
@@ -401,7 +408,7 @@ func test_remote_stream_lease_voice(t *testing.T, active bool) {
|
||||
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 })
|
||||
done <- run_remote_speech_playback(ctx, speaker, incoming, func(delivery tentacle.RemoteSpeechDelivery) bool { return delivery.Context.Err() == nil }, nil)
|
||||
}()
|
||||
send := func(lifetime context.Context, epoch uint64, text string) <-chan struct{} {
|
||||
released := make(chan struct{})
|
||||
|
||||
@@ -60,6 +60,10 @@ func (speaker *configuredSpeaker) open_remote_voice(ctx context.Context) (*remot
|
||||
if err := client.Connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := client.SetBackgroundPriority(ctx); err != nil {
|
||||
_ = client.Close(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
_ = client.Close(context.Background())
|
||||
return nil, err
|
||||
|
||||
@@ -25,6 +25,7 @@ type isolatedVoiceConnection struct {
|
||||
write sync.Mutex
|
||||
// The following fields belong to isolatedVoiceServer.mutex.
|
||||
module, pending string
|
||||
priority string
|
||||
notifications bool
|
||||
language, spelling, ssml string
|
||||
voiceType, synthesis string
|
||||
@@ -34,6 +35,7 @@ type isolatedVoiceUtterance struct {
|
||||
id int
|
||||
text, language, spelling, ssml string
|
||||
module, voiceType, synthesis string
|
||||
priority string
|
||||
}
|
||||
|
||||
func (connection *isolatedVoiceConnection) send(message string) {
|
||||
@@ -100,6 +102,8 @@ func isolated_voice_fixture(t *testing.T) (*configuredSpeaker, *isolatedVoiceSer
|
||||
response = "251-" + connection.module + "\r\n251 OK\r\n"
|
||||
case command == "GET VOICE_TYPE":
|
||||
response = "251-" + connection.voiceType + "\r\n251 OK\r\n"
|
||||
case strings.HasPrefix(command, "SET SELF PRIORITY "):
|
||||
connection.priority = strings.TrimPrefix(command, "SET SELF PRIORITY ")
|
||||
case strings.HasPrefix(command, "SET SELF LANGUAGE "):
|
||||
connection.language = strings.TrimPrefix(command, "SET SELF LANGUAGE ")
|
||||
// Server LanguageDefaultModule mappings must not silently
|
||||
@@ -161,7 +165,7 @@ func isolated_voice_fixture(t *testing.T) (*configuredSpeaker, *isolatedVoiceSer
|
||||
}
|
||||
server.mutex.Lock()
|
||||
connection.pending = strings.Join(lines, "\n")
|
||||
queued = &isolatedVoiceUtterance{id: id, text: connection.pending, language: connection.language, spelling: connection.spelling, ssml: connection.ssml, module: connection.module, voiceType: connection.voiceType, synthesis: connection.synthesis}
|
||||
queued = &isolatedVoiceUtterance{id: id, text: connection.pending, language: connection.language, spelling: connection.spelling, ssml: connection.ssml, module: connection.module, voiceType: connection.voiceType, synthesis: connection.synthesis, priority: connection.priority}
|
||||
response = "230 OK\r\n225 QUEUED\r\n"
|
||||
if connection.notifications {
|
||||
response += fmt.Sprintf("701-1\r\n701-%d\r\n701 BEGIN\r\n", id)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
)
|
||||
|
||||
func current_line_observation_request(t *testing.T, result review.Result) speechRequest {
|
||||
t.Helper()
|
||||
focus := atspi.NewObjectID(":1.42", "/focus")
|
||||
worker, err := review.NewWorker(&fakeAdapter{}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.SyncFocus(focus)
|
||||
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 1), spoken: make(chan string, 1)}
|
||||
handler := eventReviewHandler{objectReview: worker, speech: queue, focusGate: &focusGate{},
|
||||
browser: &browserModeController{ready: true, mode: review.BrowseMode, focus: focus,
|
||||
document: result.DocumentPosition.Document.ID()}}
|
||||
result.DocumentFocus = focus
|
||||
handler.present_current_line(result)
|
||||
select {
|
||||
case request := <-queue.requests:
|
||||
return request
|
||||
default:
|
||||
t.Fatal("current-line producer did not enqueue speech")
|
||||
return speechRequest{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCurrentLineSourcePrivacy(t *testing.T) {
|
||||
for _, style := range []review.FlatSpeech{review.FlatSpeak, review.FlatSpell, review.FlatPhonetic} {
|
||||
for _, source := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "unknown-ancestor", "missing-context", "ordinary-neighbor", "neighbor", "missing-neighbor", "native", "failure"} {
|
||||
t.Run(fmt.Sprintf("%s/%d", source, style), func(t *testing.T) {
|
||||
snapshot := atspi.Snapshot{Role: "static"}
|
||||
ancestors := []atspi.Snapshot{snapshot, {Role: "paragraph"}, {Role: "document web"}}
|
||||
result := review.Result{CurrentLineBrowse: true, FlatSpeech: style, DocumentCaretText: "book",
|
||||
DocumentPosition: review.DocumentPosition{Snapshot: snapshot}, DocumentCaretContext: ancestors}
|
||||
switch source {
|
||||
case "password":
|
||||
result.DocumentPosition.Snapshot.Role = "password text"
|
||||
case "ancestor":
|
||||
ancestors[1].Role = "password text"
|
||||
case "snapshot-ancestor":
|
||||
result.DocumentPosition.Snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}}
|
||||
case "unknown":
|
||||
result.DocumentPosition.Snapshot.Role = ""
|
||||
case "unknown-ancestor":
|
||||
ancestors[1].Role = ""
|
||||
case "missing-context":
|
||||
result.DocumentCaretContext = nil
|
||||
case "ordinary-neighbor", "neighbor", "missing-neighbor":
|
||||
neighbor := []atspi.Snapshot{{Role: "static"}, {Role: "password text"}}
|
||||
if source == "ordinary-neighbor" {
|
||||
neighbor[1].Role = "paragraph"
|
||||
} else if source == "missing-neighbor" {
|
||||
neighbor = nil
|
||||
}
|
||||
result.DocumentCaretLine = &atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: snapshot, Text: "book"}},
|
||||
SourceContexts: [][]atspi.Snapshot{ancestors, neighbor}}
|
||||
case "native":
|
||||
result.CurrentLineBrowse = false
|
||||
case "failure":
|
||||
result.Err = errors.New("fixture unavailable")
|
||||
}
|
||||
request := current_line_observation_request(t, result)
|
||||
if exportable_speech(request) != (source == "ordinary" || source == "ordinary-neighbor") {
|
||||
t.Fatalf("source %s produced exportable=%v", source, exportable_speech(request))
|
||||
}
|
||||
if source != "failure" && request.Text != "book" {
|
||||
t.Fatal("privacy classification changed local text")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCurrentLineResolvedOutput(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
||||
for _, probe := range []struct {
|
||||
text string
|
||||
style review.FlatSpeech
|
||||
want []string
|
||||
characters bool
|
||||
}{
|
||||
{"Current colour.", review.FlatSpeak, []string{"Current color."}, false},
|
||||
{"book", review.FlatSpell, []string{"b", "o", "o", "k"}, true},
|
||||
{"book", review.FlatPhonetic, []string{"bravo", "oscar", "oscar", "kilo"}, false},
|
||||
{"\n", review.FlatSpeak, []string{"blank"}, false},
|
||||
} {
|
||||
source := atspi.Snapshot{Role: "static"}
|
||||
result := review.Result{CurrentLineBrowse: true, FlatSpeech: probe.style, DocumentCaretText: probe.text,
|
||||
DocumentPosition: review.DocumentPosition{Snapshot: source},
|
||||
DocumentCaretContext: []atspi.Snapshot{source, {Role: "paragraph"}, {Role: "document web"}}}
|
||||
if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range probe.want {
|
||||
value := observed_speech(t, session)
|
||||
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want ||
|
||||
value.Parts[0].Characters != probe.characters || !value.Parts[1].End {
|
||||
t.Fatal("current-line delivery lost resolved text, repetition or character state", value)
|
||||
}
|
||||
if got := <-worker.speaker.(*observationSpeaker).spoken; got != want {
|
||||
t.Fatalf("local reading %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
// A sensitive ancestor must suppress every spelling/phonetic part,
|
||||
// while the complete local reading remains available.
|
||||
result.DocumentCaretContext[1].Role = "password text"
|
||||
if err := worker.EnqueueAndWait(t.Context(), current_line_observation_request(t, result)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if value := observed_speech(t, session); value.Kind != "cancel" {
|
||||
t.Fatal("sensitive current-line output reached the child", value)
|
||||
}
|
||||
for _, want := range probe.want {
|
||||
if got := <-worker.speaker.(*observationSpeaker).spoken; got != want {
|
||||
t.Fatalf("private local reading %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ func document_observation_request(t *testing.T, kind string, result review.Resul
|
||||
presentDocumentResult(logger, queue, result, options)
|
||||
case "read-all":
|
||||
presentReadAllResult(logger, queue, result, options)
|
||||
case "current-line":
|
||||
result.CurrentLineBrowse = true
|
||||
return current_line_observation_request(t, result)
|
||||
}
|
||||
select {
|
||||
case request := <-queue.requests:
|
||||
@@ -84,6 +87,101 @@ func TestRemoteDocumentSpeechSourcePrivacy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteDocumentSpeechFragmentPrivacy(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
for _, kind := range []string{"caret", "read-all"} {
|
||||
for _, source := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor", "unknown", "missing"} {
|
||||
t.Run(kind+"/"+source, func(t *testing.T) {
|
||||
caret := atspi.Snapshot{Path: "/caret", Role: "static"}
|
||||
neighbor := atspi.Snapshot{Path: "/neighbor", Role: "static"}
|
||||
context := []atspi.Snapshot{neighbor, {Role: "section"}, {Role: "document web"}}
|
||||
switch source {
|
||||
case "password":
|
||||
context[0].Role = "password text"
|
||||
case "ancestor":
|
||||
context[1].Role = "password text"
|
||||
case "snapshot-ancestor":
|
||||
context[0].SpeechContext = []atspi.Snapshot{{Role: "password text"}}
|
||||
case "unknown":
|
||||
context[0].Role = ""
|
||||
case "missing":
|
||||
context = nil
|
||||
}
|
||||
// The presentation has already flattened two fragments to one
|
||||
// string. The caret and its ancestors are ordinary in every case.
|
||||
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: caret, Text: "Joined colour."}},
|
||||
SourceContexts: [][]atspi.Snapshot{{caret, {Role: "document web"}}, context}}
|
||||
result := review.Result{Moved: true, DocumentCaretCommand: review.CaretNextLine,
|
||||
DocumentPosition: review.DocumentPosition{Snapshot: caret}, DocumentCaretText: "Joined colour.",
|
||||
DocumentCaretContext: []atspi.Snapshot{caret, {Role: "document web"}}, DocumentCaretLine: &line,
|
||||
ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line}}
|
||||
request := document_observation_request(t, kind, result)
|
||||
if exportable_speech(request) != (source == "ordinary") {
|
||||
t.Fatalf("fragment %s produced exportable=%v", source, exportable_speech(request))
|
||||
}
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source == "ordinary" {
|
||||
if value := observed_speech(t, session); value.Kind != "speak" || value.Parts[0].Text != "Joined colour." {
|
||||
t.Fatal("ordinary fragmented reading was not exported", value)
|
||||
}
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if value := observed_speech(t, session); value.Kind != "cancel" {
|
||||
t.Fatal("fragment privacy failed before cancellation", value)
|
||||
}
|
||||
select {
|
||||
case text := <-worker.speaker.(*observationSpeaker).spoken:
|
||||
if text != "Joined colour." {
|
||||
t.Fatal("source classification changed local reading", text)
|
||||
}
|
||||
default:
|
||||
t.Fatal("private fragmented reading did not reach local speech")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteDocumentSpeechCapturedNeighborPrivacy(t *testing.T) {
|
||||
id := func(path string) atspi.ObjectID { return atspi.NewObjectID(":1.42", path) }
|
||||
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
|
||||
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: ":1.42", Path: path, Role: role}, Children: children}
|
||||
}
|
||||
public := node("/public", "static")
|
||||
private := node("/private", "static")
|
||||
for index, leaf := range []*atspi.TreeNode{&public, &private} {
|
||||
text := []string{"Public text.", "Private text."}[index]
|
||||
bounds := atspi.Rectangle{X: int32(index * 100), Y: 10, Width: 100, Height: 20}
|
||||
leaf.Geometry = atspi.Geometry{Bounds: bounds, BoundsKnown: true, VisibilityKnown: true, Visible: true, Showing: true}
|
||||
leaf.Text = atspi.VisibleText{Known: true, Ranges: []atspi.TextRange{{Start: 0, End: len(text), Text: text, Bounds: bounds}}}
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(id("/doc"), []atspi.TreeNode{
|
||||
node("/doc", "document web", id("/public"), id("/password")), public,
|
||||
node("/password", "password text", id("/private")), private,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reference, _ := tree.Reference(id("/public"))
|
||||
line, err := tree.DocumentLineAtOffset(t.Context(), reference, 0)
|
||||
if err != nil || len(line.Contents) != 2 {
|
||||
t.Fatal("fixture did not assemble both visible sources", err)
|
||||
}
|
||||
// Only the neighbor has a password ancestor; the caret's line context
|
||||
// cannot classify it. Exercise the real tree-to-presentation handoff.
|
||||
for _, ancestor := range line.Context {
|
||||
if ancestor.Role == "password text" {
|
||||
t.Fatal("fixture caret has a password ancestor")
|
||||
}
|
||||
}
|
||||
request := document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line}})
|
||||
if len(request.ReadAll) == 0 || exportable_speech(request) {
|
||||
t.Fatal("captured neighbor ancestry was lost before speech admission")
|
||||
}
|
||||
}
|
||||
|
||||
func (speaker *observationSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
|
||||
if began != nil {
|
||||
began()
|
||||
|
||||
@@ -49,6 +49,7 @@ func (worker *speechWorker) SetEnabled(enabled bool) {
|
||||
}
|
||||
|
||||
func (worker *speechWorker) interruptLocked() {
|
||||
worker.retire_remote_speech_locked()
|
||||
if worker.hintCancel != nil {
|
||||
worker.hintCancel()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,16 @@ func document_line_speech_origin(line atspi.DocumentLine) speechOrigin {
|
||||
if len(line.Contents) == 0 {
|
||||
return speechOriginUnknown
|
||||
}
|
||||
for _, sources := range line.SourceContexts {
|
||||
if len(sources) == 0 {
|
||||
return speechOriginUnknown
|
||||
}
|
||||
for _, source := range sources {
|
||||
if origin := document_speech_origin(source, nil); origin != speechOriginLocal {
|
||||
return origin
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, content := range line.Contents {
|
||||
if origin := document_speech_origin(content.Snapshot, line.Context); origin != speechOriginLocal {
|
||||
return origin
|
||||
|
||||
@@ -91,6 +91,9 @@ type readAllPart struct {
|
||||
// barrier so cancellation cannot overtake newly accepted output.
|
||||
type speechWorker struct {
|
||||
remoteOutput func() *tentacle.RemoteSpeechOutput // Owned under mutex; no runtime/output locks in callback.
|
||||
remoteLifetime context.Context // Local output/interruptions retire admitted incoming work.
|
||||
remoteCancel context.CancelFunc
|
||||
remoteAfter time.Time // Discard mailbox/assembly work admitted before local output.
|
||||
logger *slog.Logger
|
||||
speaker Speaker
|
||||
history *speechHistory
|
||||
@@ -203,6 +206,7 @@ func (worker *speechWorker) Enqueue(request speechRequest) {
|
||||
return
|
||||
}
|
||||
request.observation = nil
|
||||
worker.retire_remote_speech_locked()
|
||||
if worker.remoteOutput != nil && exportable_speech(request) && !worker.secure {
|
||||
request.observation = worker.remoteOutput()
|
||||
}
|
||||
@@ -296,6 +300,7 @@ func (worker *speechWorker) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
worker.running = false
|
||||
worker.retire_remote_speech_locked()
|
||||
worker.confirmationID = 0
|
||||
if worker.uninterruptibleCancel != nil {
|
||||
worker.uninterruptibleCancel()
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
|
||||
// This uses the source-built external executable and real host/SDK IPC. Only
|
||||
// OpenLocal submissions are synthetic; it cannot prove physical-source input.
|
||||
func remote_command_fixture(t *testing.T) (*tentacleRuntime, *consentTestForms, input.Command) {
|
||||
func remote_command_fixture(t *testing.T, setup ...func(*tentacleRuntime)) (*tentacleRuntime, *consentTestForms, input.Command) {
|
||||
t.Helper()
|
||||
program := os.Getenv("CTHULHU_REMOTE_BINARY")
|
||||
if program == "" {
|
||||
@@ -53,6 +53,9 @@ func remote_command_fixture(t *testing.T) (*tentacleRuntime, *consentTestForms,
|
||||
runtime.newManager = func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }
|
||||
forms := &consentTestForms{requests: make(chan consentFormRequest, 1)}
|
||||
runtime.forms = forms
|
||||
for _, configure := range setup {
|
||||
configure(runtime)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
@@ -224,15 +227,25 @@ func TestRemoteConnectionCommandCancelsInspectionOnHandoff(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
for _, test := range []struct{ role, ending string }{
|
||||
{"controller", "disconnect"}, {"controlled", "disconnect"},
|
||||
{"controller", "lock"}, {"controlled", "lock"},
|
||||
{"controller", "handoff"}, {"controlled", "handoff"},
|
||||
{"controller", "disable"}, {"controlled", "disable"},
|
||||
for _, test := range []struct {
|
||||
role, ending string
|
||||
playback bool
|
||||
}{
|
||||
{"controller", "disconnect", false}, {"controlled", "disconnect", false},
|
||||
{"controller", "lock", false}, {"controlled", "lock", false},
|
||||
{"controller", "handoff", false}, {"controlled", "handoff", false},
|
||||
{"controller", "disable", false}, {"controlled", "disable", false},
|
||||
{"controller", "disconnect", true}, {"controller", "lock", true},
|
||||
{"controller", "handoff", true}, {"controller", "disable", true},
|
||||
{"controller", "backend-failure", true},
|
||||
} {
|
||||
role, ending := test.role, test.ending
|
||||
t.Run(role+"/"+ending, func(t *testing.T) {
|
||||
remoteText := " " + strings.Repeat("é\x00世界 ", 1500) + " "
|
||||
name := role + "/" + ending
|
||||
if test.playback {
|
||||
name += "/production-speech"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
remoteText := " " + strings.Repeat("é\t世界 ", 1500) + " "
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -248,6 +261,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
}
|
||||
defer listener.Close()
|
||||
probed, joined, closed := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
speechReady := make(chan struct{})
|
||||
relayOutput := make(chan []byte, 64)
|
||||
go func() {
|
||||
probe, err := listener.Accept()
|
||||
@@ -290,14 +304,19 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
peerRole = "slave"
|
||||
}
|
||||
io.WriteString(connection, `{"type":"channel_joined","channel":"synthetic-fixture","clients":[{"id":7,"connection_type":"`+peerRole+`"}]}`+"\n")
|
||||
close(joined)
|
||||
if role == "controller" {
|
||||
select {
|
||||
case <-speechReady:
|
||||
case <-t.Context().Done():
|
||||
return
|
||||
}
|
||||
json.NewEncoder(connection).Encode(map[string]any{"type": "speak", "origin": 7, "priority": 2, "sequence": []any{
|
||||
[]any{"CharacterModeCommand", map[string]any{"state": true}},
|
||||
[]any{"LangChangeCommand", map[string]any{"lang": "fr"}}, remoteText,
|
||||
[]any{"EndUtteranceCommand", map[string]any{}},
|
||||
}})
|
||||
}
|
||||
close(joined)
|
||||
for {
|
||||
frame, err := reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
@@ -307,7 +326,29 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
}
|
||||
close(closed)
|
||||
}()
|
||||
runtime, forms, shortcut := remote_command_fixture(t)
|
||||
var server *isolatedVoiceServer
|
||||
var voice *remoteSpeechVoice
|
||||
var rendered isolatedVoiceUtterance
|
||||
var configure []func(*tentacleRuntime)
|
||||
if test.playback {
|
||||
speaker, fixture := isolated_voice_fixture(t)
|
||||
server = fixture
|
||||
configure = append(configure, func(runtime *tentacleRuntime) {
|
||||
worker := runtime.output.speech
|
||||
worker.speaker = speaker
|
||||
if err := worker.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := worker.Stop(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
runtime, forms, shortcut := remote_command_fixture(t, configure...)
|
||||
if role == "controlled" {
|
||||
worker := runtime.output.speech
|
||||
worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 16)}}
|
||||
@@ -323,10 +364,10 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
// Incoming speech still needs a test owner; production observation
|
||||
// is attached through makeManager on the controlled side.
|
||||
// Keep the explicit mailbox assembly fixture alongside production
|
||||
// playback, which is attached by the actual runtime startup path.
|
||||
var deliveries <-chan tentacle.RemoteSpeechDelivery
|
||||
if role == "controller" {
|
||||
if role == "controller" && !test.playback {
|
||||
runtime.mutex.Lock()
|
||||
for _, command := range runtime.commands {
|
||||
if command.id == "remote" {
|
||||
@@ -371,13 +412,74 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
if status.ctx.Err() == nil {
|
||||
t.Fatal("background connection retained its form context")
|
||||
}
|
||||
if test.playback {
|
||||
waitTentacle(t, func() bool {
|
||||
worker := runtime.output.speech
|
||||
worker.mutex.Lock()
|
||||
defer worker.mutex.Unlock()
|
||||
return worker.activeDone == nil && len(worker.pending) == 0
|
||||
})
|
||||
if ending == "backend-failure" {
|
||||
server.mutex.Lock()
|
||||
server.queryReplies = map[string]string{"GET LANGUAGE": "400 Rejected\r\n"}
|
||||
server.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
close(speechReady)
|
||||
if ending == "backend-failure" {
|
||||
remote_stream_wait(t, closed)
|
||||
// Backend failure retires the lease, while the process and
|
||||
// mailbox owner remain usable for an explicit new connection.
|
||||
runtime.Invoke(t.Context(), shortcut)
|
||||
selection := next_remote_form(t, forms)
|
||||
if selection.form.Title == "Remote relay status" {
|
||||
// An ended attempt remains inspectable until explicitly
|
||||
// closed, as with ordinary transport failure.
|
||||
selection.reply <- internalUIResult{Activated: "cancel"}
|
||||
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
||||
runtime.Invoke(t.Context(), shortcut)
|
||||
selection = next_remote_form(t, forms)
|
||||
}
|
||||
if !strings.Contains(selection.form.Title, "Remote relay and role") {
|
||||
t.Fatal("backend failure retained a connected workflow")
|
||||
}
|
||||
selection.reply <- internalUIResult{Cancelled: true}
|
||||
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
||||
server.mutex.Lock()
|
||||
for _, connection := range server.connections {
|
||||
if connection.priority == "TEXT" && connection.pending != "" {
|
||||
t.Error("failed rendering exposed a speech prefix")
|
||||
}
|
||||
}
|
||||
server.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
t.Fatal("return to desktop disconnected the relay")
|
||||
default:
|
||||
}
|
||||
var delivery tentacle.RemoteSpeechDelivery
|
||||
if role == "controller" {
|
||||
if test.playback {
|
||||
utterance := server.next_utterance(t)
|
||||
for utterance.priority == "MESSAGE" {
|
||||
// Returning to desktop produces local status on its ordinary
|
||||
// client. The production Remote voice must stay separate.
|
||||
utterance = server.next_utterance(t)
|
||||
}
|
||||
rendered = utterance
|
||||
if utterance.text != remoteText || utterance.language != "fr" || utterance.spelling != "on" ||
|
||||
utterance.ssml != "off" || utterance.priority != "TEXT" {
|
||||
t.Fatal("production owner lost literal speech, metadata or local priority")
|
||||
}
|
||||
speaker := runtime.output.speech.speaker.(*configuredSpeaker)
|
||||
speaker.mu.RLock()
|
||||
voice = speaker.remote
|
||||
speaker.mu.RUnlock()
|
||||
if voice == nil {
|
||||
t.Fatal("production owner did not acquire isolated synthesis")
|
||||
}
|
||||
} else if role == "controller" {
|
||||
select {
|
||||
case delivery = <-deliveries:
|
||||
case <-time.After(5 * time.Second):
|
||||
@@ -448,6 +550,28 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request = current_line_observation_request(t, review.Result{CurrentLineBrowse: true,
|
||||
DocumentCaretText: "Current colour.", DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Role: "static"}},
|
||||
DocumentCaretContext: []atspi.Snapshot{{Role: "static"}, {Role: "paragraph"}, {Role: "document web"}}})
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A joined line can contain a private neighboring fragment even
|
||||
// when its caret source and the line's presentation are ordinary.
|
||||
caret := atspi.Snapshot{Role: "static"}
|
||||
privateLine := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: caret, Text: "private joined text"}},
|
||||
SourceContexts: [][]atspi.Snapshot{{caret}, {{Role: "static"}, {Role: "password text"}}}}
|
||||
for _, kind := range []string{"caret", "read-all", "current-line"} {
|
||||
request := document_observation_request(t, kind, review.Result{
|
||||
Moved: true, DocumentCaretCommand: review.CaretNextLine,
|
||||
DocumentPosition: review.DocumentPosition{Snapshot: caret}, DocumentCaretText: "private joined text",
|
||||
DocumentCaretContext: []atspi.Snapshot{caret, {Role: "document web"}},
|
||||
DocumentCaretLine: &privateLine, ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{privateLine},
|
||||
})
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
var received strings.Builder
|
||||
ends, frames := 0, 0
|
||||
@@ -483,7 +607,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
t.Fatal("reader output did not reach the relay in order")
|
||||
}
|
||||
}
|
||||
if received.String() != "color "+remoteText+"First color.Second color." || frames < 4 || ends != 3 {
|
||||
if received.String() != "color "+remoteText+"First color.Second color.Current color." || frames < 5 || ends != 4 {
|
||||
t.Fatal("observation leaked private text or lost resolved output/boundaries")
|
||||
}
|
||||
}
|
||||
@@ -512,7 +636,15 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
t.Fatal("background connection retained the relay after " + ending)
|
||||
}
|
||||
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
|
||||
if role == "controller" {
|
||||
if test.playback {
|
||||
remote_stream_wait(t, voice.closed)
|
||||
server.mutex.Lock()
|
||||
pending := server.connections[rendered.id].pending
|
||||
server.mutex.Unlock()
|
||||
if pending != "" {
|
||||
t.Fatal("revoked production speech survived backend cleanup")
|
||||
}
|
||||
} else if role == "controller" {
|
||||
// Socket cancellation precedes the final sequenced lease close.
|
||||
// A reopened status interaction did not issue the original consent,
|
||||
// so its result-slot completion is not the lease-close barrier.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
// Called under the speech mutex. Cancellation performs no backend I/O. A local
|
||||
// announcement or interruption discards all older incoming work, including work
|
||||
// waiting behind an active renderer. Later Remote output needs fresh admission.
|
||||
func (worker *speechWorker) retire_remote_speech_locked() {
|
||||
worker.remoteAfter = time.Now()
|
||||
if worker.remoteCancel != nil {
|
||||
worker.remoteCancel()
|
||||
}
|
||||
worker.remoteLifetime, worker.remoteCancel = nil, nil
|
||||
}
|
||||
|
||||
func (worker *speechWorker) admit_remote_speech(delivery tentacle.RemoteSpeechDelivery) (remoteSpeechPermit, bool) {
|
||||
worker.mutex.Lock()
|
||||
defer worker.mutex.Unlock()
|
||||
if !worker.running || !worker.enabled || worker.secure || worker.mode != speechModeSpeech ||
|
||||
worker.interrupting || worker.activeDone != nil || len(worker.pending) != 0 ||
|
||||
!delivery.Received.After(worker.remoteAfter) {
|
||||
return remoteSpeechPermit{}, false
|
||||
}
|
||||
if worker.remoteLifetime == nil {
|
||||
worker.remoteLifetime, worker.remoteCancel = context.WithCancel(worker.workerContext)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(delivery.Context)
|
||||
local := worker.remoteLifetime
|
||||
stop := context.AfterFunc(local, cancel)
|
||||
return remoteSpeechPermit{context: ctx, current: func() bool { return local.Err() == nil },
|
||||
release: func() { stop(); cancel() }}, true
|
||||
}
|
||||
|
||||
func (worker *speechWorker) remote_speech_available(delivery tentacle.RemoteSpeechDelivery) bool {
|
||||
worker.mutex.Lock()
|
||||
defer worker.mutex.Unlock()
|
||||
return worker.running && worker.enabled && !worker.secure && worker.mode == speechModeSpeech &&
|
||||
delivery.Received.After(worker.remoteAfter)
|
||||
}
|
||||
|
||||
// start_remote_speech installs the mailbox before the child becomes active.
|
||||
// One session worker owns it until output revocation or reader shutdown. The
|
||||
// lifecycle watcher joins cleanup; input/focus callers only cancel contexts.
|
||||
func (runtime *tentacleRuntime) start_remote_speech(ctx context.Context, session *tentacle.Session) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
owner := runtime.output
|
||||
if owner == nil || owner.speech == nil || owner.gate == nil || runtime.remotePolicy == nil ||
|
||||
!slices.Contains(session.RemoteGrants(), "remote-present") {
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
speaker, ok := owner.speech.speaker.(*configuredSpeaker)
|
||||
if !ok {
|
||||
close(done)
|
||||
return done
|
||||
}
|
||||
incoming := session.EnableRemoteSpeech()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
stop := context.AfterFunc(session.OutputContext(), cancel)
|
||||
allow := func(delivery tentacle.RemoteSpeechDelivery) bool {
|
||||
if delivery.Context == nil || delivery.Context.Err() != nil {
|
||||
return false
|
||||
}
|
||||
owner.gate.mutex.Lock()
|
||||
defer owner.gate.mutex.Unlock()
|
||||
if owner.gate.Locked() || owner.gate.Suspended() || owner.gate.InternalUIActive() ||
|
||||
!owner.speech.remote_speech_available(delivery) {
|
||||
return false
|
||||
}
|
||||
lifetime, err := session.RemoteContext(delivery.Lease, "remote-present")
|
||||
return err == nil && lifetime.Err() == nil
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer cancel()
|
||||
defer stop()
|
||||
for ctx.Err() == nil {
|
||||
err := run_remote_speech_playback(ctx, speaker, incoming, allow, owner.speech.admit_remote_speech)
|
||||
if ctx.Err() != nil || err == nil {
|
||||
return
|
||||
}
|
||||
var failure *remoteSpeechStreamError
|
||||
if !errors.As(err, &failure) {
|
||||
// Internal ownership failure: leave no accepting mailbox without
|
||||
// a consumer. Normal backend failures retire only their lease.
|
||||
session.Disable()
|
||||
return
|
||||
}
|
||||
session.RevokeRemoteEpoch(failure.lease.Epoch)
|
||||
runtime.report("Remote speech stopped. Connect again to retry.")
|
||||
}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
func TestRemoteSpeechLocalPriorityDiscardsActiveAndQueuedWork(t *testing.T) {
|
||||
for _, change := range []string{"announcement", "interrupt", "mode", "disable", "secure"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
|
||||
worker.running, worker.workerContext = true, t.Context()
|
||||
f := remote_stream_fixture(t, false, worker.admit_remote_speech)
|
||||
activeRelease := f.speak(t, "old active", 2)
|
||||
active := f.next(t, "old active")
|
||||
pendingRelease := f.speak(t, "old pending", 2)
|
||||
// Equal priority keeps the queued item behind this active NOW.
|
||||
// Use the barrier to establish admission before local output.
|
||||
f.barrier(t)
|
||||
switch change {
|
||||
case "announcement":
|
||||
worker.Enqueue(speechRequest{Text: "local status"})
|
||||
case "interrupt":
|
||||
worker.InterruptForUser()
|
||||
case "mode":
|
||||
worker.cycle_speech_mode()
|
||||
case "disable":
|
||||
worker.SetEnabled(false)
|
||||
case "secure":
|
||||
worker.SetSecureInputActive(true)
|
||||
}
|
||||
remote_stream_wait(t, active.stopped)
|
||||
remote_stream_wait(t, activeRelease)
|
||||
remote_stream_wait(t, pendingRelease)
|
||||
remote_stream_wait(t, f.speak(t, "blocked by local policy", 2))
|
||||
select {
|
||||
case <-f.started:
|
||||
t.Fatal("old or blocked incoming speech reached rendering")
|
||||
default:
|
||||
}
|
||||
worker.mutex.Lock()
|
||||
worker.pending, worker.interrupting = nil, false
|
||||
worker.enabled, worker.secure, worker.mode = true, false, speechModeSpeech
|
||||
worker.mutex.Unlock()
|
||||
freshRelease := f.speak(t, "fresh output", 0)
|
||||
fresh := f.next(t, "fresh output")
|
||||
fresh.finish <- nil
|
||||
remote_stream_wait(t, freshRelease)
|
||||
f.stop(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSpeechLocalPriorityRejectsOldMailboxAndIncompleteAssembly(t *testing.T) {
|
||||
worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
|
||||
worker.running, worker.workerContext = true, t.Context()
|
||||
// The first fragment's timestamp survives finalization: work retained in
|
||||
// the IPC assembly or mailbox must not acquire fresh local authority later.
|
||||
old := tentacle.RemoteSpeechDelivery{Context: t.Context(), Received: time.Now()}
|
||||
worker.Enqueue(speechRequest{Text: "local status"})
|
||||
worker.mutex.Lock()
|
||||
worker.pending = nil // Local queue drained before the old mailbox is read.
|
||||
worker.mutex.Unlock()
|
||||
if _, ok := worker.admit_remote_speech(old); ok {
|
||||
t.Fatal("old mailbox/assembly work followed local output")
|
||||
}
|
||||
fresh := tentacle.RemoteSpeechDelivery{Context: t.Context(), Received: time.Now()}
|
||||
permit, ok := worker.admit_remote_speech(fresh)
|
||||
if !ok || permit.context.Err() != nil || !permit.current() {
|
||||
t.Fatal("fresh incoming output was rejected after local reading")
|
||||
}
|
||||
permit.release()
|
||||
}
|
||||
|
||||
func TestRemoteSpeechRejectedLocalRequestPreservesRemoteAndServerCancelContinues(t *testing.T) {
|
||||
worker := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
|
||||
worker.running, worker.workerContext = true, t.Context()
|
||||
f := remote_stream_fixture(t, false, worker.admit_remote_speech)
|
||||
released := f.speak(t, "active", 0)
|
||||
active := f.next(t, "active")
|
||||
oldGeneration := worker.generation + 1
|
||||
worker.Enqueue(speechRequest{Text: "stale result", presentationGeneration: &oldGeneration})
|
||||
select {
|
||||
case <-active.ctx.Done():
|
||||
t.Fatal("rejected local request cancelled Remote output")
|
||||
default:
|
||||
}
|
||||
// A server priority cancellation discards this assembly. It must not
|
||||
// disable the owner or retire consent, and no suffix is retried.
|
||||
active.finish <- context.Canceled
|
||||
remote_stream_wait(t, released)
|
||||
nextRelease := f.speak(t, "after cancellation", 0)
|
||||
next := f.next(t, "after cancellation")
|
||||
next.finish <- nil
|
||||
remote_stream_wait(t, nextRelease)
|
||||
f.stop(t)
|
||||
}
|
||||
|
||||
func TestRemoteSpeechBackgroundPriorityRejectionClosesCandidate(t *testing.T) {
|
||||
speaker, server := isolated_voice_fixture(t)
|
||||
server.mutex.Lock()
|
||||
server.queryReplies = map[string]string{"SET SELF PRIORITY TEXT": "400 Rejected\r\n"}
|
||||
server.mutex.Unlock()
|
||||
if _, err := speaker.open_remote_voice(t.Context()); err == nil {
|
||||
t.Fatal("isolated client acquired without lower backend priority")
|
||||
}
|
||||
speaker.mu.RLock()
|
||||
remote := speaker.remote
|
||||
speaker.mu.RUnlock()
|
||||
if remote != nil {
|
||||
t.Fatal("failed candidate became the owned voice")
|
||||
}
|
||||
server.mutex.Lock()
|
||||
server.queryReplies = nil
|
||||
server.mutex.Unlock()
|
||||
voice, err := speaker.open_remote_voice(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal("failed priority setup prevented a fresh acquisition", err)
|
||||
}
|
||||
if err := voice.Close(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -135,8 +135,10 @@ func (owner *tentacleLifecycle) startPending() {
|
||||
}
|
||||
})
|
||||
owner.watchers = append(owner.watchers, done)
|
||||
speechDone := owner.runtime.start_remote_speech(owner.ctx, session)
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer func() { <-speechDone }()
|
||||
select {
|
||||
case <-session.Revoked():
|
||||
case <-owner.ctx.Done():
|
||||
|
||||
@@ -184,9 +184,11 @@ func (runtime *tentacleRuntime) run(ctx context.Context) error {
|
||||
}
|
||||
runtime.mutex.Unlock()
|
||||
current, ready = session, session.Ready()
|
||||
speechDone := runtime.start_remote_speech(ctx, session)
|
||||
watchers.Add(1)
|
||||
go func() {
|
||||
defer watchers.Done()
|
||||
defer func() { <-speechDone }()
|
||||
select {
|
||||
case <-session.Revoked():
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -24,6 +24,10 @@ type DocumentLine struct {
|
||||
Context []Snapshot
|
||||
Document ObjectReference
|
||||
Contents []DocumentContent
|
||||
// SourceContexts retains each contributing object's unfiltered ancestry,
|
||||
// including that object. It survives presentation flattening for speech
|
||||
// privacy checks. A nil entry means that source could not be classified.
|
||||
SourceContexts [][]Snapshot
|
||||
}
|
||||
|
||||
func (tree *AccessibleTree) DocumentLineAtOffset(ctx context.Context, reference ObjectReference, offset int) (DocumentLine, error) {
|
||||
@@ -75,6 +79,7 @@ func (tree *AccessibleTree) DocumentLineAtOffset(ctx context.Context, reference
|
||||
if node.Hidden {
|
||||
continue
|
||||
}
|
||||
contributed := false
|
||||
for _, textRange := range node.Text.Ranges {
|
||||
if !boundsAreOnSameLine(caretRange.Bounds, textRange.Bounds) {
|
||||
continue
|
||||
@@ -82,6 +87,11 @@ func (tree *AccessibleTree) DocumentLineAtOffset(ctx context.Context, reference
|
||||
if textRange.Text == "" {
|
||||
continue
|
||||
}
|
||||
if !contributed {
|
||||
owner, _ := tree.DocumentContext(node.Reference)
|
||||
line.SourceContexts = append(line.SourceContexts, owner.Ancestors)
|
||||
contributed = true
|
||||
}
|
||||
line.Contents = append(line.Contents, DocumentContent{
|
||||
Reference: node.Reference,
|
||||
Snapshot: node.Snapshot,
|
||||
|
||||
@@ -209,6 +209,19 @@ func TestDocumentLineAssemblesSameVisualRow(t *testing.T) {
|
||||
if line.Document.ID() != testID("/doc") {
|
||||
t.Fatalf("line document = %#v", line.Document)
|
||||
}
|
||||
if len(line.SourceContexts) != len(line.Contents) {
|
||||
t.Fatal("assembled line lost contributing sources")
|
||||
}
|
||||
for index, sources := range line.SourceContexts {
|
||||
if len(sources) == 0 || sources[0].ID() != line.Contents[index].Snapshot.ID() {
|
||||
t.Fatal("line source context belongs to another fragment")
|
||||
}
|
||||
}
|
||||
// The first fragment is outside the caret object's ancestry. Keep its
|
||||
// original ancestry even though line.Context belongs to the caret.
|
||||
if line.SourceContexts[0][1].ID() != testID("/doc") || line.SourceContexts[1][1].ID() != testID("/inner") {
|
||||
t.Fatal("neighbor ancestry was replaced by caret ancestry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentLineExcludesHiddenAndOtherRows(t *testing.T) {
|
||||
|
||||
@@ -69,10 +69,18 @@ func (worker *Worker) read_current_line(ctx context.Context, cursor *DocumentCur
|
||||
if result.Err != nil {
|
||||
return result
|
||||
}
|
||||
movement, _, err := worker.readAllMove(deadline, cursor, reader, CaretReadLine, result.DocumentFocus)
|
||||
movement, cursor, err := worker.readAllMove(deadline, cursor, reader, CaretReadLine, result.DocumentFocus)
|
||||
result.Err = err
|
||||
result.DocumentPosition, result.DocumentCaretText = movement.Position, movement.Text
|
||||
result.DocumentCaretRawText, result.DocumentCaretLine = movement.RawText, movement.Line
|
||||
if err == nil {
|
||||
// Retain the unfiltered ancestry from the tree that produced this
|
||||
// movement, including after recapture. Missing metadata keeps export
|
||||
// private without preventing the local current-line announcement.
|
||||
if owner, contextErr := cursor.tree.DocumentContext(movement.Position.Object); contextErr == nil {
|
||||
result.DocumentCaretContext = owner.Ancestors
|
||||
}
|
||||
}
|
||||
if result.DocumentCaretText == "" && wholeCaretNode(movement.Position.Snapshot) {
|
||||
result.DocumentCaretText = movement.Position.Snapshot.Name
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ func TestCurrentLineReadsWholeBrowseLineWithoutMoving(t *testing.T) {
|
||||
if result.Err != nil || strings.TrimSpace(result.DocumentCaretText) != "Second café 猫" {
|
||||
t.Fatalf("whole browse line from middle: %+v", result)
|
||||
}
|
||||
owner, err := cursor.tree.DocumentContext(result.DocumentPosition.Object)
|
||||
if err != nil || len(result.DocumentCaretContext) == 0 || !reflect.DeepEqual(result.DocumentCaretContext, owner.Ancestors) {
|
||||
t.Fatal("current-line report lost its captured source ancestry", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cursor.places, before) || capturer.nativePlacements != 0 {
|
||||
t.Fatal("report changed browse or native caret")
|
||||
}
|
||||
@@ -76,6 +80,9 @@ func TestCurrentLineJoinsBrowseFragmentsWithoutMoving(t *testing.T) {
|
||||
if result.Err != nil || result.DocumentCaretText != "One two." || !reflect.DeepEqual(cursor.places, before) {
|
||||
t.Fatalf("fragmented current line: %+v", result)
|
||||
}
|
||||
if result.DocumentCaretLine == nil || len(result.DocumentCaretLine.SourceContexts) != 2 || len(result.DocumentCaretContext) == 0 {
|
||||
t.Fatal("fragmented report lost source ancestry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentLineReadsBrowseControlLabel(t *testing.T) {
|
||||
@@ -99,6 +106,46 @@ func TestCurrentLineReadsBrowseControlLabel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentLineUsesRecapturedSourceContext(t *testing.T) {
|
||||
text := caretTextReader{webID("/text"): "First\nSecond", webID("/next"): "Last"}
|
||||
cursor := newCaretTextCursor(t, text)
|
||||
fresh := newCaretTextCursor(t, text).tree
|
||||
capturer := &sequenceReadAllCapturer{caretTextReader: text, trees: []*atspi.AccessibleTree{fresh},
|
||||
invalidateTree: cursor.tree, staleAt: 2}
|
||||
worker, _ := NewWorker(capturer, time.Second)
|
||||
worker.SyncFocus(webID("/text"))
|
||||
result := worker.read_current_line(t.Context(), cursor, true)
|
||||
if result.Err != nil || strings.TrimSpace(result.DocumentCaretText) != "First" || capturer.index != 1 {
|
||||
t.Fatalf("current line did not recover capture: %+v", result)
|
||||
}
|
||||
owner, err := fresh.DocumentContext(result.DocumentPosition.Object)
|
||||
if err != nil || len(result.DocumentCaretContext) == 0 || !reflect.DeepEqual(result.DocumentCaretContext, owner.Ancestors) {
|
||||
t.Fatal("report did not retain the replacement tree's source context", err)
|
||||
}
|
||||
}
|
||||
|
||||
type currentLineStaleContextCapturer struct{ *readAllCapturer }
|
||||
|
||||
func (capturer *currentLineStaleContextCapturer) ReadDocumentCaret(ctx context.Context, reference atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
||||
text, err := capturer.caretTextReader.ReadDocumentCaret(ctx, reference, offset, unit)
|
||||
if unit == atspi.DocumentCaretLineRemainder {
|
||||
capturer.tree.InvalidateSnapshot(webID("/doc"))
|
||||
}
|
||||
return text, err
|
||||
}
|
||||
|
||||
func TestCurrentLinePreservesLocalTextWithStaleContext(t *testing.T) {
|
||||
text := caretTextReader{webID("/text"): "Local line", webID("/next"): "Last"}
|
||||
cursor := newCaretTextCursor(t, text)
|
||||
capturer := ¤tLineStaleContextCapturer{&readAllCapturer{caretTextReader: text, tree: cursor.tree}}
|
||||
worker, _ := NewWorker(capturer, time.Second)
|
||||
worker.SyncFocus(webID("/text"))
|
||||
result := worker.read_current_line(t.Context(), cursor, true)
|
||||
if result.Err != nil || result.DocumentCaretText != "Local line" || len(result.DocumentCaretContext) != 0 {
|
||||
t.Fatalf("stale ancestry must leave local reading available without export metadata: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type currentLineFragmentCapturer struct {
|
||||
*readAllCapturer
|
||||
fragmentTextReader
|
||||
|
||||
@@ -272,9 +272,9 @@ func assemble_visual_line(tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot
|
||||
raw.WriteString(separator)
|
||||
raw.WriteString(value)
|
||||
snapshot := doc.Nodes[part.index].Snapshot
|
||||
if context, err := tree.DocumentContext(doc.Nodes[part.index].Reference); err == nil {
|
||||
snapshot.BrailleContext = context.Ancestors
|
||||
}
|
||||
owner, _ := tree.DocumentContext(doc.Nodes[part.index].Reference)
|
||||
snapshot.BrailleContext = owner.Ancestors
|
||||
line.SourceContexts = append(line.SourceContexts, owner.Ancestors)
|
||||
snapshot.Misspelled = part.text.Misspelled
|
||||
misspelled = misspelled || part.text.Misspelled
|
||||
line.Contents = append(line.Contents, atspi.DocumentContent{Reference: doc.Nodes[part.index].Reference, Snapshot: snapshot,
|
||||
|
||||
@@ -167,6 +167,13 @@ func TestReadAllFragmentLinesAndRemainder(t *testing.T) {
|
||||
t.Fatalf("line %d: %q, want %q", index, got, want)
|
||||
}
|
||||
}
|
||||
// Say All flattens the fragments into one text value, but must retain
|
||||
// every original source and ancestry for Remote's privacy decision.
|
||||
contexts := result.ReadAllLines[0].SourceContexts
|
||||
if len(contexts) != 2 || len(contexts[0]) < 2 || len(contexts[1]) < 2 ||
|
||||
contexts[0][0].ID() != webID("/one") || contexts[1][0].ID() != webID("/two") || contexts[1][1].ID() != webID("/span") {
|
||||
t.Fatal("flattened Say All line lost fragment ancestry")
|
||||
}
|
||||
if cursor.places[0].offset != 1 {
|
||||
t.Fatal("assembly moved the user's cursor")
|
||||
}
|
||||
@@ -182,6 +189,31 @@ func TestReadAllFragmentLinesAndRemainder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentFragmentSourceLookupFailureKeepsLocalText(t *testing.T) {
|
||||
cursor, reader := fragment_cursor(t, "paragraph")
|
||||
doc, err := cursor.tree.Document(t.Context(), cursor.places[0].object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parts []visualLinePart
|
||||
for index, node := range doc.Nodes {
|
||||
if node.Reference.ID() == webID("/one") || node.Reference.ID() == webID("/two") {
|
||||
text, err := reader.ReadDocumentCaret(t.Context(), node.Reference, 0, atspi.DocumentCaretLineRemainder)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts = append(parts, visualLinePart{index: index, text: text})
|
||||
}
|
||||
}
|
||||
// Metadata can become stale after the successful text read. The local
|
||||
// text stays usable; missing source evidence must remain explicit.
|
||||
cursor.tree.InvalidateSnapshot(webID("/span"))
|
||||
line, raw, _ := assemble_visual_line(cursor.tree, doc, parts)
|
||||
if raw != "One two." || len(line.SourceContexts) != 2 || len(line.SourceContexts[0]) == 0 || line.SourceContexts[1] != nil {
|
||||
t.Fatal("stale source context changed local text or looked classified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentFragmentBoundaries(t *testing.T) {
|
||||
for _, boundary := range []string{"heading", "link", "table cell", "pre", "code", "entry"} {
|
||||
t.Run(boundary, func(t *testing.T) {
|
||||
|
||||
@@ -114,6 +114,12 @@ func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Resul
|
||||
previous = movement.Position
|
||||
movement.Position.Snapshot.Misspelled = movement.Misspelled
|
||||
line := atspi.DocumentLine{Document: movement.Position.Document, Contents: []atspi.DocumentContent{{Snapshot: movement.Position.Snapshot, Text: movement.Text, RawText: movement.RawText}}}
|
||||
if movement.Line != nil {
|
||||
// Presentation deliberately flattens a visual line into one spoken
|
||||
// string. Keep every contributing source for independent privacy
|
||||
// checks, including sources outside the caret object's ancestry.
|
||||
line.SourceContexts = movement.Line.SourceContexts
|
||||
}
|
||||
owner, err := cursor.tree.DocumentContext(movement.Position.Object)
|
||||
if err != nil {
|
||||
if !documentNeedsCapture(err) {
|
||||
|
||||
@@ -273,6 +273,19 @@ func (client *Client) Speak(ctx context.Context, text string) (result error) {
|
||||
return client.speak(ctx, text)
|
||||
}
|
||||
|
||||
// SetBackgroundPriority makes this isolated client yield to ordinary MESSAGE
|
||||
// output. Call before speaking; relay priorities must never select SSIP priority.
|
||||
// TEXT waits behind MESSAGE and is cancelled when new MESSAGE output arrives.
|
||||
// Protocol: https://github.com/brailcom/speechd/blob/master/doc/ssip.texi
|
||||
func (client *Client) SetBackgroundPriority(ctx context.Context) error {
|
||||
client.mutex.Lock()
|
||||
defer client.mutex.Unlock()
|
||||
if client.conn == nil {
|
||||
return errors.New("speech client is not connected")
|
||||
}
|
||||
return client.sendCommand(ctx, []string{"SET", "SELF", "PRIORITY", "TEXT"})
|
||||
}
|
||||
|
||||
// SpeakCharacter gives the synthesizer a single character as a character,
|
||||
// preserving repeated adjacent letters during caret and flat review. Spoken
|
||||
// names such as "space" still use normal speech.
|
||||
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
type RemoteSpeechDelivery struct {
|
||||
Context context.Context
|
||||
Lease tentaclewire.RemoteLease
|
||||
// Received is the reader's monotonic admission time of the first frame.
|
||||
// Local output can discard old mailbox/assembly work before it is dequeued.
|
||||
Received time.Time
|
||||
// Speeches is one complete effect/utterance, including all IPC fragments.
|
||||
// The owner applies priority once and must not deliver a partial assembly.
|
||||
Speeches []tentaclewire.RemoteSpeech
|
||||
@@ -30,13 +33,14 @@ type RemoteSpeechDelivery struct {
|
||||
|
||||
type remoteSpeechBatch struct {
|
||||
id uint64
|
||||
received time.Time
|
||||
speeches []tentaclewire.RemoteSpeech
|
||||
bytes int
|
||||
}
|
||||
|
||||
// EnableRemoteSpeech installs one bounded mailbox, explicitly from reader code.
|
||||
// Without an owner, remote.present is unavailable. No production speech worker
|
||||
// is bound yet. The owner selects Session.Done as well as this channel.
|
||||
// Without an owner, remote.present is unavailable. The owner selects session
|
||||
// shutdown as well as this channel and joins its backend cleanup.
|
||||
func (session *Session) EnableRemoteSpeech() <-chan RemoteSpeechDelivery {
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
@@ -103,7 +107,7 @@ func (session *Session) remote_present_locked(params json.RawMessage) (any, *RPC
|
||||
if request.Utterance != 0 && request.Utterance != request.Sequence {
|
||||
return nil, &RPCError{Code: -32602, Message: "Invalid utterance assembly"}
|
||||
}
|
||||
batch = &remoteSpeechBatch{id: request.Utterance}
|
||||
batch = &remoteSpeechBatch{id: request.Utterance, received: time.Now()}
|
||||
} else if request.Utterance != batch.id || request.Speech.Priority != batch.speeches[0].Priority {
|
||||
// No interleaved speech, cancel, pause, or changed priority may expose
|
||||
// an incomplete utterance. Heartbeats may still advance the sequence.
|
||||
@@ -125,7 +129,7 @@ func (session *Session) remote_present_locked(params json.RawMessage) (any, *RPC
|
||||
}
|
||||
access.speechBatch = nil
|
||||
var released sync.Once
|
||||
delivery := RemoteSpeechDelivery{Context: access.ctx, Lease: lease, Speeches: batch.speeches, Release: func() {
|
||||
delivery := RemoteSpeechDelivery{Context: access.ctx, Lease: lease, Received: batch.received, Speeches: batch.speeches, Release: func() {
|
||||
released.Do(func() {
|
||||
session.mutex.Lock()
|
||||
defer session.mutex.Unlock()
|
||||
|
||||
@@ -178,6 +178,7 @@ func TestRemoteSpeechSDKAtomicUtterance(t *testing.T) {
|
||||
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
firstAdmitted := time.Now()
|
||||
request.Sequence++
|
||||
if _, code := remoteCall(t, session, "remote.heartbeat", request.RemoteOperation); code != 0 {
|
||||
t.Fatal("heartbeat could not run during assembly", code)
|
||||
@@ -210,6 +211,9 @@ func TestRemoteSpeechSDKAtomicUtterance(t *testing.T) {
|
||||
t.Fatal(code)
|
||||
}
|
||||
delivery := <-deliveries
|
||||
if delivery.Received.IsZero() || delivery.Received.After(firstAdmitted) {
|
||||
t.Fatal("finalization refreshed the original assembly admission time")
|
||||
}
|
||||
if !reflect.DeepEqual(delivery.Speeches, frames) || delivery.Lease != lease || delivery.Context.Err() != nil {
|
||||
t.Fatal("assembly changed semantics or ownership")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user