agent-orchestrator/backend/internal/terminal/manager.go

386 lines
9.3 KiB
Go

package terminal
import (
"context"
"encoding/base64"
"log/slog"
"sync"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/cdc"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// EventSource is the session-state feed the "sessions" channel forwards. The CDC
// broadcaster satisfies it; the interface lives next to its consumer so terminal
// does not depend on CDC internals beyond the Event shape.
type EventSource interface {
Subscribe(fn func(cdc.Event)) (unsubscribe func())
}
// wsConn is the transport seam: a JSON-framed, single-reader/single-writer
// WebSocket connection. internal/httpd adapts coder/websocket to this; tests
// supply an in-memory fake. WriteJSON is only ever called from the per-conn
// writer goroutine; Ping may be called concurrently (it is a control frame).
type wsConn interface {
ReadJSON(ctx context.Context, v any) error
WriteJSON(ctx context.Context, v any) error
Ping(ctx context.Context) error
Close(reason string) error
}
const (
defaultHeartbeat = 15 * time.Second
defaultWriteBuffer = 1024
)
// Manager serves WebSocket clients, spawning one attach PTY per opened pane
// per connection. There is no shared per-pane state to outlive a connection:
// the Zellij server owns the session (screen, scrollback, modes), and every
// fresh attach gets its full handshake + repaint. A client reconnect simply
// attaches again.
type Manager struct {
src PTYSource
events EventSource
spawn spawnFunc
log *slog.Logger
heartbeat time.Duration
// ctx scopes every attachment's PTY lifetime; cancelled by Close.
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
attachments map[*attachment]struct{}
closed bool
}
// Option configures a Manager.
type Option func(*Manager)
// WithSpawn overrides the PTY spawner (tests inject a fake).
func WithSpawn(fn spawnFunc) Option { return func(m *Manager) { m.spawn = fn } }
// WithHeartbeat overrides the ping interval.
func WithHeartbeat(d time.Duration) Option { return func(m *Manager) { m.heartbeat = d } }
// NewManager builds a Manager. src attaches PTYs; events feeds the session
// channel (may be nil to disable it). A nil logger falls back to slog.Default.
func NewManager(src PTYSource, events EventSource, log *slog.Logger, opts ...Option) *Manager {
if log == nil {
log = slog.Default()
}
ctx, cancel := context.WithCancel(context.Background())
m := &Manager{
src: src,
events: events,
spawn: defaultSpawn,
log: log,
heartbeat: defaultHeartbeat,
ctx: ctx,
cancel: cancel,
attachments: map[*attachment]struct{}{},
}
for _, opt := range opts {
opt(m)
}
return m
}
// Close tears down every live attachment and stops re-attach loops. Safe to
// call once on daemon shutdown.
func (m *Manager) Close() {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return
}
m.closed = true
attachments := make([]*attachment, 0, len(m.attachments))
for a := range m.attachments {
attachments = append(attachments, a)
}
m.attachments = map[*attachment]struct{}{}
m.mu.Unlock()
m.cancel()
for _, a := range attachments {
a.close()
}
}
// track registers a live attachment so Close can tear it down; it refuses new
// attachments once the manager is closed.
func (m *Manager) track(a *attachment) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return context.Canceled
}
m.attachments[a] = struct{}{}
return nil
}
func (m *Manager) forget(a *attachment) {
m.mu.Lock()
delete(m.attachments, a)
m.mu.Unlock()
}
// Serve runs the protocol loop for one client connection until it errors, the
// client disconnects, or ctx/the manager is cancelled. It owns the single writer
// goroutine and the heartbeat.
func (m *Manager) Serve(ctx context.Context, conn wsConn) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
c := &connState{
mgr: m,
conn: conn,
cancel: cancel,
out: make(chan serverMsg, defaultWriteBuffer),
terms: map[string]*attachment{},
}
defer c.cleanup()
go c.writeLoop(ctx)
go c.heartbeatLoop(ctx, m.heartbeat)
for {
var msg clientMsg
if err := conn.ReadJSON(ctx, &msg); err != nil {
return
}
if ctx.Err() != nil {
return
}
c.handle(msg)
}
}
// connState is the per-connection mutable state.
type connState struct {
mgr *Manager
conn wsConn
cancel context.CancelFunc
out chan serverMsg
mu sync.Mutex
terms map[string]*attachment // terminal id -> this conn's own attach PTY
unsubEvts func()
closed bool
}
func (c *connState) handle(msg clientMsg) {
switch msg.Ch {
case chTerminal:
c.handleTerminal(msg)
case chSubscribe:
c.handleSubscribe(msg)
case chSystem:
if msg.Type == msgPing {
c.enqueue(serverMsg{Ch: chSystem, Type: msgPong})
}
}
}
func (c *connState) handleTerminal(msg clientMsg) {
switch msg.Type {
case msgOpen:
c.openTerminal(msg.ID, msg.Rows, msg.Cols)
case msgData:
raw, err := base64.StdEncoding.DecodeString(msg.Data)
if err != nil {
return
}
if a := c.lookup(msg.ID); a != nil {
_ = a.write(raw)
}
case msgResize:
if a := c.lookup(msg.ID); a != nil {
_ = a.resize(msg.Rows, msg.Cols)
}
case msgClose:
c.closeTerminal(msg.ID)
}
}
// openTerminal spawns this connection's own attach PTY for the pane. rows/cols
// are the client's grid from the open frame, applied as the PTY's initial size
// (a resize that raced ahead of the attach would otherwise be lost).
func (c *connState) openTerminal(id string, rows, cols uint16) {
if id == "" {
c.enqueue(serverMsg{Ch: chTerminal, Type: msgError, Error: "missing terminal id"})
return
}
c.mu.Lock()
if _, ok := c.terms[id]; ok {
c.mu.Unlock()
return // already open on this conn; avoid a duplicate attach
}
c.mu.Unlock()
// a is captured by onExit before assignment; safe because the attach loop —
// the only thing that fires onExit — starts after the registration below.
var a *attachment
a = newAttachment(id, ports.RuntimeHandle{ID: id}, c.mgr.src, c.mgr.spawn,
func(data []byte) {
c.enqueue(serverMsg{
Ch: chTerminal,
ID: id,
Type: msgData,
Data: base64.StdEncoding.EncodeToString(data),
})
},
func() {
// Clear the connection's entry for this id before sending exited so
// a client that reopens the moment it sees exited finds no stale
// entry and is served instead of dropped by the open guard. Guard on
// identity: that reopen may already have installed a fresh
// attachment under the same id, which must not be evicted.
c.mu.Lock()
if c.terms[id] == a {
delete(c.terms, id)
}
c.mu.Unlock()
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgExited})
},
c.mgr.log)
if rows > 0 && cols > 0 {
_ = a.resize(rows, cols) // recorded now, applied when the PTY attaches
}
if err := c.mgr.track(a); err != nil {
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgError, Error: err.Error()})
return
}
c.mu.Lock()
c.terms[id] = a
c.mu.Unlock()
// Ack before starting the attach loop so opened always precedes any
// data/exited frames (the single out channel preserves this order). A
// dead pane is reported as opened followed by exited.
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened})
go func() {
a.run(c.mgr.ctx)
c.mgr.forget(a)
}()
}
func (c *connState) closeTerminal(id string) {
c.mu.Lock()
a := c.terms[id]
delete(c.terms, id)
c.mu.Unlock()
if a != nil {
a.close()
}
}
func (c *connState) lookup(id string) *attachment {
c.mu.Lock()
defer c.mu.Unlock()
return c.terms[id]
}
func (c *connState) handleSubscribe(msg clientMsg) {
if msg.Type != msgSubscribe || c.mgr.events == nil {
return
}
c.mu.Lock()
if c.unsubEvts != nil {
c.mu.Unlock()
return
}
c.mu.Unlock()
unsub := c.mgr.events.Subscribe(func(e cdc.Event) {
c.enqueue(serverMsg{
Ch: chSessions,
Type: msgSnapshot,
Session: &sessionUpdate{
Seq: e.Seq,
ProjectID: e.ProjectID,
SessionID: e.SessionID,
EventType: string(e.Type),
},
})
})
c.mu.Lock()
c.unsubEvts = unsub
c.mu.Unlock()
}
// enqueue pushes a frame to the writer. If the buffer is full the client is too
// slow to keep up; tear the connection down rather than block the attachment's
// PTY read loop behind it.
func (c *connState) enqueue(msg serverMsg) {
select {
case c.out <- msg:
default:
c.cancel()
}
}
func (c *connState) writeLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case msg := <-c.out:
if err := c.conn.WriteJSON(ctx, msg); err != nil {
c.cancel()
return
}
}
}
}
func (c *connState) heartbeatLoop(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pctx, cancel := context.WithTimeout(ctx, interval)
err := c.conn.Ping(pctx)
cancel()
if err != nil {
c.cancel()
return
}
}
}
}
func (c *connState) cleanup() {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return
}
c.closed = true
attachments := make([]*attachment, 0, len(c.terms))
for _, a := range c.terms {
attachments = append(attachments, a)
}
c.terms = map[string]*attachment{}
unsubEvts := c.unsubEvts
c.unsubEvts = nil
c.mu.Unlock()
for _, a := range attachments {
a.close()
}
if unsubEvts != nil {
unsubEvts()
}
_ = c.conn.Close("server: connection closed")
}