// 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 }