194 lines
6.2 KiB
Go
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
|
|
}
|