234 lines
6.9 KiB
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
|
|
}
|