Assemble Remote speech before reader delivery

This commit is contained in:
ak47 committed 2026-11-04 14:33:54 -05:00
1 parent f23e89ed93
commit 205cb27483
9 files changed
+492 -33

No files matched your search

+44 -13
View File
@@ -2,7 +2,10 @@
This increment adds typed speech IPC and an explicit reader-owned mailbox. It This increment adds typed speech IPC and an explicit reader-owned mailbox. It
does not connect the speech worker, observe local output, or make Remote speak. does not connect the speech worker, observe local output, or make Remote speak.
The external source command remains relay-only. Base reader revision: `4f9b89e` The external coordinator now connects this IPC to relay speech; the production
reader still has no presentation owner or output observer. The current increment
adds atomic assembly of split incoming utterances before owner admission.
Original base reader revision: `4f9b89e`
on `testing`; matching Tentacles base: `0c74038` on `master`, plus the pending on `testing`; matching Tentacles base: `0c74038` on `master`, plus the pending
connection-command changes. No active or installed reader was replaced. connection-command changes. No active or installed reader was replaced.
@@ -26,13 +29,35 @@ Child `remote.present` takes `RemotePresentation`: lease, epoch, monotonically
increasing operation sequence and speech. The sequence is shared with heartbeat increasing operation sequence and speech. The sequence is shared with heartbeat
and close. Admission requires a live controller lease with `remote-present`. and close. Admission requires a live controller lease with `remote-present`.
Without a reader owner calling `Session.EnableRemoteSpeech`, it returns `-32601`. Without a reader owner calling `Session.EnableRemoteSpeech`, it returns `-32601`.
The queue holds at most 64 items, never blocks the IPC reader, and preserves FIFO The queue never blocks the IPC reader and preserves FIFO speech/cancel/pause
speech/cancel/pause order. Overflow revokes the entire lease and returns `-32002`; order. Across incomplete assemblies, queued deliveries and work in flight, each
it cannot drop a cancel while admitting newer speech. Speech does not refresh lease retains at most 64 speech frames and 256 KiB of canonical speech JSON.
the heartbeat deadline. Overflow revokes the entire lease and returns `-32002`; it cannot drop a cancel
while admitting newer speech. Speech does not refresh the heartbeat deadline.
For split speech, `RemotePresentation` also accepts `utterance` and `final`.
Omitting both retains the single-call contract. A nonzero utterance identifies
one assembly and equals the first fragment's operation sequence. Later fragments
carry that same identifier and a strictly increasing operation sequence; the
last sets `final: true`. Only speak can be grouped, and every fragment must have
the same priority. Heartbeats may advance the shared sequence between fragments.
Only one assembly may be pending; interleaved effects, changed identifiers or
priorities return `-32602` without committing a prefix or consuming the sequence.
Replay still returns `-32001`. Null/unknown fields and invalid grouping metadata
are rejected; both SDKs expose matching presentation validators.
No fragment reaches the owner before the final call succeeds. Finalization
queues one delivery containing every frame, in order. An unfinished assembly
stays bounded until completed or its lease ends; it has no independent renewal
or retry. Revocation, replacement, close, policy cancellation, process loss and
heartbeat expiry discard it. No relay protocol extension is involved.
The owner receives `RemoteSpeechDelivery` containing a cancellation context, The owner receives `RemoteSpeechDelivery` containing a cancellation context,
lease and validated speech. It must select session shutdown, check the context lease, `Speeches` slice and idempotent `Release` callback. The slice is one
complete effect/utterance; apply priority once, preserving all fragment states
and boundaries. The owner must call Release after delivery or discard and drop
its references. Dequeue alone does not return budget; stale releases cannot
debit a replacement lease. It must select session shutdown, check the context
and `RemoteContext` at delivery, apply focus/secure-input/speech policy, keep and `RemoteContext` at delivery, apply focus/secure-input/speech policy, keep
remote cancellation separate from local status, and mark remote origins so they remote cancellation separate from local status, and mark remote origins so they
cannot be observed again. Revocation immediately cancels contexts and drains cannot be observed again. Revocation immediately cancels contexts and drains
@@ -52,18 +77,24 @@ Tentacle must recheck lease/epoch and peer readiness before sending over a relay
Run from this checkout: Run from this checkout:
```sh ```sh
GOCACHE=/tmp/cthulhu-cache GOPROXY=off go test -race ./internal/tentaclewire ./internal/tentacle ./sdk/... -count=1 GOCACHE=/tmp/cthulhu-cache GOPROXY=off go test -race ./internal/tentaclewire ./internal/tentacle ./sdk/... -count=1 -timeout=240s
``` ```
Real Go/Python child processes cover typed speech and observed events, absent Real Go/Python child processes cover typed speech and observed events, absent
owners, role/grant denial, replay, ordering, revocation and queue overflow. owners, role/grant denial, replay, ordering, revocation and queue overflow.
Shared validation cases cover null/type/unknown-field rejection and escaped Shared validation cases cover null/type/unknown-field rejection, grouping
byte bounds. Existing lifecycle/SDK tests exercise old 1.0 behavior, process metadata and escaped byte bounds. Assembly tests cover no-prefix admission,
failure, timeout, event overflow and installation contracts. interleaving, replay, priority consistency, heartbeats, in-flight accounting,
idempotent/stale release, byte/frame overflow and retirement. A private TLS
fixture in `TestRemoteConnectionCommandPrivateRelay` also passes long escaped
multilingual speech through the actual source command and reader host to one
complete test-owner delivery. Existing lifecycle/SDK tests exercise old 1.0
behavior, process failure, timeout, event overflow and installation contracts.
Next: connect the policy-resolved speech worker and the external coordinator, Next: connect the policy-resolved speech worker with origin ownership,
with origin ownership, secure/secret exclusion, delivery-time policy, local secure/secret exclusion, delivery-time policy, local
priority, cancellation generations and atomic treatment of split utterances. priority and cancellation generations. Atomic IPC assembly is implemented;
it does not establish backend priority/cancel behavior or audible delivery.
Pause support or cancel-on-pause behavior and NVDA priority fidelity must be Pause support or cancel-on-pause behavior and NVDA priority fidelity must be
resolved there. The codec transports metadata without claiming backend behavior. resolved there. The codec transports metadata without claiming backend behavior.
Then verify normal reading, key echo, read-all, spelling, speech modes and local Then verify normal reading, key echo, read-all, spelling, speech modes and local
@@ -25,6 +25,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/tentacle" "git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
) )
// This uses the source-built external executable and real host/SDK IPC. Only // This uses the source-built external executable and real host/SDK IPC. Only
@@ -223,6 +224,7 @@ func TestRemoteConnectionCommandCancelsInspectionOnHandoff(t *testing.T) {
func TestRemoteConnectionCommandPrivateRelay(t *testing.T) { func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
for _, role := range []string{"controller", "controlled"} { for _, role := range []string{"controller", "controlled"} {
t.Run(role, func(t *testing.T) { t.Run(role, func(t *testing.T) {
remoteText := " " + strings.Repeat("é\x00世界 ", 1500) + " "
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -274,12 +276,39 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Error("invalid relay join or wrong selected role") t.Error("invalid relay join or wrong selected role")
return return
} }
io.WriteString(connection, `{"type":"channel_joined","channel":"synthetic-fixture","clients":[]}`+"\n") peerRole := "master"
if role == "controller" {
peerRole = "slave"
}
io.WriteString(connection, `{"type":"channel_joined","channel":"synthetic-fixture","clients":[{"id":7,"connection_type":"`+peerRole+`"}]}`+"\n")
if role == "controller" {
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) close(joined)
io.Copy(io.Discard, reader) io.Copy(io.Discard, reader)
close(closed) close(closed)
}() }()
runtime, forms, _ := remote_command_fixture(t) runtime, forms, _ := remote_command_fixture(t)
// Only this fixture supplies a reader owner. Production remains
// disabled until output arbitration/security policy is implemented.
var deliveries <-chan tentacle.RemoteSpeechDelivery
if role == "controller" {
runtime.mutex.Lock()
for _, command := range runtime.commands {
if command.id == "remote" {
deliveries = command.session.EnableRemoteSpeech()
break
}
}
runtime.mutex.Unlock()
if deliveries == nil {
t.Fatal("Remote fixture session unavailable")
}
}
consent := select_remote_relay(t, forms, listener.Addr().String(), role) consent := select_remote_relay(t, forms, listener.Addr().String(), role)
credentials := approve_remote_form(t, forms, consent, role) credentials := approve_remote_form(t, forms, consent, role)
credentials.reply <- internalUIResult{Activated: "inspect-certificate"} credentials.reply <- internalUIResult{Activated: "inspect-certificate"}
@@ -305,6 +334,40 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
if status.form.Title != "Remote relay status" { if status.form.Title != "Remote relay status" {
t.Fatal("missing connection status") t.Fatal("missing connection status")
} }
var delivery tentacle.RemoteSpeechDelivery
if role == "controller" {
select {
case delivery = <-deliveries:
case <-time.After(5 * time.Second):
t.Fatal("split relay speech did not reach the reader owner")
}
var text strings.Builder
ends := 0
for _, speech := range delivery.Speeches {
if !tentaclewire.ValidRemoteSpeech(speech) || speech.Priority != 2 {
t.Fatal("invalid speech fragment or changed priority")
}
for _, part := range speech.Parts {
if part.End {
ends++
} else {
if !part.Characters || part.Language != "fr" {
t.Fatal("split speech lost character/language state")
}
text.WriteString(part.Text)
}
}
}
if len(delivery.Speeches) < 2 || text.String() != remoteText || ends != 1 {
t.Fatal("reader owner received partial or altered utterance")
}
select {
case <-deliveries:
t.Fatal("one relay utterance became multiple deliveries")
default:
}
delivery.Release()
}
status.reply <- internalUIResult{Activated: "cancel"} status.reply <- internalUIResult{Activated: "cancel"}
select { select {
case <-closed: case <-closed:
@@ -312,6 +375,9 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
t.Fatal("Disconnect and close retained the relay") t.Fatal("Disconnect and close retained the relay")
} }
waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 }) waitTentacle(t, func() bool { return len(runtime.resultSlots) == 0 })
if role == "controller" && delivery.Context.Err() == nil {
t.Fatal("Disconnect retained remote delivery authority")
}
}) })
} }
} }
+16 -11
View File
@@ -27,17 +27,20 @@ func (session *Session) RemoteGrants() []string { return slices.Clone(session.sp
// Owned by Session.mutex. No network identity or secret belongs here. // Owned by Session.mutex. No network identity or secret belongs here.
type remoteAccess struct { type remoteAccess struct {
action string action string
lease string lease string
epoch uint64 epoch uint64
sequence uint64 sequence uint64
role string role string
grants []string grants []string
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
deadline time.Time deadline time.Time
timer *time.Timer timer *time.Timer
stopPolicy func() bool stopPolicy func() bool
speechBatch *remoteSpeechBatch
speechItems int
speechBytes int
} }
// AuthorizeRemote is the unrestricted protocol-fixture hook. Reader managers // AuthorizeRemote is the unrestricted protocol-fixture hook. Reader managers
@@ -160,6 +163,8 @@ func (session *Session) revoke_remote_locked(reason string, notify bool) {
} }
session.remote = nil session.remote = nil
access.cancel() access.cancel()
access.speechBatch = nil
access.speechItems, access.speechBytes = 0, 0
if session.remoteSpeech != nil { if session.remoteSpeech != nil {
drain: drain:
for { for {
+52 -4
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"slices" "slices"
"sync"
"time" "time"
"git.stormux.org/storm/cthulhu/internal/tentaclewire" "git.stormux.org/storm/cthulhu/internal/tentaclewire"
@@ -19,7 +20,18 @@ import (
type RemoteSpeechDelivery struct { type RemoteSpeechDelivery struct {
Context context.Context Context context.Context
Lease tentaclewire.RemoteLease Lease tentaclewire.RemoteLease
Speech tentaclewire.RemoteSpeech // 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
// Release returns the retained frame/byte budget after delivery or discard.
// It is idempotent and safe after revocation. Dequeue alone frees no budget.
Release func()
}
type remoteSpeechBatch struct {
id uint64
speeches []tentaclewire.RemoteSpeech
bytes int
} }
// EnableRemoteSpeech installs one bounded mailbox, explicitly from reader code. // EnableRemoteSpeech installs one bounded mailbox, explicitly from reader code.
@@ -67,7 +79,7 @@ func (session *Session) PublishRemoteSpeech(lease tentaclewire.RemoteLease, orig
func (session *Session) remote_present_locked(params json.RawMessage) (any, *RPCError) { func (session *Session) remote_present_locked(params json.RawMessage) (any, *RPCError) {
var request tentaclewire.RemotePresentation var request tentaclewire.RemotePresentation
if decode_strict(params, &request) != nil || !tentaclewire.ValidRemoteSpeech(request.Speech) || len(request.Lease) != 32 || request.Epoch == 0 || request.Sequence == 0 { if decode_strict(params, &request) != nil {
return nil, &RPCError{Code: -32602, Message: "Invalid session parameters"} return nil, &RPCError{Code: -32602, Message: "Invalid session parameters"}
} }
lease := tentaclewire.RemoteLease{Lease: request.Lease, Epoch: request.Epoch} lease := tentaclewire.RemoteLease{Lease: request.Lease, Epoch: request.Epoch}
@@ -78,9 +90,45 @@ func (session *Session) remote_present_locked(params json.RawMessage) (any, *RPC
if session.remoteSpeech == nil { if session.remoteSpeech == nil {
return nil, &RPCError{Code: -32601, Message: "Remote speech owner unavailable"} return nil, &RPCError{Code: -32601, Message: "Remote speech owner unavailable"}
} }
batch := access.speechBatch
if batch == nil {
if request.Utterance != 0 && request.Utterance != request.Sequence {
return nil, &RPCError{Code: -32602, Message: "Invalid utterance assembly"}
}
batch = &remoteSpeechBatch{id: request.Utterance}
} 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.
return nil, &RPCError{Code: -32602, Message: "Invalid utterance assembly"}
}
data, _ := json.Marshal(request.Speech)
if access.speechItems == tentaclewire.RemoteSpeechQueueSize || access.speechBytes+len(data) > tentaclewire.RemoteSpeechQueueBytes {
session.revoke_remote_locked("local", true)
return nil, &RPCError{Code: -32002, Message: "Remote speech queue full"}
}
access.speechItems++
access.speechBytes += len(data)
batch.speeches = append(batch.speeches, request.Speech)
batch.bytes += len(data)
access.sequence = request.Sequence
if request.Utterance != 0 && !request.Final {
access.speechBatch = batch
return struct{}{}, nil
}
access.speechBatch = nil
var released sync.Once
delivery := RemoteSpeechDelivery{Context: access.ctx, Lease: lease, Speeches: batch.speeches, Release: func() {
released.Do(func() {
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remote == access {
access.speechItems -= len(batch.speeches)
access.speechBytes -= batch.bytes
}
})
}}
select { select {
case session.remoteSpeech <- RemoteSpeechDelivery{Context: access.ctx, Lease: lease, Speech: request.Speech}: case session.remoteSpeech <- delivery:
access.sequence = request.Sequence
return struct{}{}, nil return struct{}{}, nil
default: default:
// A dropped cancel would let stale speech outlive the intended stream. // A dropped cancel would let stale speech outlive the intended stream.
+211 -3
View File
@@ -5,14 +5,27 @@ package tentacle
import ( import (
"encoding/json" "encoding/json"
"reflect"
"strings"
"testing" "testing"
"time"
"git.stormux.org/storm/cthulhu/internal/tentaclewire" "git.stormux.org/storm/cthulhu/internal/tentaclewire"
) )
func speech_fixture_lease(t *testing.T, session *Session, role string, grants []string) tentaclewire.RemoteLease { func speech_fixture_lease(t *testing.T, session *Session, role string, grants []string) tentaclewire.RemoteLease {
t.Helper() t.Helper()
action, err := session.AuthorizeRemote(role, grants) var action tentaclewire.SessionEvent
var err error
if session.manager.remotePolicy == nil {
action, err = session.AuthorizeRemote(role, grants)
} else {
approval, failure := session.BeginRemoteApproval(role, grants)
if failure != nil {
t.Fatal(failure)
}
action, err = session.AuthorizeRemoteWithApproval(approval, role, grants)
}
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -43,7 +56,7 @@ func TestRemoteSpeechSDKDeliveryAndRevocation(t *testing.T) {
t.Fatal(code) t.Fatal(code)
} }
first := <-deliveries first := <-deliveries
if first.Speech.Parts[0] != request.Speech.Parts[0] || first.Context.Err() != nil { if first.Speeches[0].Parts[0] != request.Speech.Parts[0] || first.Context.Err() != nil {
t.Fatal("changed speech or invalid lifetime") t.Fatal("changed speech or invalid lifetime")
} }
if _, code := remoteCall(t, session, "remote.present", request); code != -32001 { if _, code := remoteCall(t, session, "remote.present", request); code != -32001 {
@@ -60,7 +73,7 @@ func TestRemoteSpeechSDKDeliveryAndRevocation(t *testing.T) {
if _, code := remoteCall(t, session, "remote.present", request); code != 0 { if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code) t.Fatal(code)
} }
if (<-deliveries).Speech.Kind != "cancel" || (<-deliveries).Speech.Kind != "pause" { if (<-deliveries).Speeches[0].Kind != "cancel" || (<-deliveries).Speeches[0].Kind != "pause" {
t.Fatal("lost cancel ordering") t.Fatal("lost cancel ordering")
} }
// Heartbeat shares the operation sequence, but speech is not a heartbeat. // Heartbeat shares the operation sequence, but speech is not a heartbeat.
@@ -149,3 +162,198 @@ func TestRemoteSpeechQueueOverflowRetiresLease(t *testing.T) {
default: default:
} }
} }
func TestRemoteSpeechSDKAtomicUtterance(t *testing.T) {
for _, language := range []string{"go", "python"} {
t.Run(language, func(t *testing.T) {
session := startSessionSDK(t, test_manager(t, DefaultLimits(), nil), language, "speech")
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
deliveries := session.EnableRemoteSpeech()
frames := []tentaclewire.RemoteSpeech{
{Kind: "speak", Priority: 2, Parts: []tentaclewire.RemoteSpeechPart{{Text: " prefix\t", Characters: true, Language: "fr"}}},
{Kind: "speak", Priority: 2, Parts: []tentaclewire.RemoteSpeechPart{{Text: "世界 "}}},
{Kind: "speak", Priority: 2, Parts: []tentaclewire.RemoteSpeechPart{{End: true}}},
}
request := tentaclewire.RemotePresentation{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}, Utterance: 1, Speech: frames[0]}
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code)
}
request.Sequence++
if _, code := remoteCall(t, session, "remote.heartbeat", request.RemoteOperation); code != 0 {
t.Fatal("heartbeat could not run during assembly", code)
}
request.Sequence++
request.Speech = frames[1]
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code)
}
select {
case <-deliveries:
t.Fatal("exposed incomplete utterance")
default:
}
if _, code := remoteCall(t, session, "remote.present", request); code != -32001 {
t.Fatal("accepted fragment replay", code)
}
request.Sequence++
for _, invalid := range []tentaclewire.RemotePresentation{
{RemoteOperation: request.RemoteOperation, Speech: tentaclewire.RemoteSpeech{Kind: "cancel"}},
{RemoteOperation: request.RemoteOperation, Speech: frames[2], Utterance: request.Sequence, Final: true},
{RemoteOperation: request.RemoteOperation, Speech: tentaclewire.RemoteSpeech{Kind: "speak", Parts: []tentaclewire.RemoteSpeechPart{{Text: "priority changed"}}}, Utterance: 1, Final: true},
} {
if _, code := remoteCall(t, session, "remote.present", invalid); code != -32602 {
t.Fatal("accepted interleaving or changed priority", code)
}
}
request.Speech, request.Final = frames[2], true
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code)
}
delivery := <-deliveries
if !reflect.DeepEqual(delivery.Speeches, frames) || delivery.Lease != lease || delivery.Context.Err() != nil {
t.Fatal("assembly changed semantics or ownership")
}
request.Sequence++
request.Utterance, request.Final, request.Speech = 0, false, tentaclewire.RemoteSpeech{Kind: "cancel"}
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code)
}
cancel := <-deliveries
if len(cancel.Speeches) != 1 || cancel.Speeches[0].Kind != "cancel" {
t.Fatal("cancel lost ordering")
}
session.mutex.Lock()
if session.remote.speechItems != 4 || session.remote.speechBatch != nil {
t.Error("dequeue released in-flight budget")
}
session.mutex.Unlock()
delivery.Release()
delivery.Release()
cancel.Release()
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remote.speechItems != 0 || session.remote.speechBytes != 0 {
t.Fatal("release leaked budget or counted twice")
}
})
}
}
func TestRemoteSpeechSDKAssemblyRetirement(t *testing.T) {
for _, mode := range []string{"revoke", "replace", "close", "expiry", "policy"} {
t.Run(mode, func(t *testing.T) {
manager := test_manager(t, DefaultLimits(), nil)
policy := NewRemotePolicy()
if mode == "policy" {
if err := manager.UseRemotePolicy(policy); err != nil {
t.Fatal(err)
}
}
session := startSessionSDK(t, manager, "go", "speech")
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
deliveries := session.EnableRemoteSpeech()
request := tentaclewire.RemotePresentation{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}, Speech: tentaclewire.RemoteSpeech{Kind: "cancel"}}
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code)
}
inFlight := <-deliveries
request.Sequence, request.Utterance = 2, 2
request.Speech = tentaclewire.RemoteSpeech{Kind: "speak", Parts: []tentaclewire.RemoteSpeechPart{{Text: "never a prefix"}}}
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code)
}
session.mutex.Lock()
old := session.remote
session.mutex.Unlock()
switch mode {
case "revoke":
session.RevokeRemote()
case "replace":
speech_fixture_lease(t, session, "controller", []string{"remote-present"})
case "close":
request.Sequence++
if _, code := remoteCall(t, session, "remote.close", request.RemoteOperation); code != 0 {
t.Fatal(code)
}
case "expiry":
session.mutex.Lock()
session.refresh_remote_locked(time.Millisecond)
session.mutex.Unlock()
wait_signal(t, old.ctx.Done())
case "policy":
// Cancellation cannot wait for the session mutex; lock/handoff
// invalidates work before asynchronous mailbox cleanup.
session.mutex.Lock()
policy.SetAvailable(false)
cancelled := old.ctx.Err() != nil
session.mutex.Unlock()
if !cancelled {
t.Fatal("policy cancellation waited for the session")
}
invokeSessionSDK(t, session, "event", nil) // Wait for retirement notification.
}
inFlight.Release() // A stale release must not debit a replacement lease.
if inFlight.Context.Err() == nil {
t.Fatal("in-flight delivery survived retirement")
}
request.Sequence++
request.Final = true
if _, code := remoteCall(t, session, "remote.present", request); code != -32001 {
t.Fatal("stale fragment committed after retirement", code)
}
session.mutex.Lock()
defer session.mutex.Unlock()
if old.speechBatch != nil || old.speechItems != 0 || old.speechBytes != 0 || len(deliveries) != 0 {
t.Fatal("retired assembly retained work")
}
if session.remote != nil && (session.remote.speechItems != 0 || session.remote.speechBytes != 0) {
t.Fatal("stale release changed replacement budget")
}
})
}
}
func TestRemoteSpeechSDKAssemblyBudget(t *testing.T) {
for _, limit := range []string{"items", "bytes"} {
t.Run(limit, func(t *testing.T) {
session := startSessionSDK(t, test_manager(t, DefaultLimits(), nil), "python", "speech")
lease := speech_fixture_lease(t, session, "controller", []string{"remote-present"})
deliveries := session.EnableRemoteSpeech()
text := "x"
if limit == "bytes" {
text = strings.Repeat("x", 5800)
}
request := tentaclewire.RemotePresentation{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: 1}, Speech: tentaclewire.RemoteSpeech{Kind: "speak", Parts: []tentaclewire.RemoteSpeechPart{{Text: text}}}}
if _, code := remoteCall(t, session, "remote.present", request); code != 0 {
t.Fatal(code)
}
inFlight := <-deliveries // Retained until Release, even outside the mailbox.
session.mutex.Lock()
old := session.remote
session.mutex.Unlock()
request.Utterance = 2
for index := 2; index <= tentaclewire.RemoteSpeechQueueSize+1; index++ {
request.Sequence = uint64(index)
_, code := remoteCall(t, session, "remote.present", request)
if code == -32002 {
if limit == "items" && index != tentaclewire.RemoteSpeechQueueSize+1 || limit == "bytes" && index >= tentaclewire.RemoteSpeechQueueSize {
t.Fatal("wrong bound fired", index)
}
break
}
if code != 0 || index == tentaclewire.RemoteSpeechQueueSize+1 {
t.Fatal("assembly overflow did not fail closed", index, code)
}
}
if inFlight.Context.Err() == nil {
t.Fatal("overflow left lease active")
}
inFlight.Release()
session.mutex.Lock()
defer session.mutex.Unlock()
if session.remote != nil || old.speechBatch != nil || old.speechItems != 0 || old.speechBytes != 0 || len(deliveries) != 0 {
t.Fatal("overflow exposed a prefix or retained work")
}
})
}
}
+23 -1
View File
@@ -12,6 +12,7 @@ import (
// Leave room for lease metadata inside the existing 8192-byte call bound. // Leave room for lease metadata inside the existing 8192-byte call bound.
const MaxRemoteSpeechBytes = 6144 const MaxRemoteSpeechBytes = 6144
const RemoteSpeechQueueSize = 64 const RemoteSpeechQueueSize = 64
const RemoteSpeechQueueBytes = 256 * 1024
// RemoteSpeechPart carries resolved character/language state for each fragment. // RemoteSpeechPart carries resolved character/language state for each fragment.
// End is an utterance boundary, not text. Whitespace is significant. // End is an utterance boundary, not text. Whitespace is significant.
@@ -31,7 +32,28 @@ type RemoteSpeech struct {
type RemotePresentation struct { type RemotePresentation struct {
RemoteOperation RemoteOperation
Speech RemoteSpeech `json:"speech"` Speech RemoteSpeech `json:"speech"`
Utterance uint64 `json:"utterance,omitempty"` // First fragment's operation sequence.
Final bool `json:"final,omitempty"`
}
// A zero utterance is a complete, single-call effect. A nonzero utterance
// stages speak fragments until Final; the reader admits them as one delivery.
func ValidRemotePresentation(request RemotePresentation) bool {
return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0 &&
ValidRemoteSpeech(request.Speech) &&
(request.Utterance == 0 && !request.Final ||
request.Utterance > 0 && request.Utterance <= request.Sequence && request.Speech.Kind == "speak")
}
func (request *RemotePresentation) UnmarshalJSON(data []byte) error {
type plain RemotePresentation
var value plain
if reject_speech_nulls(data) != nil || DecodeStrict(data, &value) != nil || !ValidRemotePresentation(RemotePresentation(value)) {
return ErrProtocol
}
*request = RemotePresentation(value)
return nil
} }
func reject_speech_nulls(data []byte) error { func reject_speech_nulls(data []byte) error {
@@ -90,3 +90,63 @@ func TestRemoteSpeechEncodedBoundary(t *testing.T) {
t.Fatal("accepted invalid UTF-8") t.Fatal("accepted invalid UTF-8")
} }
} }
func TestRemoteSpeechPresentationSDKValidation(t *testing.T) {
base := `"lease":"` + strings.Repeat("a", 32) + `","epoch":1,"sequence":3,"speech":{"kind":"speak","parts":[{"text":"x"}]}`
cases := []struct {
fields string
valid bool
}{
{"", true},
{`,"utterance":3`, true},
{`,"utterance":1,"final":true`, true},
{`,"utterance":0,"final":false`, true},
{`,"utterance":4`, false},
{`,"utterance":-1`, false},
{`,"utterance":true`, false},
{`,"utterance":null`, false},
{`,"utterance":18446744073709551616`, false},
{`,"final":true`, false},
{`,"utterance":1,"final":1`, false},
{`,"utterance":1,"final":null`, false},
{`,"extra":1`, false},
}
var pythonCases []any
for _, item := range cases {
data := []byte(`{` + base + item.fields + `}`)
var request RemotePresentation
if valid := DecodeStrict(data, &request) == nil && ValidRemotePresentation(request); valid != item.valid {
t.Fatalf("Go presentation validation: %s: got %v", item.fields, valid)
}
// Use RawMessage when encoding the cases for Python, preserving uint64
// boundaries rather than passing them through float64.
pythonCases = append(pythonCases, map[string]any{"request": json.RawMessage(data), "valid": item.valid})
}
for _, kind := range []string{"cancel", "pause"} {
request := RemotePresentation{RemoteOperation: RemoteOperation{Lease: strings.Repeat("a", 32), Epoch: 1, Sequence: 1}, Utterance: 1, Speech: RemoteSpeech{Kind: kind}}
if kind == "pause" {
paused := true
request.Speech.Paused = &paused
}
if ValidRemotePresentation(request) {
t.Fatal("grouped non-speak effect")
}
pythonCases = append(pythonCases, map[string]any{"request": request, "valid": false})
}
path, err := filepath.Abs("../../sdk/python")
if err != nil {
t.Fatal(err)
}
script := `import json, sys
sys.path.insert(0, sys.argv[1])
from cthulhu_tentacle.session import valid_remote_presentation
for case in json.load(sys.stdin):
assert valid_remote_presentation(case['request']) == case['valid'], str(case)[:180]
`
data, _ := json.Marshal(pythonCases)
command := exec.Command("python3", "-B", "-c", script, path)
command.Stdin = strings.NewReader(string(data))
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("Python presentation validation: %v: %s", err, output)
}
}
+4
View File
@@ -29,6 +29,10 @@ const MaxRemoteSpeechBytes = tentaclewire.MaxRemoteSpeechBytes
func ValidRemoteSpeech(speech RemoteSpeech) bool { return tentaclewire.ValidRemoteSpeech(speech) } func ValidRemoteSpeech(speech RemoteSpeech) bool { return tentaclewire.ValidRemoteSpeech(speech) }
func ValidRemotePresentation(request RemotePresentation) bool {
return tentaclewire.ValidRemotePresentation(request)
}
type SessionError struct { type SessionError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
+15
View File
@@ -87,6 +87,21 @@ def valid_remote_speech(speech):
return False return False
def valid_remote_presentation(request):
"""Validate one speech call; assembly and lease checks belong to core."""
try:
_fields(request, ("lease", "epoch", "sequence", "speech"), ("utterance", "final"))
utterance, final = request.get("utterance", 0), request.get("final", False)
return (_token(request["lease"]) and _positive(request["epoch"])
and _positive(request["sequence"]) and valid_remote_speech(request["speech"])
and type(utterance) is int and 0 <= utterance < 2**64
and type(final) is bool
and (utterance == 0 and not final
or 0 < utterance <= request["sequence"] and request["speech"]["kind"] == "speak"))
except (ProtocolError, TypeError, ValueError, UnicodeError):
return False
class SessionError(Exception): class SessionError(Exception):
def __init__(self, code, message): def __init__(self, code, message):
super().__init__("Tentacle session error " + str(code)) super().__init__("Tentacle session error " + str(code))