Files

194 lines
6.2 KiB
Go

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