236 lines
7.4 KiB
Go
236 lines
7.4 KiB
Go
// runtime.go - conpty Runtime adapter. Implements ports.Runtime and
|
|
// ports.Attacher (see attach.go). Drives sessions via the B3 pty-host over
|
|
// loopback TCP, using the B1 protocol and the B2 registry for restart recovery.
|
|
package conpty
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/conpty/ptyregistry"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
)
|
|
|
|
// Ensure Runtime satisfies the port at compile time (Attach in attach.go).
|
|
var _ ports.Runtime = (*Runtime)(nil)
|
|
|
|
// validSessionID matches agent-orchestrator's assertValidSessionId.
|
|
var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
|
|
// hostSession is the in-memory state for a live pty-host connection.
|
|
type hostSession struct {
|
|
addr string
|
|
pid int
|
|
}
|
|
|
|
// Options configures the Runtime. All fields are optional; zero values use
|
|
// sensible defaults. The Spawner field is injectable for tests.
|
|
type Options struct {
|
|
// Spawner overrides the default OS-level process spawner. If nil,
|
|
// defaultSpawnHost is used (Windows-only; returns an error on other OSes).
|
|
Spawner hostSpawner
|
|
}
|
|
|
|
// Runtime is the conpty runtime adapter.
|
|
type Runtime struct {
|
|
spawner hostSpawner
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*hostSession // sessionID -> live session
|
|
}
|
|
|
|
// New creates a Runtime with the given options.
|
|
func New(opts Options) *Runtime {
|
|
sp := opts.Spawner
|
|
if sp == nil {
|
|
sp = defaultSpawnHost
|
|
}
|
|
return &Runtime{
|
|
spawner: sp,
|
|
sessions: make(map[string]*hostSession),
|
|
}
|
|
}
|
|
|
|
// Create spawns a detached pty-host for the session, waits for READY, stores
|
|
// the addr+pid in-memory and in the B2 registry, and returns the handle.
|
|
// Returns an error if sessionID is invalid, already exists, or spawn fails.
|
|
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
|
|
id := string(cfg.SessionID)
|
|
if !validSessionID.MatchString(id) {
|
|
return ports.RuntimeHandle{}, fmt.Errorf("conpty: invalid session id %q: must match ^[a-zA-Z0-9_-]+$", id)
|
|
}
|
|
if cfg.WorkspacePath == "" {
|
|
return ports.RuntimeHandle{}, fmt.Errorf("conpty: workspace path required")
|
|
}
|
|
if len(cfg.Argv) == 0 {
|
|
return ports.RuntimeHandle{}, fmt.Errorf("conpty: argv required")
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if _, dup := r.sessions[id]; dup {
|
|
r.mu.Unlock()
|
|
return ports.RuntimeHandle{}, fmt.Errorf("conpty: session %q already exists; destroy before re-creating", id)
|
|
}
|
|
// Reserve the slot before the async spawn so a concurrent Create for the
|
|
// same id fails immediately (no gap between check and set).
|
|
r.sessions[id] = nil
|
|
r.mu.Unlock()
|
|
|
|
addr, pid, err := r.spawner(ctx, id, cfg.WorkspacePath, cfg.Argv, cfg.Env)
|
|
if err != nil {
|
|
r.mu.Lock()
|
|
delete(r.sessions, id)
|
|
r.mu.Unlock()
|
|
return ports.RuntimeHandle{}, fmt.Errorf("conpty: spawn pty-host for %q: %w", id, err)
|
|
}
|
|
|
|
sess := &hostSession{addr: addr, pid: pid}
|
|
|
|
r.mu.Lock()
|
|
r.sessions[id] = sess
|
|
r.mu.Unlock()
|
|
|
|
// Register in B2 registry for daemon-restart recovery (best-effort).
|
|
_ = ptyregistry.Register(ptyregistry.Entry{
|
|
SessionID: id,
|
|
PtyHostPID: pid,
|
|
PipePath: addr, // ponytail: reuse PipePath field for loopback addr
|
|
RegisteredAt: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
|
|
return ports.RuntimeHandle{ID: id}, nil
|
|
}
|
|
|
|
// Destroy gracefully kills the pty-host, waits up to ~500ms for the pid to
|
|
// exit, then force-kills it. Removes the session from the map and the registry.
|
|
// Idempotent: unknown/already-gone session returns nil.
|
|
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
|
|
sess := r.resolve(handle.ID)
|
|
if sess == nil {
|
|
return nil // unknown or already gone
|
|
}
|
|
|
|
// Ask host to shut down gracefully (triggers shutdown() in Serve).
|
|
_ = clientKill(sess.addr)
|
|
|
|
// Poll up to ~500ms (20 x 25ms) for the pty-host pid to exit.
|
|
// ponytail: signal-0 probe; upgrade to process-tree kill if orphan ConPTY
|
|
// helpers appear.
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if !pidAlive(sess.pid) {
|
|
break
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
|
|
// Best-effort force-kill (the host's graceful shutdown already disposed
|
|
// the ConPTY child; killing the host process is sufficient).
|
|
if p, err := findProcess(sess.pid); err == nil {
|
|
_ = p.Kill()
|
|
}
|
|
|
|
r.mu.Lock()
|
|
delete(r.sessions, handle.ID)
|
|
r.mu.Unlock()
|
|
|
|
_ = ptyregistry.Unregister(handle.ID)
|
|
return nil
|
|
}
|
|
|
|
// IsAlive distinguishes three outcomes so the reaper never spuriously reaps a
|
|
// live session on a transient probe failure:
|
|
//
|
|
// - (true, nil): the pty-host answered a status probe -> alive.
|
|
// - (false, nil): DEFINITIVELY gone. Either the session resolves to nothing
|
|
// (no in-memory entry and no registry entry), or the dial was refused
|
|
// (nothing listening on the loopback addr).
|
|
// - (false, err): a TRANSIENT probe failure (loopback timeout, connected-
|
|
// then-failed I/O). The reaper records ProbeFailed and retries rather than
|
|
// treating it as a death conclusion.
|
|
//
|
|
// tmux returns a non-nil error for transient failures for the same
|
|
// reason; conpty matches that contract here.
|
|
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
|
|
sess := r.resolve(handle.ID)
|
|
if sess == nil {
|
|
return false, nil // no in-memory entry, no registry entry -> definitively gone
|
|
}
|
|
return clientIsAlive(sess.addr)
|
|
}
|
|
|
|
// SendMessage chunks message and writes it to the pty-host followed by Enter.
|
|
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
|
|
sess := r.resolve(handle.ID)
|
|
if sess == nil {
|
|
return fmt.Errorf("conpty: session %q not found", handle.ID)
|
|
}
|
|
return clientSendMessage(sess.addr, message)
|
|
}
|
|
|
|
// GetOutput returns the last lines lines from the pty-host ring buffer.
|
|
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
|
|
if lines <= 0 {
|
|
return "", fmt.Errorf("conpty: lines must be > 0")
|
|
}
|
|
sess := r.resolve(handle.ID)
|
|
if sess == nil {
|
|
return "", fmt.Errorf("conpty: session %q not found", handle.ID)
|
|
}
|
|
return clientGetOutput(sess.addr, lines)
|
|
}
|
|
|
|
// resolve looks up a session by id: first the in-memory map, then the B2
|
|
// registry (for daemon-restart recovery). Returns nil if not found either way.
|
|
func (r *Runtime) resolve(id string) *hostSession {
|
|
r.mu.Lock()
|
|
sess := r.sessions[id]
|
|
r.mu.Unlock()
|
|
if sess != nil {
|
|
return sess
|
|
}
|
|
|
|
// Registry fallback: scan for the entry by session id.
|
|
entries, err := ptyregistry.List()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, e := range entries {
|
|
if e.SessionID != id {
|
|
continue
|
|
}
|
|
// Re-populate the map so subsequent calls skip the file scan.
|
|
recovered := &hostSession{addr: e.PipePath, pid: e.PtyHostPID}
|
|
r.mu.Lock()
|
|
// Only store if another goroutine hasn't beaten us.
|
|
if r.sessions[id] == nil {
|
|
r.sessions[id] = recovered
|
|
} else {
|
|
recovered = r.sessions[id]
|
|
}
|
|
r.mu.Unlock()
|
|
return recovered
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findProcess wraps os.FindProcess to make it swappable in tests.
|
|
// ponytail: direct call; no interface needed at this scale.
|
|
func findProcess(pid int) (processKiller, error) {
|
|
p, err := osProcessFinder(pid)
|
|
return p, err
|
|
}
|
|
|
|
// processKiller is the subset of *os.Process used by Destroy.
|
|
type processKiller interface {
|
|
Kill() error
|
|
}
|
|
|
|
// osProcessFinder is the production implementation; tests may replace it.
|
|
// The real defaultOSProcessFinder is in pidalive_unix.go / pidalive_windows.go
|
|
// (same files that provide pidAlive).
|
|
var osProcessFinder = defaultOSProcessFinder
|