Files
cthulhu/internal/app/tentacle_startup.go
Chase CichorzandClaude Opus 5.5 551ef86d5f Export and display Remote Braille through the reader
A controlled reader renders accepted application frames in a new
virtual-only cthulhu-braille mode at the controller's width and applies
its pan and route gestures; reader UI, flashes and secure frames are
never exported, and hidden frames are unroutable. A controller reader
reports its display width, shows peer cells as an untranslated raw
frame and forwards local pan and routing keys. The physical helper now
reports its width and edge pans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:06:38 -05:00

206 lines
6.5 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"errors"
"slices"
"git.stormux.org/storm/cthulhu/internal/tentacle"
)
func (owner *tentacleLifecycle) ensureManager() error {
if owner.manager != nil {
return nil
}
if owner.runtime.input == nil {
return errors.New("native bindings are unavailable")
}
var err error
owner.manager, err = owner.runtime.makeManager()
return err
}
func (owner *tentacleLifecycle) revoke(id string) {
if session := owner.sessions[id]; session != nil {
session.Disable()
owner.runtime.output.RetireRevoked()
}
}
func (owner *tentacleLifecycle) replyStartup(id string, err error) {
startup, exists := owner.startups[id]
if !exists {
return
}
delete(owner.startups, id)
startup.stop()
if err == nil {
if startup.previous != nil {
previous := *startup.previous
// Starting the replacement already waited for the old session.
owner.removedPackages[previous.PackageDirectory] = tentacleRetiredPackage{entry: previous}
}
err = startup.saveError
}
owner.setSnapshot()
startup.call.result <- err
}
func (owner *tentacleLifecycle) failStartup(id, message string) {
owner.problems[id] = message
startup, exists := owner.startups[id]
if !exists {
return
}
owner.revoke(id)
if owner.current != nil && owner.current.TentacleID() == id {
owner.current, owner.ready = nil, nil
}
delete(owner.pending, id)
next := tentacle.CloneEntries(owner.entries)
for index := range next {
if next[index].ID == id {
if startup.previous != nil {
next[index] = *startup.previous
} else {
next[index].Enabled = false
}
}
}
err := owner.runtime.registry.Save(next)
if err == nil || errors.Is(err, tentacle.ErrRegistrySync) {
if startup.previous != nil {
failed := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id })
if failed >= 0 {
entry := owner.entries[failed]
owner.removedPackages[entry.PackageDirectory] = tentacleRetiredPackage{entry: entry, session: owner.sessions[id]}
}
owner.runtime.mutex.Lock()
disabled := owner.runtime.disabled
owner.runtime.mutex.Unlock()
owner.pending[id] = startup.previous.Enabled && !disabled && owner.ctx.Err() == nil
message += " The previous package was restored."
}
owner.entries = next
}
if err != nil {
message += " The recovery state could not be saved durably: " + err.Error()
}
owner.replyStartup(id, errors.New(message))
}
func (owner *tentacleLifecycle) cancelStartups() {
for id := range owner.startups {
owner.failStartup(id, "Tentacle startup was cancelled before activation.")
}
}
func (owner *tentacleLifecycle) startPending() {
for _, entry := range owner.entries {
if !owner.pending[entry.ID] || owner.sessions[entry.ID] != nil {
continue
}
owner.pending[entry.ID] = false
err := owner.ensureManager()
var session *tentacle.Session
if err == nil {
session, err = owner.manager.Start(owner.ctx, entry.Spec())
}
if err != nil {
owner.failStartup(entry.ID, "Tentacle could not start; check resource containment and its program, then enable it again.")
owner.runtime.logger.Warn("start configured tentacle", "tentacle", entry.ID, "error", err)
owner.runtime.report("A tentacle could not start.")
owner.notify()
break
}
owner.sessions[entry.ID] = session
owner.runtime.mutex.Lock()
owner.runtime.sessions = append(owner.runtime.sessions, session)
if owner.runtime.disabled {
session.Disable()
}
owner.runtime.mutex.Unlock()
owner.current, owner.ready = session, session.Ready()
done := make(chan struct{})
// Prune joined watcher handles so retries never accumulate history.
owner.watchers = slices.DeleteFunc(owner.watchers, func(done <-chan struct{}) bool {
select {
case <-done:
return true
default:
return false
}
})
owner.watchers = append(owner.watchers, done)
speechDone := owner.runtime.start_remote_speech(owner.ctx, session)
keysDone := owner.runtime.start_remote_keyboard(owner.ctx, session)
captureDone := owner.runtime.start_remote_capture(owner.ctx, session)
brailleDone := owner.runtime.start_remote_braille(owner.ctx, session)
go func() {
defer close(done)
defer func() { <-speechDone; <-keysDone; <-captureDone; <-brailleDone }()
select {
case <-session.Revoked():
case <-owner.ctx.Done():
}
owner.runtime.output.RetireRevoked()
owner.runtime.signal_extensions()
owner.notify()
<-session.Done()
owner.notify()
}()
break
}
}
func (owner *tentacleLifecycle) activateReady() error {
session := owner.current
owner.current, owner.ready = nil, nil
if state, _ := session.Status(); state != tentacle.Ready {
owner.failStartup(session.TentacleID(), "Tentacle failed during startup; check its program and resource limits, then enable it again.")
return nil
}
if startup, exists := owner.startups[session.TentacleID()]; exists && startup.call.ctx.Err() != nil {
owner.failStartup(session.TentacleID(), "Tentacle startup was cancelled before activation.")
return nil
}
candidate := append(slices.Clone(owner.active), session)
if err := owner.runtime.bind(owner.ctx, candidate); err != nil {
session.Disable()
owner.runtime.logger.Warn("configured tentacle bindings rejected", "tentacle", session.TentacleID(), "error", err)
owner.runtime.report("A tentacle could not acquire its keyboard shortcuts.")
owner.failStartup(session.TentacleID(), "Tentacle could not acquire its keyboard shortcuts; enable it again after resolving the conflict.")
return nil
}
// Native binding setup can wait for held keys. Cancellation during
// that wait must roll back the new grabs before reporting failure.
activationError := owner.ctx.Err()
if startup, exists := owner.startups[session.TentacleID()]; exists && activationError == nil {
activationError = startup.call.ctx.Err()
}
if activationError == nil {
activationError = session.Activate()
}
if activationError != nil {
session.Disable()
if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil {
select {
case owner.runtime.failures <- err:
default:
}
return err
}
owner.failStartup(session.TentacleID(), "Tentacle stopped before activation; enable it again to retry.")
return nil
}
owner.active = candidate
delete(owner.problems, session.TentacleID())
owner.runtime.publishCommands(owner.active)
owner.replyStartup(session.TentacleID(), nil)
owner.runtime.logger.Info("configured tentacle active", "tentacle", session.TentacleID(), "session", session.Identity())
return nil
}