first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
// Package jsonrpc implements a minimal JSON-RPC 2.0 client over a subprocess's
// stdio (US-014/#116). It is the shared transport foundation reused by the MCP
// client (#130/#131), the plugin system (#132/#133) and process-isolated
// sub-agents (#135): each spawns an external executable and speaks line-delimited
// JSON-RPC 2.0 over the child's stdin/stdout.
//
// The wire format follows the spec: every message carries "jsonrpc":"2.0". A
// request has an id and expects a matching response; a notification omits the id
// and expects none. Requests and responses are correlated by id, so concurrent
// requests from different goroutines are safe — each waits only on its own reply.
//
// This file defines the message envelope and (de)serialization; transport.go
// implements the subprocess client.
package jsonrpc
import (
"encoding/json"
"fmt"
)
// Version is the only JSON-RPC protocol version this package speaks.
const Version = "2.0"
// ID is a JSON-RPC request identifier. The spec allows a string or a number;
// this client only ever generates numeric ids, but ID round-trips whatever a
// peer sends back so response correlation still works against servers that echo
// string ids.
type ID struct {
num int64
str string
isStr bool
}
// NumID returns a numeric request id.
func NumID(n int64) ID { return ID{num: n} }
// String renders the id for use as a map key when correlating responses.
func (id ID) String() string {
if id.isStr {
return "s:" + id.str
}
return fmt.Sprintf("n:%d", id.num)
}
// MarshalJSON emits the id as its underlying JSON scalar (number or string).
func (id ID) MarshalJSON() ([]byte, error) {
if id.isStr {
return json.Marshal(id.str)
}
return json.Marshal(id.num)
}
// UnmarshalJSON accepts either a JSON number or string id.
func (id *ID) UnmarshalJSON(data []byte) error {
var n int64
if err := json.Unmarshal(data, &n); err == nil {
id.num, id.isStr, id.str = n, false, ""
return nil
}
var s string
if err := json.Unmarshal(data, &s); err == nil {
id.str, id.isStr, id.num = s, true, 0
return nil
}
return fmt.Errorf("jsonrpc: id is neither number nor string: %s", data)
}
// Request is an outgoing JSON-RPC request or notification. When ID is nil the
// message is a notification (no response expected).
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID *ID `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// Response is an incoming JSON-RPC response. Exactly one of Result / Error is
// set on a well-formed reply.
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID *ID `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
// Error is a JSON-RPC error object.
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
// Error implements the error interface so a peer error can be returned directly.
func (e *Error) Error() string {
return fmt.Sprintf("jsonrpc: server error %d: %s", e.Code, e.Message)
}
// newRequest builds a request (id != nil) or notification (id == nil) with the
// given params marshaled to JSON. A nil params value is omitted from the wire
// message.
func newRequest(id *ID, method string, params any) (*Request, error) {
req := &Request{JSONRPC: Version, ID: id, Method: method}
if params != nil {
raw, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("jsonrpc: marshal params for %q: %w", method, err)
}
req.Params = raw
}
return req, nil
}
+293
View File
@@ -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
}
+223
View File
@@ -0,0 +1,223 @@
package jsonrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
)
// TestMain lets this test binary double as a mock JSON-RPC server subprocess.
// When JSONRPC_TEST_SERVER is set the process runs the echo server and exits;
// otherwise it runs the normal test suite. This is the standard Go pattern for
// exercising a subprocess transport without shipping a separate helper binary.
func TestMain(m *testing.M) {
switch os.Getenv("JSONRPC_TEST_SERVER") {
case "echo":
runEchoServer()
return
case "silent":
// Read and discard everything, never reply — used for timeout tests.
sc := bufio.NewScanner(os.Stdin)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for sc.Scan() {
}
return
}
os.Exit(m.Run())
}
// runEchoServer replies to each request: method "echo" returns its params,
// method "fail" returns a JSON-RPC error, notifications produce no reply.
func runEchoServer() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
w := bufio.NewWriter(os.Stdout)
for scanner.Scan() {
var req Request
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
continue
}
if req.ID == nil {
continue // notification: no response
}
var resp Response
resp.JSONRPC = Version
resp.ID = req.ID
switch req.Method {
case "fail":
resp.Error = &Error{Code: -32000, Message: "boom"}
default:
resp.Result = req.Params
if resp.Result == nil {
resp.Result = json.RawMessage(`null`)
}
}
out, _ := json.Marshal(&resp)
out = append(out, '\n')
_, _ = w.Write(out)
_ = w.Flush()
}
}
// newTestClient starts this test binary as a mock server in the given mode.
func newTestClient(t *testing.T, mode string) *Client {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable: %v", err)
}
c, err := NewClient(Config{
Command: exe,
Env: append(os.Environ(), "JSONRPC_TEST_SERVER="+mode),
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
func TestCallEcho(t *testing.T) {
c := newTestClient(t, "echo")
ctx := context.Background()
raw, err := c.Call(ctx, "echo", map[string]any{"hello": "world"})
if err != nil {
t.Fatalf("Call: %v", err)
}
var got map[string]string
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if got["hello"] != "world" {
t.Fatalf("got %v, want hello=world", got)
}
}
func TestCallServerError(t *testing.T) {
c := newTestClient(t, "echo")
_, err := c.Call(context.Background(), "fail", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
var rpcErr *Error
if !errors.As(err, &rpcErr) {
t.Fatalf("expected *jsonrpc.Error, got %T: %v", err, err)
}
if rpcErr.Code != -32000 || !strings.Contains(rpcErr.Message, "boom") {
t.Fatalf("unexpected error: %+v", rpcErr)
}
}
// TestConcurrentCalls verifies responses correlate to the right caller when many
// requests are in flight at once (id-based correlation).
func TestConcurrentCalls(t *testing.T) {
c := newTestClient(t, "echo")
ctx := context.Background()
const n = 50
var wg sync.WaitGroup
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(i int) {
defer wg.Done()
raw, err := c.Call(ctx, "echo", map[string]int{"n": i})
if err != nil {
errs[i] = err
return
}
var got map[string]int
if err := json.Unmarshal(raw, &got); err != nil {
errs[i] = err
return
}
if got["n"] != i {
errs[i] = fmt.Errorf("call %d got n=%d", i, got["n"])
}
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
}
// TestCallContextTimeout verifies Call returns when the context is cancelled and
// the server never replies.
func TestCallContextTimeout(t *testing.T) {
c := newTestClient(t, "silent")
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
_, err := c.Call(ctx, "echo", nil)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if time.Since(start) > 2*time.Second {
t.Fatalf("Call blocked too long: %v", time.Since(start))
}
}
func TestNotifyNoResponse(t *testing.T) {
c := newTestClient(t, "echo")
if err := c.Notify("ping", map[string]string{"k": "v"}); err != nil {
t.Fatalf("Notify: %v", err)
}
// A subsequent Call must still work (notification produced no stray reply).
if _, err := c.Call(context.Background(), "echo", nil); err != nil {
t.Fatalf("Call after Notify: %v", err)
}
}
func TestCallAfterClose(t *testing.T) {
c := newTestClient(t, "echo")
if err := c.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if _, err := c.Call(context.Background(), "echo", nil); err == nil {
t.Fatal("expected error calling closed client")
}
}
func TestNewClientEmptyCommand(t *testing.T) {
if _, err := NewClient(Config{}); err == nil {
t.Fatal("expected error for empty command")
}
}
func TestIDRoundTrip(t *testing.T) {
for _, tc := range []struct {
name string
raw string
}{
{"number", `123`},
{"string", `"abc"`},
} {
var id ID
if err := json.Unmarshal([]byte(tc.raw), &id); err != nil {
t.Fatalf("%s: unmarshal: %v", tc.name, err)
}
out, err := json.Marshal(id)
if err != nil {
t.Fatalf("%s: marshal: %v", tc.name, err)
}
if string(out) != tc.raw {
t.Fatalf("%s: round-trip got %s want %s", tc.name, out, tc.raw)
}
}
var bad ID
if err := json.Unmarshal([]byte(`true`), &bad); err == nil {
t.Fatal("expected error for boolean id")
}
}