425 lines
13 KiB
Go
425 lines
13 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"git.stormux.org/storm/cthulhu/internal/shortcutui"
|
|
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
type tentacleInput interface {
|
|
SetTentacleBindings(context.Context, []input.BindingOverride) error
|
|
TentacleBindings() []input.BindingOverride
|
|
}
|
|
|
|
type tentacleCommand struct {
|
|
session *tentacle.Session
|
|
entryID string
|
|
id, title, description string
|
|
presentation bool
|
|
interactive bool
|
|
settings bool
|
|
}
|
|
|
|
// tentacleRuntime owns local sessions for one reader lifetime. Its worker owns
|
|
// native publication; mutex protects dispatch snapshots and immediate disable.
|
|
// Only explicitly granted presentation commands reach the core output owner.
|
|
type tentacleRuntime struct {
|
|
forms tentacleForms
|
|
focusedItem func(context.Context) (tentaclewire.Item, error)
|
|
interactionSlots chan struct{}
|
|
registry *tentacle.Registry
|
|
requests chan tentacleManagementCall
|
|
snapshot tentacle.Snapshot
|
|
guardianProgram []string // Private fixture seam; production uses this executable.
|
|
output *tentacleOutput
|
|
logger *slog.Logger
|
|
input tentacleInput
|
|
path, parent string
|
|
newManager func() (*tentacle.Manager, error) // private fixture seam
|
|
fetchRepository func(context.Context, string) (tentacle.Repository, error) // private fixture seam
|
|
preparePackage func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) // private fixture seam
|
|
repositoryBusy bool
|
|
mutex sync.Mutex
|
|
commands map[input.Command]tentacleCommand
|
|
sessions []*tentacle.Session
|
|
disabled bool
|
|
cancel context.CancelFunc
|
|
readerContext context.Context
|
|
lifecycleContext context.Context
|
|
done chan struct{}
|
|
failures chan error
|
|
announcements chan string
|
|
stopError error
|
|
results sync.WaitGroup
|
|
resultSlots chan struct{}
|
|
}
|
|
|
|
func newTentacleRuntime(logger *slog.Logger, commands commandListener, path, parent string) *tentacleRuntime {
|
|
owner, _ := commands.(tentacleInput)
|
|
return &tentacleRuntime{logger: logger, input: owner, path: path, parent: parent,
|
|
commands: make(map[input.Command]tentacleCommand), failures: make(chan error, 1), announcements: make(chan string, 1), interactionSlots: make(chan struct{}, 1), resultSlots: make(chan struct{}, 64)}
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) Start(ctx context.Context) error {
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
runtime.readerContext = ctx
|
|
ctx, runtime.cancel = context.WithCancel(ctx)
|
|
runtime.lifecycleContext = ctx
|
|
runtime.done = make(chan struct{})
|
|
go func() {
|
|
defer close(runtime.done)
|
|
run := runtime.run
|
|
if runtime.registry != nil {
|
|
run = runtime.run_managed
|
|
}
|
|
if err := run(ctx); err != nil {
|
|
runtime.logger.Warn("tentacle startup failed", "error", err)
|
|
runtime.report("Tentacles could not start.")
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) Disable() {
|
|
if runtime == nil {
|
|
return
|
|
}
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
runtime.disabled = true
|
|
runtime.output.Stop()
|
|
clear(runtime.commands)
|
|
for _, session := range runtime.sessions {
|
|
session.Disable()
|
|
}
|
|
if runtime.cancel != nil {
|
|
runtime.cancel()
|
|
}
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) Stop(ctx context.Context) error {
|
|
if runtime == nil {
|
|
return nil
|
|
}
|
|
runtime.Disable()
|
|
if runtime.done == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-runtime.done:
|
|
return runtime.stopError
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) run(ctx context.Context) error {
|
|
if runtime.input == nil {
|
|
return errors.New("tentacles require native binding support")
|
|
}
|
|
specs, err := tentacle.LoadLocalSpecs(runtime.path)
|
|
if err != nil || len(specs) == 0 {
|
|
return err
|
|
}
|
|
var manager *tentacle.Manager
|
|
if runtime.newManager != nil {
|
|
manager, err = runtime.newManager()
|
|
} else {
|
|
parent := runtime.parent
|
|
if parent == "auto" {
|
|
parent, err = tentacle.DelegatedParent()
|
|
}
|
|
if err == nil {
|
|
manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, parent, tentacle.DefaultResourceLimits())
|
|
}
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var watchers sync.WaitGroup
|
|
defer func() {
|
|
runtime.Disable()
|
|
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
bindingError := runtime.input.SetTentacleBindings(cleanup, nil)
|
|
if runtime.readerContext.Err() != nil {
|
|
bindingError = nil
|
|
}
|
|
runtime.stopError = errors.Join(bindingError, manager.Shutdown(cleanup))
|
|
watchers.Wait()
|
|
runtime.results.Wait()
|
|
if runtime.stopError != nil && runtime.readerContext.Err() == nil {
|
|
select {
|
|
case runtime.failures <- runtime.stopError:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
wake := make(chan struct{}, 1)
|
|
var current *tentacle.Session
|
|
var ready <-chan struct{}
|
|
var active []*tentacle.Session
|
|
index := 0
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
if current == nil && index < len(specs) {
|
|
spec := specs[index]
|
|
index++
|
|
session, startError := manager.Start(ctx, spec)
|
|
if startError != nil {
|
|
runtime.logger.Warn("start local tentacle", "tentacle", spec.ID, "error", startError)
|
|
runtime.report("A tentacle could not start.")
|
|
continue
|
|
}
|
|
runtime.mutex.Lock()
|
|
runtime.sessions = append(runtime.sessions, session)
|
|
if runtime.disabled {
|
|
session.Disable()
|
|
}
|
|
runtime.mutex.Unlock()
|
|
current, ready = session, session.Ready()
|
|
watchers.Add(1)
|
|
go func() {
|
|
defer watchers.Done()
|
|
select {
|
|
case <-session.Revoked():
|
|
case <-ctx.Done():
|
|
}
|
|
runtime.output.RetireRevoked()
|
|
select {
|
|
case wake <- struct{}{}:
|
|
default:
|
|
}
|
|
}()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-wake:
|
|
retained := active[:0]
|
|
for _, session := range active {
|
|
if state, failure := session.Status(); state == tentacle.Active {
|
|
retained = append(retained, session)
|
|
} else if state == tentacle.Failed {
|
|
runtime.logger.Warn("local tentacle failed", "session", session.Identity(), "error", failure)
|
|
runtime.report("A tentacle stopped unexpectedly.")
|
|
}
|
|
}
|
|
active = retained
|
|
runtime.publishCommands(active)
|
|
if err := runtime.bind(ctx, active); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
runtime.failures <- err // Stop the reader if stale grabs cannot be removed.
|
|
return err
|
|
}
|
|
case <-ready:
|
|
session := current
|
|
current, ready = nil, nil
|
|
if state, failure := session.Status(); state != tentacle.Ready {
|
|
runtime.logger.Warn("local tentacle handshake failed", "error", failure)
|
|
runtime.report("A tentacle could not start.")
|
|
continue
|
|
}
|
|
candidate := append(append([]*tentacle.Session(nil), active...), session)
|
|
if err := runtime.bind(ctx, candidate); err != nil {
|
|
session.Disable()
|
|
runtime.logger.Warn("local tentacle bindings rejected", "error", err)
|
|
runtime.report("A tentacle could not acquire its keyboard shortcuts.")
|
|
continue
|
|
}
|
|
if err := session.Activate(); err != nil {
|
|
session.Disable()
|
|
if restore := runtime.bind(ctx, active); restore != nil && ctx.Err() == nil {
|
|
runtime.failures <- restore
|
|
return restore
|
|
}
|
|
continue
|
|
}
|
|
active = candidate
|
|
runtime.publishCommands(active)
|
|
runtime.logger.Info("local tentacle active", "session", session.Identity())
|
|
}
|
|
}
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) bind(ctx context.Context, sessions []*tentacle.Session) error {
|
|
var bindings []input.BindingOverride
|
|
for _, session := range sessions {
|
|
for _, command := range session.Commands() {
|
|
for _, binding := range command.Bindings {
|
|
layout := input.DesktopKeyboardLayout
|
|
if binding.Layout == "laptop" {
|
|
layout = input.LaptopKeyboardLayout
|
|
}
|
|
bindings = append(bindings, input.BindingOverride{Layout: layout, Scope: binding.Scope,
|
|
Command: input.Command("tentacle/" + session.Identity() + "/" + command.ID), Keys: []string{binding.Key}})
|
|
}
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
|
defer cancel()
|
|
return runtime.input.SetTentacleBindings(ctx, bindings)
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) publishCommands(sessions []*tentacle.Session) {
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
clear(runtime.commands)
|
|
if runtime.disabled {
|
|
return
|
|
}
|
|
for _, session := range sessions {
|
|
for _, command := range session.Commands() {
|
|
runtime.commands[input.Command("tentacle/"+session.Identity()+"/"+command.ID)] = tentacleCommand{
|
|
session: session, entryID: session.TentacleID(), id: command.ID, title: command.Title,
|
|
description: command.Description, presentation: command.Presentation,
|
|
interactive: command.Interaction, settings: command.Settings,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invoke never waits for a child or native binding transaction. Every result
|
|
// worker has a bounded slot and joins before lifecycle shutdown completes.
|
|
func (runtime *tentacleRuntime) Invoke(ctx context.Context, command input.Command) bool {
|
|
if runtime == nil || !strings.HasPrefix(string(command), "tentacle/") {
|
|
return false
|
|
}
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
registered, ok := runtime.commands[command]
|
|
if !ok || runtime.disabled {
|
|
return true
|
|
}
|
|
if err := runtime.invokeLocked(ctx, command, registered); err != nil {
|
|
runtime.logger.Debug("tentacle command rejected", "command", command, "error", err)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Caller holds runtime.mutex so shutdown cannot race worker admission.
|
|
func (runtime *tentacleRuntime) invokeLocked(ctx context.Context, command input.Command, registered tentacleCommand) error {
|
|
select {
|
|
case runtime.resultSlots <- struct{}{}:
|
|
default:
|
|
return tentacle.ErrBusy
|
|
}
|
|
result, err := registered.session.Invoke(ctx, registered.id, nil)
|
|
if err != nil {
|
|
<-runtime.resultSlots
|
|
return err
|
|
}
|
|
var ticket tentacleOutputTicket
|
|
if registered.presentation {
|
|
ticket = runtime.output.Capture(registered.session.OutputContext())
|
|
}
|
|
runtime.results.Add(1)
|
|
go func() {
|
|
defer runtime.results.Done()
|
|
defer func() { <-runtime.resultSlots }()
|
|
value := <-result
|
|
runtime.logger.Debug("tentacle command completed", "command", command, "request", value.ID, "error", value.Err)
|
|
if value.Err != nil || !registered.presentation {
|
|
return
|
|
}
|
|
if registered.interactive {
|
|
if ticket.ctx == nil || ticket.ctx.Err() != nil {
|
|
return
|
|
}
|
|
runtime.interact(ctx, registered, value.Value)
|
|
return
|
|
}
|
|
presentation, err := tentaclewire.DecodePresentation(value.Value)
|
|
if err != nil {
|
|
registered.session.Disable()
|
|
runtime.output.RetireRevoked()
|
|
runtime.logger.Warn("tentacle returned invalid presentation", "command", command, "error", err)
|
|
runtime.report("A tentacle returned an invalid message and was disabled.")
|
|
return
|
|
}
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
if !runtime.disabled && value.Session == registered.session.Identity() {
|
|
runtime.output.Present(ticket, presentation, string(command))
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) Describe(chord input.CommandChord, layout input.KeyboardLayout, browse bool) string {
|
|
if runtime == nil || runtime.input == nil {
|
|
return ""
|
|
}
|
|
bindings := runtime.input.TentacleBindings()
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
for _, binding := range bindings {
|
|
if binding.Layout != layout || binding.Scope == "browse" && !browse {
|
|
continue
|
|
}
|
|
bound, _ := input.ParseCommandChord(binding.Keys[0])
|
|
registered, ok := runtime.commands[binding.Command]
|
|
if bound != chord || !ok {
|
|
continue
|
|
}
|
|
if state, _ := registered.session.Status(); state != tentacle.Active {
|
|
continue
|
|
}
|
|
if registered.description != "" {
|
|
return registered.description
|
|
}
|
|
return registered.title
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ShortcutRows uses the acknowledged native assignments, including core remaps.
|
|
func (runtime *tentacleRuntime) ShortcutRows(layout input.KeyboardLayout) []shortcutui.Row {
|
|
if runtime == nil || runtime.input == nil {
|
|
return nil
|
|
}
|
|
bindings := runtime.input.TentacleBindings()
|
|
runtime.mutex.Lock()
|
|
defer runtime.mutex.Unlock()
|
|
var rows []shortcutui.Row
|
|
for _, binding := range bindings {
|
|
registered, ok := runtime.commands[binding.Command]
|
|
if binding.Layout != layout || !ok {
|
|
continue
|
|
}
|
|
if state, _ := registered.session.Status(); state != tentacle.Active {
|
|
continue
|
|
}
|
|
label := registered.description
|
|
if label == "" {
|
|
label = registered.title
|
|
}
|
|
group := "Tentacles"
|
|
if binding.Scope == "browse" {
|
|
group = "Tentacles in browse mode"
|
|
}
|
|
rows = append(rows, shortcutui.Row{Group: group, Label: label, Keys: binding.Keys[0]})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (runtime *tentacleRuntime) report(message string) {
|
|
select {
|
|
case runtime.announcements <- message:
|
|
default:
|
|
}
|
|
}
|