Files
cthulhu/internal/app/tentacle_lifecycle.go
2026-11-01 22:01:32 -05:00

269 lines
8.0 KiB
Go

// 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"
)
// A startup request is owned by the lifecycle worker until activation or failure.
// Cancellation only wakes that worker; it never mutates registry or session state.
type tentacleStartupCall struct {
call tentacleManagementCall
saveError error
stop func() bool
previous *tentacle.Entry
}
// A retired package stays on disk until the session using it has joined.
type tentacleRetiredPackage struct {
entry tentacle.Entry
session *tentacle.Session
}
// tentacleLifecycle owns managed state for one run, on the lifecycle goroutine.
// Watchers and cancellation callbacks only notify it; they never mutate these
// maps or transitions. Startup rollback runs before shutdown joins sessions.
type tentacleLifecycle struct {
ctx context.Context
runtime *tentacleRuntime
entries []tentacle.Entry
repositories []tentacle.Repository
loadError error
pending map[string]bool
sessions map[string]*tentacle.Session
problems map[string]string
startups map[string]tentacleStartupCall
removedPackages map[string]tentacleRetiredPackage
wake chan struct{}
manager *tentacle.Manager
current *tentacle.Session
ready <-chan struct{}
active []*tentacle.Session
watchers []<-chan struct{}
}
func (runtime *tentacleRuntime) run_managed(ctx context.Context) error {
entries, loadError := runtime.registry.Load()
owner := &tentacleLifecycle{
ctx: ctx,
runtime: runtime,
entries: entries,
loadError: loadError,
repositories: runtime.registry.Repositories(),
pending: make(map[string]bool),
sessions: make(map[string]*tentacle.Session),
problems: make(map[string]string),
startups: make(map[string]tentacleStartupCall),
removedPackages: make(map[string]tentacleRetiredPackage),
wake: make(chan struct{}, 1),
}
if loadError == nil {
if err := tentacle.RecoverPackages(tentacle.PackageRoot(runtime.registry.Path), entries); err != nil {
runtime.logger.Warn("recover tentacle packages", "error", err)
}
}
for _, entry := range entries {
owner.pending[entry.ID] = entry.Enabled
}
owner.setSnapshot()
if loadError != nil {
runtime.report("Tentacles could not start.")
}
defer owner.shutdown()
defer owner.cancelStartups()
return owner.run()
}
func (owner *tentacleLifecycle) notify() {
select {
case owner.wake <- struct{}{}:
default:
}
}
func (owner *tentacleLifecycle) setSnapshot() {
result := tentacle.Snapshot{Repositories: tentacle.CloneRepositories(owner.repositories)}
if owner.loadError != nil {
result.Message = "Tentacle registry could not be loaded; no tentacles were started."
}
for _, entry := range owner.entries {
status := tentacle.EntryStatus{Entry: entry, State: "disabled", Message: owner.problems[entry.ID]}
if entry.Enabled {
status.State = "starting"
}
if session := owner.sessions[entry.ID]; session != nil {
state, _ := session.Status()
status.State = string(state)
if state == tentacle.Ready {
status.State = "starting"
}
if (state == tentacle.Failed || state == tentacle.Stopped) && entry.Enabled && status.Message == "" {
status.Message = "Tentacle stopped; retry to start it again."
}
}
if owner.pending[entry.ID] {
status.State = "starting"
}
if status.Message != "" && !owner.pending[entry.ID] {
status.State = "failed"
}
if !entry.Enabled && owner.sessions[entry.ID] == nil {
status.State = "disabled"
}
result.Entries = append(result.Entries, status)
}
result.Updates = tentacle.AvailableUpdates(result)
owner.runtime.mutex.Lock()
owner.runtime.snapshot = result
owner.runtime.mutex.Unlock()
}
func (owner *tentacleLifecycle) shutdown() {
owner.runtime.Disable()
cleanup, cancel := context.WithTimeout(context.Background(), 7*time.Second)
defer cancel()
var bindingError error
if owner.runtime.input != nil {
bindingError = owner.runtime.input.SetTentacleBindings(cleanup, nil)
}
if owner.runtime.readerContext.Err() != nil {
bindingError = nil
}
var managerError error
if owner.manager != nil {
managerError = owner.manager.Shutdown(cleanup)
}
for _, retired := range owner.removedPackages {
entry := retired.entry
if managerError == nil {
if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil {
owner.runtime.logger.Warn("remove tentacle package", "error", err)
}
}
}
for _, done := range owner.watchers {
<-done
}
owner.runtime.results.Wait()
owner.runtime.stopError = errors.Join(bindingError, managerError)
if owner.runtime.stopError != nil && owner.runtime.readerContext.Err() == nil {
select {
case owner.runtime.failures <- owner.runtime.stopError:
default:
}
}
}
func (owner *tentacleLifecycle) reconcileBindings() error {
retained := owner.active[:0]
for _, session := range owner.active {
if state, _ := session.Status(); state == tentacle.Active {
retained = append(retained, session)
}
}
changed := len(retained) != len(owner.active)
owner.active = retained
if changed {
owner.runtime.publishCommands(owner.active)
if err := owner.runtime.bind(owner.ctx, owner.active); err != nil && owner.ctx.Err() == nil {
select {
case owner.runtime.failures <- err:
default:
}
return err
}
}
return nil
}
func (owner *tentacleLifecycle) reapSessions() {
for id, session := range owner.sessions {
select {
case <-session.Done():
if state, failure := session.Status(); state == tentacle.Failed {
owner.runtime.logger.Warn("configured tentacle failed", "tentacle", id, "error", failure)
}
if !owner.pending[id] {
index := slices.IndexFunc(owner.entries, func(entry tentacle.Entry) bool { return entry.ID == id })
if index >= 0 && owner.entries[index].Enabled && owner.problems[id] == "" {
owner.problems[id] = "Tentacle stopped; retry to start it again."
owner.runtime.report("A tentacle stopped unexpectedly.")
}
}
delete(owner.sessions, id)
owner.runtime.mutex.Lock()
owner.runtime.sessions = slices.DeleteFunc(owner.runtime.sessions, func(value *tentacle.Session) bool { return value == session })
owner.runtime.mutex.Unlock()
default:
}
}
}
func (owner *tentacleLifecycle) removeRetiredPackages() {
for directory, retired := range owner.removedPackages {
entry := retired.entry
if retired.session != nil {
select {
case <-retired.session.Done():
default:
continue
}
}
if slices.ContainsFunc(owner.entries, func(candidateEntry tentacle.Entry) bool {
return candidateEntry.PackageDirectory == directory
}) {
continue
}
if err := tentacle.RemovePackage(tentacle.PackageRoot(owner.runtime.registry.Path), entry); err != nil {
owner.runtime.logger.Warn("remove tentacle package", "error", err)
owner.runtime.report("Tentacle was removed, but its package files could not be deleted.")
continue
}
delete(owner.removedPackages, directory)
}
}
func (owner *tentacleLifecycle) run() error {
for {
if owner.ctx.Err() != nil {
return nil
}
for id, startup := range owner.startups {
if startup.call.ctx.Err() != nil {
owner.failStartup(id, "Tentacle startup was cancelled before activation.")
}
}
// Reconcile revoked sessions before staging new native grabs, and release
// joined sessions before starting replacements or removing their files.
if err := owner.reconcileBindings(); err != nil {
return err
}
owner.reapSessions()
if owner.current == nil {
owner.removeRetiredPackages()
owner.startPending()
}
owner.setSnapshot()
select {
case <-owner.ctx.Done():
return nil
case <-owner.wake:
case call := <-owner.runtime.requests:
owner.handleRequest(call)
case <-owner.ready:
if err := owner.activateReady(); err != nil {
return err
}
}
}
}