291 lines
7.9 KiB
Go
291 lines
7.9 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package tentacle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type State string
|
|
|
|
const (
|
|
Starting State = "starting"
|
|
Ready State = "ready"
|
|
Active State = "active"
|
|
Stopping State = "stopping"
|
|
Stopped State = "stopped"
|
|
Failed State = "failed"
|
|
)
|
|
|
|
// Result is never delivered to an output backend by this package. The future
|
|
// reader output owner must revalidate context, lock, mode and interruption state
|
|
// when consuming it, including a result already buffered before cancellation.
|
|
type Result struct {
|
|
Session string
|
|
ID string
|
|
Value json.RawMessage
|
|
Err error
|
|
}
|
|
|
|
type pendingCall struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
stopWatch func() bool
|
|
result chan Result
|
|
}
|
|
|
|
type outgoing struct {
|
|
id string // Empty for lifecycle and cancellation notifications.
|
|
frame []byte
|
|
}
|
|
|
|
type Session struct {
|
|
outputContext context.Context
|
|
revokeOutput context.CancelFunc
|
|
|
|
mutex sync.Mutex
|
|
manager *Manager
|
|
spec Spec
|
|
identity string
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
state State
|
|
failure error
|
|
commands []Command
|
|
bindings []BindingStatus
|
|
sequence uint64
|
|
pending map[string]*pendingCall
|
|
outbound chan outgoing
|
|
ready chan struct{}
|
|
done chan struct{}
|
|
stop chan struct{}
|
|
revoked chan struct{}
|
|
callbacks sync.WaitGroup
|
|
}
|
|
|
|
func (session *Session) Ready() <-chan struct{} { return session.ready }
|
|
|
|
// Revoked closes as soon as dispatch is retired, before descendant cleanup.
|
|
func (session *Session) Revoked() <-chan struct{} { return session.revoked }
|
|
func (session *Session) Done() <-chan struct{} { return session.done }
|
|
func (session *Session) Identity() string { return session.identity }
|
|
func (session *Session) TentacleID() string { return session.spec.ID }
|
|
|
|
// OutputContext expires synchronously on revocation, including after a reply
|
|
// has left the host's pending-call map. It never authorizes output by itself.
|
|
func (session *Session) OutputContext() context.Context { return session.outputContext }
|
|
|
|
func (session *Session) Status() (State, error) {
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
return session.state, session.failure
|
|
}
|
|
|
|
// Commands returns a copy of staged or active registrations. A caller may
|
|
// inspect Ready registrations but must not dispatch them before Activate.
|
|
func (session *Session) Commands() []Command {
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
return clone_commands(session.commands)
|
|
}
|
|
|
|
// Activate is the host publication barrier. The reader stages acknowledged
|
|
// native bindings before calling it; there are no external service exports.
|
|
func (session *Session) Activate() error {
|
|
session.manager.mutex.Lock()
|
|
defer session.manager.mutex.Unlock()
|
|
occupied := session.manager.occupied_bindings()
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
if session.state != Ready || session.ctx.Err() != nil {
|
|
return ErrUnavailable
|
|
}
|
|
if !session.notify_locked("initialized", struct {
|
|
Session string `json:"session"`
|
|
}{session.identity}) {
|
|
return ErrBusy
|
|
}
|
|
session.bindings = resolve_bindings(session.spec.ID, session.commands, occupied)
|
|
session.state = Active
|
|
return nil
|
|
}
|
|
|
|
func (session *Session) Invoke(ctx context.Context, command string, arguments json.RawMessage) (<-chan Result, error) {
|
|
if ctx == nil {
|
|
return nil, errors.New("nil command context")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(arguments) > MaxArgumentBytes {
|
|
return nil, ErrLimit
|
|
}
|
|
if len(arguments) == 0 {
|
|
arguments = json.RawMessage(`{}`)
|
|
}
|
|
if err := validate_json(arguments); err != nil {
|
|
return nil, err
|
|
}
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
if session.state != Active || session.ctx.Err() != nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
known := false
|
|
for _, registered := range session.commands {
|
|
known = known || registered.ID == command
|
|
}
|
|
if !known {
|
|
return nil, ErrUnavailable
|
|
}
|
|
return session.request_locked(ctx, "command.invoke", struct {
|
|
Command string `json:"command"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}{command, arguments}, session.manager.limits.RequestTimeout)
|
|
}
|
|
|
|
func (session *Session) request_locked(parent context.Context, method string, params any, timeout time.Duration) (<-chan Result, error) {
|
|
if len(session.pending) >= session.manager.limits.PendingPerProcess {
|
|
return nil, ErrBusy
|
|
}
|
|
select {
|
|
case session.manager.pending <- struct{}{}:
|
|
default:
|
|
return nil, ErrBusy
|
|
}
|
|
session.sequence++
|
|
id := strconv.FormatUint(session.sequence, 10)
|
|
frame, err := encode_request(id, method, params)
|
|
if err != nil {
|
|
<-session.manager.pending
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(parent, timeout)
|
|
call := &pendingCall{ctx: ctx, cancel: cancel, result: make(chan Result, 1)}
|
|
session.pending[id] = call
|
|
session.callbacks.Add(1)
|
|
call.stopWatch = context.AfterFunc(ctx, func() {
|
|
defer session.callbacks.Done()
|
|
session.cancel_call(id)
|
|
})
|
|
select {
|
|
case session.outbound <- outgoing{id: id, frame: frame}:
|
|
return call.result, nil
|
|
default:
|
|
session.finish_locked(id, nil, ErrBusy)
|
|
return nil, ErrBusy
|
|
}
|
|
}
|
|
|
|
func (session *Session) notify_locked(method string, params any) bool {
|
|
frame, err := encode_request("", method, params)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
select {
|
|
case session.outbound <- outgoing{frame: frame}:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (session *Session) finish_locked(id string, value json.RawMessage, err error) {
|
|
call := session.pending[id]
|
|
if call == nil {
|
|
return
|
|
}
|
|
delete(session.pending, id)
|
|
if call.stopWatch() {
|
|
session.callbacks.Done()
|
|
}
|
|
if contextError := call.ctx.Err(); contextError != nil && err == nil {
|
|
err = contextError
|
|
value = nil
|
|
}
|
|
call.cancel()
|
|
<-session.manager.pending
|
|
call.result <- Result{Session: session.identity, ID: id, Value: value, Err: err}
|
|
close(call.result)
|
|
}
|
|
|
|
func (session *Session) cancel_call(id string) {
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
call := session.pending[id]
|
|
if call == nil {
|
|
return
|
|
}
|
|
err := call.ctx.Err()
|
|
session.finish_locked(id, nil, err)
|
|
// Without a cancellation acknowledgment contract, a timed-out child could
|
|
// accumulate unlimited still-running work. Retire the whole session instead.
|
|
// Explicit cancellation follows the same conservative rule in this slice.
|
|
session.fail_locked(err)
|
|
}
|
|
|
|
func (session *Session) accept_reply(reply response) {
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
if session.state == Stopping || session.state == Stopped || session.state == Failed {
|
|
return
|
|
}
|
|
if session.pending[reply.ID] == nil {
|
|
// Unknown/duplicate responses are not allowed to keep a flood alive.
|
|
session.fail_locked(ErrProtocol)
|
|
return
|
|
}
|
|
var err error
|
|
if reply.Error != nil {
|
|
err = reply.Error
|
|
}
|
|
session.finish_locked(reply.ID, reply.Result, err)
|
|
}
|
|
|
|
func (session *Session) fail_locked(err error) {
|
|
if session.state == Stopping || session.state == Stopped || session.state == Failed {
|
|
return
|
|
}
|
|
session.state, session.failure = Failed, err
|
|
close(session.revoked)
|
|
session.revokeOutput()
|
|
session.commands = nil
|
|
session.bindings = nil
|
|
for id := range session.pending {
|
|
session.finish_locked(id, nil, err)
|
|
}
|
|
session.cancel()
|
|
}
|
|
|
|
func (session *Session) fail(err error) {
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
session.fail_locked(err)
|
|
}
|
|
|
|
// Disable revokes commands and retires replies synchronously; pipe shutdown and
|
|
// process cleanup happen on the supervisor. It is safe to call repeatedly.
|
|
func (session *Session) Disable() {
|
|
session.mutex.Lock()
|
|
defer session.mutex.Unlock()
|
|
if session.state == Stopping || session.state == Stopped || session.state == Failed {
|
|
return
|
|
}
|
|
session.state = Stopping
|
|
close(session.revoked)
|
|
session.revokeOutput()
|
|
session.commands = nil
|
|
session.bindings = nil
|
|
for id := range session.pending {
|
|
session.finish_locked(id, nil, ErrDisabled)
|
|
}
|
|
session.notify_locked("shutdown", nil)
|
|
close(session.stop)
|
|
}
|