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

234 lines
6.9 KiB
Go

// client.go - loopback TCP client helpers that mirror pty-client.ts.
// Each function dials the host addr fresh (short-lived connection) and
// returns without maintaining state. Cross-platform: uses only stdlib net.
package conpty
import (
"encoding/json"
"errors"
"net"
"syscall"
"time"
)
const (
// ptyInputChunkRunes is the max runes per terminal-input frame.
// Mirrors PTY_INPUT_CHUNK_CHARS in pty-client.ts.
ptyInputChunkRunes = 512
// ptyInputChunkDelay is the inter-chunk delay. Mirrors PTY_INPUT_CHUNK_DELAY_MS.
ptyInputChunkDelay = 15 * time.Millisecond
// ptyInputEnterDelay is the pause before sending Enter. Mirrors PTY_INPUT_ENTER_DELAY_MS.
ptyInputEnterDelay = 300 * time.Millisecond
dialTimeout = 3 * time.Second
getOutputTimeout = 3 * time.Second
isAliveTimeout = 2 * time.Second
)
// dialHost opens a TCP connection to addr with a deadline. Callers close it.
func dialHost(addr string, timeout time.Duration) (net.Conn, error) {
return net.DialTimeout("tcp", addr, timeout)
}
// clientSendMessage chunks message by 512 runes and sends each as a
// MsgTerminalInput frame with 15ms gaps, then pauses 300ms and sends "\r".
// Mirrors ptyHostSendMessage from pty-client.ts.
func clientSendMessage(addr, message string) error {
conn, err := dialHost(addr, dialTimeout)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
runes := []rune(message)
for i := 0; i < len(runes); i += ptyInputChunkRunes {
end := i + ptyInputChunkRunes
if end > len(runes) {
end = len(runes)
}
chunk := string(runes[i:end])
frame, err := EncodeMessage(MsgTerminalInput, []byte(chunk))
if err != nil {
return err
}
if _, err := conn.Write(frame); err != nil {
return err
}
// Inter-chunk delay only between chunks, not after the last one.
if end < len(runes) {
time.Sleep(ptyInputChunkDelay)
}
}
// Brief pause before Enter (matches TS: Enter sent as a separate frame).
time.Sleep(ptyInputEnterDelay)
frame, err := EncodeMessage(MsgTerminalInput, []byte("\r"))
if err != nil {
return err
}
_, err = conn.Write(frame)
return err
}
// clientGetOutput sends MsgGetOutputReq and reads frames until MsgGetOutputRes.
// Returns "" on timeout or connection failure (no error), matching the TS.
// lines <= 0 is handled by the caller (runtime.go rejects it before calling).
func clientGetOutput(addr string, lines int) (string, error) {
conn, err := dialHost(addr, getOutputTimeout)
if err != nil {
return "", nil // ponytail: connect failure -> "" like the TS
}
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(getOutputTimeout))
req, _ := json.Marshal(GetOutputReq{Lines: lines})
reqFrame, _ := EncodeMessage(MsgGetOutputReq, req) // req is small JSON, never overflows uint32
if _, err := conn.Write(reqFrame); err != nil {
return "", nil
}
resultC := make(chan string, 1)
parser := NewMessageParser(func(msgType byte, payload []byte) {
if msgType == MsgGetOutputRes {
select {
case resultC <- string(payload):
default:
}
}
})
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if n > 0 {
parser.Feed(buf[:n])
}
select {
case text := <-resultC:
return text, nil
default:
}
if err != nil {
break
}
}
// Drain the channel one last time after the read loop ends.
select {
case text := <-resultC:
return text, nil
default:
return "", nil // timeout or EOF before response
}
}
// clientIsAlive probes the host with MsgStatusReq and distinguishes three
// outcomes for the reaper (see IsAlive in runtime.go):
//
// - alive==true, transientErr==nil: a valid MsgStatusRes was received.
// - alive==false, transientErr==nil: the host is DEFINITIVELY gone (the dial
// was refused: nothing is listening on the loopback addr).
// - alive==false, transientErr!=nil: a TRANSIENT probe failure (network
// timeout, or any connected-then-failed I/O error). The reaper records this
// as ProbeFailed and retries instead of reaping a possibly-live session.
//
// When unsure, we prefer transient (return the error) rather than reporting
// death. Mirrors ptyHostIsAlive from pty-client.ts on the alive path: host
// reachable == alive, regardless of the inner agent's alive field.
func clientIsAlive(addr string) (alive bool, transientErr error) {
conn, err := dialHost(addr, isAliveTimeout)
if err != nil {
// A dial timeout is transient (the loopback hiccupped). A refused
// connection means nothing is listening -> definitively gone. Any
// other dial failure is treated as transient ("when unsure, retry").
if isTimeout(err) {
return false, err
}
if isConnRefused(err) {
return false, nil
}
return false, err
}
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(isAliveTimeout))
statusReqFrame, _ := EncodeMessage(MsgStatusReq, nil) // nil payload, never overflows
if _, err := conn.Write(statusReqFrame); err != nil {
// We connected, then the write failed: connected-then-failed I/O is
// transient (the host may still be up; the conn was disrupted).
return false, err
}
aliveC := make(chan bool, 1)
parser := NewMessageParser(func(msgType byte, payload []byte) {
if msgType == MsgStatusRes {
var sp StatusPayload
ok := json.Unmarshal(payload, &sp) == nil
select {
case aliveC <- ok:
default:
}
}
})
buf := make([]byte, 4096)
var lastErr error
for {
n, err := conn.Read(buf)
if n > 0 {
parser.Feed(buf[:n])
}
select {
case result := <-aliveC:
return result, nil
default:
}
if err != nil {
lastErr = err
break
}
}
select {
case result := <-aliveC:
return result, nil
default:
// Connected but never got a STATUS_RES: read timeout or mid-read EOF.
// lastErr is the error that broke the read loop (always non-nil here).
return false, lastErr
}
}
// isTimeout reports whether err is a network timeout (dial timeout or
// read-deadline expiry). Cross-platform via the net.Error interface.
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
// isConnRefused reports whether err is a fast "connection refused" dial
// failure (nothing listening). errors.Is(ECONNREFUSED) covers Unix and modern
// Windows; the explicit WSAECONNREFUSED (10061) guards older Windows runtimes
// where the errno is not mapped to syscall.ECONNREFUSED.
func isConnRefused(err error) bool {
if errors.Is(err, syscall.ECONNREFUSED) {
return true
}
const wsaeconnrefused = syscall.Errno(10061)
return errors.Is(err, wsaeconnrefused)
}
// clientKill sends MsgKillReq best-effort. Connect failure is a no-op
// (host already dead). Mirrors ptyHostKill from pty-client.ts.
func clientKill(addr string) error {
conn, err := dialHost(addr, isAliveTimeout)
if err != nil {
return nil // already dead
}
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(isAliveTimeout))
killFrame, _ := EncodeMessage(MsgKillReq, nil) // nil payload, never overflows
_, _ = conn.Write(killFrame)
return nil
}