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