281 lines
7.9 KiB
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
|
|
}
|