// 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 }