404 lines
9.8 KiB
Go
404 lines
9.8 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 owns the set of live terminal sessions and serves WebSocket clients.
|
|
// Sessions outlive any single connection: multiple clients can attach to the
|
|
// same pane, and a client reconnect re-subscribes to the existing session.
|
|
type Manager struct {
|
|
src PTYSource
|
|
events EventSource
|
|
spawn spawnFunc
|
|
log *slog.Logger
|
|
heartbeat time.Duration
|
|
|
|
// ctx scopes every session's PTY lifetime; cancelled by Close.
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*session
|
|
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,
|
|
sessions: map[string]*session{},
|
|
}
|
|
for _, opt := range opts {
|
|
opt(m)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Close tears down every session 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
|
|
sessions := make([]*session, 0, len(m.sessions))
|
|
for _, s := range m.sessions {
|
|
sessions = append(sessions, s)
|
|
}
|
|
m.sessions = map[string]*session{}
|
|
m.mu.Unlock()
|
|
|
|
m.cancel()
|
|
for _, s := range sessions {
|
|
s.close()
|
|
}
|
|
}
|
|
|
|
// openSession returns the live session for id, starting it on first open. The id
|
|
// is the runtime handle id (Zellij handle).
|
|
func (m *Manager) openSession(id string) (*session, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.closed {
|
|
return nil, context.Canceled
|
|
}
|
|
if s, ok := m.sessions[id]; ok {
|
|
return s, nil
|
|
}
|
|
handle := ports.RuntimeHandle{ID: id}
|
|
s := newSession(id, handle, m.src, m.spawn, m.log)
|
|
m.sessions[id] = s
|
|
go func() {
|
|
s.run(m.ctx)
|
|
m.mu.Lock()
|
|
if cur, ok := m.sessions[id]; ok && cur == s {
|
|
delete(m.sessions, id)
|
|
}
|
|
m.mu.Unlock()
|
|
}()
|
|
return s, nil
|
|
}
|
|
|
|
// 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]func(){},
|
|
}
|
|
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]func() // terminal id -> unsubscribe
|
|
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)
|
|
case msgData:
|
|
raw, err := base64.StdEncoding.DecodeString(msg.Data)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if s := c.lookup(msg.ID); s != nil {
|
|
_ = s.write(raw)
|
|
}
|
|
case msgResize:
|
|
if s := c.lookup(msg.ID); s != nil {
|
|
_ = s.resize(msg.Rows, msg.Cols)
|
|
}
|
|
case msgClose:
|
|
c.closeTerminal(msg.ID)
|
|
}
|
|
}
|
|
|
|
func (c *connState) openTerminal(id string) {
|
|
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 duplicate replay
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
s, err := c.mgr.openSession(id)
|
|
if err != nil {
|
|
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgError, Error: err.Error()})
|
|
return
|
|
}
|
|
|
|
// Ack before subscribing so opened always precedes the replay and any
|
|
// data/exited frames subscribe delivers (the single out channel preserves
|
|
// this order). Reversing it would let a reconnecting client with buffered
|
|
// content, or one opening an already-dead pane, see data/exited before the
|
|
// open acknowledgement.
|
|
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgOpened})
|
|
|
|
// exitFired guards the subscribe-to-assign window: the session can exit (and
|
|
// run onExit) at any point after subscribe returns exited=false, including
|
|
// before c.terms[id] is assigned below. onExit and the assign both read/write
|
|
// this flag and the map only under c.mu, so no atomic is needed.
|
|
var exitFired bool
|
|
unsub, exited := s.subscribe(
|
|
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. Ordering
|
|
// the delete after the frame would race that reopen. markExited fires
|
|
// this without s.mu held, so taking c.mu is safe.
|
|
c.mu.Lock()
|
|
exitFired = true
|
|
delete(c.terms, id)
|
|
c.mu.Unlock()
|
|
c.enqueue(serverMsg{Ch: chTerminal, ID: id, Type: msgExited})
|
|
},
|
|
)
|
|
// An already-exited session sent its exited frame from subscribe and has
|
|
// nothing to unsubscribe. Don't register it: leaving c.terms[id] set would
|
|
// trip the open guard above and silently drop every later open for this id
|
|
// on this connection (e.g. after the pane respawns) until close/reconnect.
|
|
if exited {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
c.terms[id] = unsub
|
|
// If onExit already ran in the subscribe-to-assign window its delete was a
|
|
// no-op (the key did not exist yet), so the assign above just resurrected a
|
|
// stale entry for a dead pane. Re-apply the delete while still holding c.mu.
|
|
if exitFired {
|
|
delete(c.terms, id)
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *connState) closeTerminal(id string) {
|
|
c.mu.Lock()
|
|
unsub := c.terms[id]
|
|
delete(c.terms, id)
|
|
c.mu.Unlock()
|
|
if unsub != nil {
|
|
unsub()
|
|
}
|
|
}
|
|
|
|
func (c *connState) lookup(id string) *session {
|
|
c.mu.Lock()
|
|
_, open := c.terms[id]
|
|
c.mu.Unlock()
|
|
if !open {
|
|
return nil
|
|
}
|
|
c.mgr.mu.Lock()
|
|
s := c.mgr.sessions[id]
|
|
c.mgr.mu.Unlock()
|
|
return s
|
|
}
|
|
|
|
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 stall fan-out for other
|
|
// subscribers of the same pane.
|
|
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
|
|
unsubs := make([]func(), 0, len(c.terms)+1)
|
|
for _, u := range c.terms {
|
|
unsubs = append(unsubs, u)
|
|
}
|
|
c.terms = map[string]func(){}
|
|
if c.unsubEvts != nil {
|
|
unsubs = append(unsubs, c.unsubEvts)
|
|
c.unsubEvts = nil
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
for _, u := range unsubs {
|
|
u()
|
|
}
|
|
_ = c.conn.Close("server: connection closed")
|
|
}
|