agent-orchestrator/backend/internal/adapters/runtime/conpty/host.go

281 lines
7.9 KiB
Go

// Package conpty - host.go implements the serve engine for the pty-host
// detached process. It owns the agent's PTY (via the ptyConn seam), exposes
// it over a loopback TCP socket using the B1 binary protocol, replays
// scrollback to new clients, fans output to all connected clients, and shuts
// down gracefully (ConPTY dispose first, then clients, then listener).
//
// This file is cross-platform; only the real conptyConn impl is Windows-tagged.
package conpty
import (
"context"
"encoding/json"
"io"
"net"
"sync"
"time"
)
// ptyConn is the host's handle to the running agent's pseudo-terminal.
// The real impl (conptyConn) lives in host_conpty_windows.go; tests use a fake.
type ptyConn interface {
io.Reader // PTY output (raw bytes from the terminal)
io.Writer // PTY input (keystrokes to the terminal)
Resize(cols, rows int) error
Close() error // dispose the ConPTY
Done() <-chan struct{} // closed when the child process exits
ExitCode() (int, bool) // (code, true) once exited; (0, false) while running
PID() int
}
// ServeConfig carries everything the host needs.
type ServeConfig struct {
SessionID string
Listener net.Listener // caller provides (loopback); engine owns Accept loop
PTY ptyConn
Ring *Ring
}
// Serve runs the host event loop until the listener closes or Shutdown is
// invoked via the returned ShutdownFunc. It pumps PTY output into the ring
// and broadcasts to all clients, accepts new clients (replaying ring snapshot),
// and dispatches client messages. On PTY exit it broadcasts a status update
// but stays alive (keep-alive, mirroring tmux behavior). Returns when shut down.
func Serve(ctx context.Context, cfg ServeConfig) error {
h := &host{
cfg: cfg,
clients: make(map[net.Conn]struct{}),
shutdownC: make(chan struct{}),
}
return h.run(ctx)
}
// host holds the mutable state for a single pty-host session.
type host struct {
cfg ServeConfig
mu sync.Mutex
clients map[net.Conn]struct{}
shutdownOnce sync.Once
shutdownC chan struct{} // closed when Shutdown is called
}
// run is the main event loop.
func (h *host) run(ctx context.Context) error {
// Pump PTY output to ring + broadcast.
go h.pumpPTY()
// Watch for ctx cancellation and trigger shutdown.
go func() {
select {
case <-ctx.Done():
h.shutdown()
case <-h.shutdownC:
}
}()
// runAcceptLoop accepts connections until the listener closes. A listener
// close is normal (shutdown or external) and is treated as success.
h.runAcceptLoop()
return nil
}
// runAcceptLoop runs the Accept loop until the listener closes or returns an
// error. Listener-close errors are swallowed; they signal normal shutdown.
func (h *host) runAcceptLoop() {
for {
conn, err := h.cfg.Listener.Accept()
if err != nil {
return
}
go h.handleConn(conn)
}
}
// shutdown is idempotent: disposes the ConPTY, closes clients, closes the
// listener. Mirrors the pty-host.ts shutdown() function.
// ponytail: 50ms sleep after pty.Close() gives the OS ConPTY helper
// (conpty_console_list_agent.exe) time to release cleanly; avoids the
// 0x800700e8 error dialog on Windows.
func (h *host) shutdown() {
h.shutdownOnce.Do(func() {
close(h.shutdownC)
// 1. Dispose the ConPTY first (critical ordering).
_ = h.cfg.PTY.Close()
// 2. Brief grace so the OS ConPTY helper can clean up.
time.Sleep(50 * time.Millisecond)
// 3. Close all client connections.
h.mu.Lock()
for c := range h.clients {
_ = c.Close()
}
h.clients = make(map[net.Conn]struct{})
h.mu.Unlock()
// 4. Close the listener to unblock Accept.
_ = h.cfg.Listener.Close()
})
}
// pumpPTY reads PTY output continuously, appends to the ring, and broadcasts
// to clients. On PTY exit it flushes the partial line and sends a status
// update but does NOT close the listener (keep-alive).
func (h *host) pumpPTY() {
buf := make([]byte, 32*1024)
for {
n, err := h.cfg.PTY.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
h.cfg.Ring.Append(chunk)
if frame, err := EncodeMessage(MsgTerminalData, chunk); err == nil {
h.broadcast(frame)
}
}
if err != nil {
break
}
}
// PTY reader is done (process exited or PTY closed). Wait for the Done
// signal so ExitCode is populated before we send the status broadcast.
<-h.cfg.PTY.Done()
h.cfg.Ring.FlushPartial()
code, _ := h.cfg.PTY.ExitCode()
pid := h.cfg.PTY.PID()
h.broadcast(statusFrame(false, pid, &code))
// Keep-alive: do NOT shutdown here. The host stays up so clients can
// still connect and read scrollback.
}
// broadcast sends msg to all connected clients, removing any that error.
func (h *host) broadcast(msg []byte) {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
if _, err := c.Write(msg); err != nil {
_ = c.Close()
delete(h.clients, c)
}
}
}
// sendTo sends msg to a single conn (best-effort; removes on error).
func (h *host) sendTo(conn net.Conn, msg []byte) {
if _, err := conn.Write(msg); err != nil {
h.mu.Lock()
_ = conn.Close()
delete(h.clients, conn)
h.mu.Unlock()
}
}
// handleConn manages the lifecycle of a single client connection.
func (h *host) handleConn(conn net.Conn) {
// Scrollback replay: take the ring snapshot, write it to the conn, and add
// the conn to the broadcast set all under a SINGLE h.mu hold. broadcast()
// also takes h.mu, so it cannot interleave: any PTY chunk that arrives is
// either already in this snapshot, or is broadcast strictly after the conn
// joins the set. Doing this in two separate locks would let a chunk slip
// into the gap (in neither the snapshot nor this client's broadcast) and be
// silently dropped.
// ponytail: the snapshot write happens while holding h.mu. It is bounded by
// MaxOutputLines (the ring cap), so the lock hold is bounded; upgrade path
// is a per-client send queue if a slow client ever stalls broadcast.
h.mu.Lock()
snap := h.cfg.Ring.Snapshot()
if len(snap) > 0 {
snapFrame, err := EncodeMessage(MsgTerminalData, snap)
if err == nil {
_, err = conn.Write(snapFrame)
}
if err != nil {
h.mu.Unlock()
_ = conn.Close()
return
}
}
h.clients[conn] = struct{}{}
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
_ = conn.Close()
}()
parser := NewMessageParser(func(msgType byte, payload []byte) {
h.handleClientMsg(conn, msgType, payload)
})
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if n > 0 {
parser.Feed(buf[:n])
}
if err != nil {
return
}
}
}
// handleClientMsg dispatches a decoded client message. Mirrors handleClientMessage
// from pty-host.ts.
func (h *host) handleClientMsg(conn net.Conn, msgType byte, payload []byte) {
switch msgType {
case MsgTerminalInput:
if _, alive := h.cfg.PTY.ExitCode(); !alive {
_, _ = h.cfg.PTY.Write(payload)
}
case MsgResize:
if _, alive := h.cfg.PTY.ExitCode(); !alive {
var rp ResizePayload
if err := json.Unmarshal(payload, &rp); err == nil {
_ = h.cfg.PTY.Resize(rp.Cols, rp.Rows)
}
// Malformed resize: ignore (matches TS behavior).
}
case MsgGetOutputReq:
lines := 50 // default matches TS
var req GetOutputReq
if err := json.Unmarshal(payload, &req); err == nil && req.Lines > 0 {
lines = req.Lines
}
text := h.cfg.Ring.Tail(lines)
if frame, err := EncodeMessage(MsgGetOutputRes, []byte(text)); err == nil {
h.sendTo(conn, frame)
}
case MsgStatusReq:
code, exited := h.cfg.PTY.ExitCode()
alive := !exited
pid := h.cfg.PTY.PID()
var codePtr *int
if exited {
codePtr = &code
}
h.sendTo(conn, statusFrame(alive, pid, codePtr))
case MsgKillReq:
// Trigger graceful shutdown; returns immediately (idempotent).
go h.shutdown()
}
}
// statusFrame builds a MsgStatusRes frame.
func statusFrame(alive bool, pid int, exitCode *int) []byte {
sp := StatusPayload{Alive: alive, PID: pid, ExitCode: exitCode}
b, _ := json.Marshal(sp)
frame, _ := EncodeMessage(MsgStatusRes, b) // b is small JSON, never overflows uint32
return frame
}