// 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, runtime.logger) 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 }