Files

210 lines
6.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentacle
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"os"
"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.
ReaderSettings bool // Declarative preferences, quick settings and speech overrides.
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
supervisorProgram []string
cleanupError error
}
// NewSupervisedManager launches a private lifetime helper for each Tentacle.
// The default needs no administrator setup. An explicit cgroup parent (or the
// CTHULHU_TENTACLE_CGROUP_PARENT environment variable in auto mode) opts into
// kernel resource limits; a broken explicit configuration never falls back.
func NewSupervisedManager(program []string, parent string) (*Manager, error) {
if len(program) == 0 || !filepath.IsAbs(program[0]) {
return nil, errors.New("tentacle supervisor must be an absolute program")
}
if parent == "" || parent == "auto" {
parent = os.Getenv("CTHULHU_TENTACLE_CGROUP_PARENT")
}
var manager *Manager
var err error
if parent == "" {
manager, err = NewManager(DefaultLimits(), nil)
} else {
manager, err = NewContainedManager(DefaultLimits(), nil, parent, DefaultResourceLimits())
}
if err == nil {
manager.supervisorProgram = append([]string(nil), program...)
}
return manager, err
}
// NewManager is the plain process-group host for trusted protocol fixtures.
// Reader integration uses NewSupervisedManager to clean up detached descendants.
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.ReaderSettings && !spec.Interaction || 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
}