// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentacle import ( "context" "crypto/rand" "encoding/hex" "errors" "path/filepath" "strings" "sync" "time" "git.stormux.org/storm/cthulhu/internal/input" ) type Limits struct { Processes int Starting int PendingPerProcess int PendingTotal int StartupTimeout time.Duration RequestTimeout time.Duration ShutdownTimeout time.Duration } func DefaultLimits() Limits { return Limits{Processes: 16, Starting: 2, PendingPerProcess: 8, PendingTotal: 64, StartupTimeout: 3 * time.Second, RequestTimeout: 2 * time.Second, ShutdownTimeout: 250 * time.Millisecond} } // Spec is a validated launch description, independent of package metadata. // Program[0] must be absolute; arguments are never interpreted by a shell. type Spec struct { ID string Program []string Directory string Presentation bool // Explicit local grant for command speech/braille results. Interaction bool // Internal forms and explicit focused-item/clipboard exchanges. } type Stats struct { Processes int Starting int Pending int } type Manager struct { mutex sync.Mutex limits Limits sessions map[string]*Session starting chan struct{} pending chan struct{} closed bool coreBindings map[input.KeyboardLayout]input.CommandBindings containment *containment delegation *Delegation cleanupError error } // NewDelegatedManager keeps exec inside the delegated service. This is required // when the reader's own cgroup is outside that service's delegation boundary. func NewDelegatedManager(delegation *Delegation) (*Manager, error) { manager, err := NewContainedManager(DefaultLimits(), nil, delegation.Parent, DefaultResourceLimits()) if err == nil { manager.delegation = delegation } return manager, err } // NewManager is the uncontained host for explicit trusted protocol fixtures. // Reader integration must use NewContainedManager; there is no implicit fallback. func NewManager(limits Limits, overrides []input.BindingOverride) (*Manager, error) { if limits.Processes < 1 || limits.Starting < 1 || limits.Starting > limits.Processes || limits.PendingPerProcess < 1 || limits.PendingTotal < limits.PendingPerProcess || limits.StartupTimeout <= 0 || limits.RequestTimeout <= 0 || limits.ShutdownTimeout <= 0 { return nil, errors.New("invalid tentacle host limits") } if err := input.ValidateBindingOverrides(overrides); err != nil { return nil, err } coreBindings := make(map[input.KeyboardLayout]input.CommandBindings) for _, layout := range []input.KeyboardLayout{input.DesktopKeyboardLayout, input.LaptopKeyboardLayout} { bindings, err := input.ResolveCommandBindings(layout, true, overrides) if err != nil { return nil, err } coreBindings[layout] = bindings } return &Manager{limits: limits, sessions: make(map[string]*Session), coreBindings: coreBindings, starting: make(chan struct{}, limits.Starting), pending: make(chan struct{}, limits.PendingTotal)}, nil } // Start reserves capacity and returns immediately. Ready closes after the child // has either staged a valid registration or failed; Done includes worker joins. // Rejected admission starts no process and changes no desired enablement. func (manager *Manager) Start(ctx context.Context, spec Spec) (*Session, error) { if ctx == nil { return nil, errors.New("nil tentacle context") } if err := ctx.Err(); err != nil { return nil, err } if err := validate_spec(spec); err != nil { return nil, err } manager.mutex.Lock() defer manager.mutex.Unlock() if manager.cleanupError != nil { return nil, manager.cleanupError } if manager.closed { return nil, ErrUnavailable } if len(manager.sessions) >= manager.limits.Processes || manager.sessions[spec.ID] != nil { return nil, ErrBusy } select { case manager.starting <- struct{}{}: default: return nil, ErrBusy } var identity [16]byte if _, err := rand.Read(identity[:]); err != nil { <-manager.starting return nil, err } spec.Program = append([]string{}, spec.Program...) sessionContext, cancel := context.WithCancel(ctx) outputContext, revokeOutput := context.WithCancel(sessionContext) session := &Session{manager: manager, spec: spec, identity: hex.EncodeToString(identity[:]), outputContext: outputContext, revokeOutput: revokeOutput, ctx: sessionContext, cancel: cancel, state: Starting, ready: make(chan struct{}), done: make(chan struct{}), stop: make(chan struct{}), revoked: make(chan struct{}), outbound: make(chan outgoing, manager.limits.PendingPerProcess+2), pending: make(map[string]*pendingCall)} manager.sessions[spec.ID] = session go session.run() return session, nil } func (manager *Manager) Stats() Stats { manager.mutex.Lock() defer manager.mutex.Unlock() return Stats{Processes: len(manager.sessions), Starting: len(manager.starting), Pending: len(manager.pending)} } // Shutdown may wait for children and is intended for the lifecycle worker. // Disable itself is nonblocking and revokes dispatch before requesting exit. func (manager *Manager) Shutdown(ctx context.Context) error { manager.mutex.Lock() manager.closed = true sessions := make([]*Session, 0, len(manager.sessions)) for _, session := range manager.sessions { sessions = append(sessions, session) session.Disable() } manager.mutex.Unlock() for _, session := range sessions { select { case <-session.Done(): case <-ctx.Done(): return ctx.Err() } } manager.mutex.Lock() defer manager.mutex.Unlock() if manager.cleanupError != nil { return manager.cleanupError } if manager.containment != nil { if err := manager.containment.group.remove(); err != nil { manager.cleanupError = err return err } manager.containment = nil } return nil } func validate_spec(spec Spec) error { if spec.Interaction && !spec.Presentation || !valid_id(spec.ID) || len(spec.Program) == 0 || len(spec.Program) > 32 || !filepath.IsAbs(spec.Program[0]) || (spec.Directory != "" && !filepath.IsAbs(spec.Directory)) { return errors.New("invalid local tentacle launch") } for _, argument := range append(append([]string{}, spec.Program...), spec.Directory) { if len(argument) > 4096 || strings.ContainsRune(argument, 0) { return errors.New("invalid local tentacle argument") } } return nil }