first commit
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user