Files
cthulhu/internal/app/tentacle_output.go

145 lines
4.2 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"sync"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type tentacleOutputTicket struct {
ctx context.Context
session context.Context
speechGeneration uint64
speechRevision uint64
brailleSequence uint64
}
// tentacleOutput owns one explicit command's presentation lifetime. Any later
// command, foreground/mode transition or accessibility event retires it. This
// deliberately conservative revision gate exposes no accessible text/positions.
// Speech's interruption generation and braille's frame sequence are independent.
// Lock order: runtime, focus gate, output owner, then speech/braille owner.
type tentacleOutput struct {
mutex sync.Mutex
speech *speechWorker
braille *brailleWorker
gate *focusGate
live *liveSettings
current context.Context
cancel context.CancelFunc
}
func (owner *tentacleOutput) Capture(parent context.Context) tentacleOutputTicket {
if owner == nil {
return tentacleOutputTicket{}
}
owner.mutex.Lock()
defer owner.mutex.Unlock()
owner.retireLocked()
ctx, cancel := context.WithCancel(parent)
owner.current, owner.cancel = ctx, cancel
ticket := tentacleOutputTicket{ctx: ctx, session: parent}
if owner.speech != nil {
owner.speech.mutex.Lock()
ticket.speechGeneration = owner.speech.generation
ticket.speechRevision = owner.speech.presentationRevision
owner.speech.mutex.Unlock()
}
if owner.braille != nil {
owner.braille.mu.Lock()
ticket.brailleSequence = owner.braille.sequence
owner.braille.mu.Unlock()
}
return ticket
}
func (owner *tentacleOutput) Retire() {
if owner == nil {
return
}
owner.mutex.Lock()
defer owner.mutex.Unlock()
owner.retireLocked()
}
// Stop additionally revokes accepted protected speech during reader shutdown
// or terminal handoff. Ordinary focus/command retirement only retires replies.
func (owner *tentacleOutput) Stop() {
if owner == nil {
return
}
owner.mutex.Lock()
defer owner.mutex.Unlock()
owner.retireLocked()
if owner.speech != nil {
owner.speech.stop_uninterruptible()
}
}
func (owner *tentacleOutput) RetireRevoked() {
if owner == nil {
return
}
owner.mutex.Lock()
defer owner.mutex.Unlock()
if owner.current != nil && owner.current.Err() != nil {
owner.retireLocked()
}
}
func (owner *tentacleOutput) retireLocked() {
if owner.cancel == nil {
return
}
owner.cancel()
if owner.speech != nil {
owner.speech.revokePresentation(owner.current)
}
if owner.braille != nil {
owner.braille.retirePresentation(owner.current)
}
owner.current, owner.cancel = nil, nil
}
func (owner *tentacleOutput) Present(ticket tentacleOutputTicket, value tentaclewire.Presentation, command string) {
if owner == nil || ticket.ctx == nil {
return
}
if owner.gate != nil {
owner.gate.mutex.Lock()
defer owner.gate.mutex.Unlock()
}
owner.mutex.Lock()
defer owner.mutex.Unlock()
if ticket.ctx != owner.current || ticket.ctx.Err() != nil || owner.gate.Locked() || owner.gate.Suspended() {
return
}
if value.Speech != "" && owner.speech != nil {
request := speechRequest{Text: value.Speech, Role: "system", Cause: command,
context: ticket.ctx, presentationGeneration: &ticket.speechGeneration, presentationRevision: &ticket.speechRevision}
if value.Uninterruptible {
// Admission still checks the ticket; accepted speech belongs to the
// session so subsequent focus/command changes cannot cancel it.
request.Uninterruptible = true
request.context, request.cancel = context.WithTimeout(ticket.session, uninterruptibleSpeechTimeout)
}
owner.speech.Enqueue(request)
}
if value.Braille != "" && owner.braille != nil && owner.live != nil {
settings := owner.live.Load()
if settings.EnableBraille {
if owner.braille.flash_message_guarded(value.Braille, settings.Braille, false, ticket.ctx, ticket.brailleSequence) {
owner.braille.mu.Lock()
if owner.braille.flashContext == ticket.ctx {
owner.braille.logger.Debug("queued tentacle braille message", "command", command,
"frame_id", owner.braille.frame.ID, "braille_text", owner.braille.frame.Text)
}
owner.braille.mu.Unlock()
}
}
}
}