294 lines
7.3 KiB
Go
294 lines
7.3 KiB
Go
// This file implements the subprocess JSON-RPC client (US-014/#116). A Client
|
|
// launches an external executable, writes requests to its stdin and reads
|
|
// newline-delimited JSON-RPC messages from its stdout on a background reader
|
|
// goroutine. Requests are correlated to responses by id through a pending-call
|
|
// map, so Call is safe for concurrent use: each caller blocks only on its own
|
|
// response channel until the reply arrives, the context is cancelled, or the
|
|
// child exits.
|
|
package jsonrpc
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// closeGrace bounds how long Close waits for a child to exit on its own after
|
|
// stdin is closed, before force-killing it.
|
|
const closeGrace = 5 * time.Second
|
|
|
|
// ErrClosed is returned by Call once the client has been closed or the child
|
|
// process has exited.
|
|
var ErrClosed = errors.New("jsonrpc: client closed")
|
|
|
|
// Client is a JSON-RPC 2.0 client bound to a subprocess over its stdio.
|
|
type Client struct {
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
stdout io.ReadCloser
|
|
|
|
writeMu sync.Mutex // serializes writes to the child's stdin
|
|
nextID atomic.Int64
|
|
|
|
mu sync.Mutex // guards pending and closed
|
|
pending map[string]chan res // id -> waiter
|
|
closed bool
|
|
closeErr error
|
|
|
|
done chan struct{} // closed when the reader goroutine exits
|
|
}
|
|
|
|
// res carries a decoded response (or a transport-level error) to a waiter.
|
|
type res struct {
|
|
resp *Response
|
|
err error
|
|
}
|
|
|
|
// Config describes the subprocess to launch.
|
|
type Config struct {
|
|
// Command is the executable path.
|
|
Command string
|
|
// Args are the process arguments (excluding the command itself).
|
|
Args []string
|
|
// Env is the child's environment (os/exec form: "KEY=value"). When nil the
|
|
// child inherits the parent environment.
|
|
Env []string
|
|
// Dir is the child's working directory; empty means the parent's.
|
|
Dir string
|
|
// Stderr optionally receives the child's stderr (e.g. for logging). When nil
|
|
// the child's stderr is discarded.
|
|
Stderr io.Writer
|
|
}
|
|
|
|
// NewClient starts the subprocess and begins reading its stdout. The caller must
|
|
// Close the client to terminate the child and release resources.
|
|
func NewClient(cfg Config) (*Client, error) {
|
|
if cfg.Command == "" {
|
|
return nil, errors.New("jsonrpc: empty command")
|
|
}
|
|
cmd := exec.Command(cfg.Command, cfg.Args...)
|
|
cmd.Env = cfg.Env
|
|
cmd.Dir = cfg.Dir
|
|
cmd.Stderr = cfg.Stderr
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jsonrpc: stdin pipe: %w", err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jsonrpc: stdout pipe: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("jsonrpc: start %q: %w", cfg.Command, err)
|
|
}
|
|
|
|
c := &Client{
|
|
cmd: cmd,
|
|
stdin: stdin,
|
|
stdout: stdout,
|
|
pending: make(map[string]chan res),
|
|
done: make(chan struct{}),
|
|
}
|
|
go c.readLoop()
|
|
return c, nil
|
|
}
|
|
|
|
// readLoop reads newline-delimited JSON messages from the child's stdout and
|
|
// dispatches each response to its waiter. It exits when stdout hits EOF/error,
|
|
// failing all outstanding calls.
|
|
func (c *Client) readLoop() {
|
|
defer close(c.done)
|
|
scanner := bufio.NewScanner(c.stdout)
|
|
// MCP/plugin payloads (e.g. tool schemas) can be large; raise the line cap.
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var resp Response
|
|
if err := json.Unmarshal(line, &resp); err != nil {
|
|
// A line we can't parse as a response is skipped (it may be a
|
|
// server->client request/notification, which this minimal client
|
|
// does not handle).
|
|
continue
|
|
}
|
|
if resp.ID == nil {
|
|
continue // notification from server; nothing to correlate
|
|
}
|
|
c.deliver(resp.ID.String(), res{resp: &resp})
|
|
}
|
|
|
|
err := scanner.Err()
|
|
if err == nil {
|
|
err = io.EOF
|
|
}
|
|
c.failAll(fmt.Errorf("jsonrpc: reader stopped: %w", err))
|
|
}
|
|
|
|
// deliver hands a response to its waiter (if still registered).
|
|
func (c *Client) deliver(id string, r res) {
|
|
c.mu.Lock()
|
|
ch, ok := c.pending[id]
|
|
if ok {
|
|
delete(c.pending, id)
|
|
}
|
|
c.mu.Unlock()
|
|
if ok {
|
|
ch <- r
|
|
}
|
|
}
|
|
|
|
// failAll completes every outstanding call with err and marks the client closed.
|
|
func (c *Client) failAll(err error) {
|
|
c.mu.Lock()
|
|
if c.closeErr == nil {
|
|
c.closeErr = err
|
|
}
|
|
c.closed = true
|
|
pending := c.pending
|
|
c.pending = make(map[string]chan res)
|
|
c.mu.Unlock()
|
|
for _, ch := range pending {
|
|
ch <- res{err: err}
|
|
}
|
|
}
|
|
|
|
// Call sends a request and waits for the matching response. It returns the
|
|
// decoded Result on success, the server Error as error on an error response, or
|
|
// a transport/context error. Call is safe for concurrent use.
|
|
func (c *Client) Call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
|
id := NumID(c.nextID.Add(1))
|
|
req, err := newRequest(&id, method, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ch := make(chan res, 1)
|
|
c.mu.Lock()
|
|
if c.closed {
|
|
c.mu.Unlock()
|
|
return nil, c.closedErr()
|
|
}
|
|
c.pending[id.String()] = ch
|
|
c.mu.Unlock()
|
|
|
|
if err := c.write(req); err != nil {
|
|
c.mu.Lock()
|
|
delete(c.pending, id.String())
|
|
c.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
c.mu.Lock()
|
|
delete(c.pending, id.String())
|
|
c.mu.Unlock()
|
|
return nil, ctx.Err()
|
|
case r := <-ch:
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
if r.resp.Error != nil {
|
|
return nil, r.resp.Error
|
|
}
|
|
return r.resp.Result, nil
|
|
}
|
|
}
|
|
|
|
// Notify sends a notification (no id, no response expected).
|
|
func (c *Client) Notify(method string, params any) error {
|
|
req, err := newRequest(nil, method, params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.mu.Lock()
|
|
if c.closed {
|
|
c.mu.Unlock()
|
|
return c.closedErr()
|
|
}
|
|
c.mu.Unlock()
|
|
return c.write(req)
|
|
}
|
|
|
|
// write serializes a message and writes it as one newline-terminated line.
|
|
// Writes are serialized so concurrent callers don't interleave bytes on stdin.
|
|
func (c *Client) write(msg any) error {
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return fmt.Errorf("jsonrpc: marshal request: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
c.writeMu.Lock()
|
|
defer c.writeMu.Unlock()
|
|
if _, err := c.stdin.Write(data); err != nil {
|
|
return fmt.Errorf("jsonrpc: write: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// closedErr reports why the client is closed, defaulting to ErrClosed.
|
|
func (c *Client) closedErr() error {
|
|
if c.closeErr != nil {
|
|
return c.closeErr
|
|
}
|
|
return ErrClosed
|
|
}
|
|
|
|
// Close closes the child's stdin (signalling graceful shutdown), waits briefly
|
|
// for the process to exit, and kills it if it does not. It is idempotent.
|
|
func (c *Client) Close() error {
|
|
c.mu.Lock()
|
|
if c.closed {
|
|
c.mu.Unlock()
|
|
<-c.done
|
|
return nil
|
|
}
|
|
c.closed = true
|
|
if c.closeErr == nil {
|
|
c.closeErr = ErrClosed
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
// Closing stdin lets a well-behaved child exit on its own.
|
|
_ = c.stdin.Close()
|
|
|
|
// Wait for the child; kill if it doesn't stop promptly. The grace timer
|
|
// bounds Close so a child that ignores stdin EOF and keeps stdout open
|
|
// (a hung plugin/server) cannot block us forever.
|
|
waitErr := make(chan error, 1)
|
|
go func() { waitErr <- c.cmd.Wait() }()
|
|
|
|
grace := time.NewTimer(closeGrace)
|
|
defer grace.Stop()
|
|
|
|
select {
|
|
case <-waitErr:
|
|
<-c.done
|
|
return nil
|
|
case <-c.done:
|
|
// reader saw EOF; give Wait a brief moment, then force kill.
|
|
case <-grace.C:
|
|
// child hasn't exited within the grace period; force it.
|
|
}
|
|
|
|
select {
|
|
case <-waitErr:
|
|
default:
|
|
_ = c.cmd.Process.Kill()
|
|
<-waitErr
|
|
}
|
|
<-c.done
|
|
return nil
|
|
}
|