Files
cthulhu/internal/app/tentacle_startup.go
ak47 49be8309fe 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.
2026-11-05 15:41:08 -05:00

203 lines
6.3 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)
go func() {
defer close(done)
defer func() { <-speechDone }()
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
}