Files
2026-07-13 13:00:08 +08:00

332 lines
9.6 KiB
Go

package acp
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"strconv"
"sync"
"sync/atomic"
)
// maxMessageBytes caps a single inbound NDJSON line. ACP messages can embed
// resource text, so the limit is generous; a line past it is a framing error.
const maxMessageBytes = 32 << 20 // 32 MiB
// RequestHandler answers an inbound JSON-RPC request. The returned value is
// marshaled as the response result. To control the error code, return a
// *RPCError; any other error becomes ErrInternal.
type RequestHandler func(ctx context.Context, params json.RawMessage) (any, error)
// NotificationHandler reacts to an inbound notification. It cannot reply, so it
// returns nothing — errors have nowhere to go on the wire (stderr would corrupt
// stdout, which is the JSON-RPC channel).
type NotificationHandler func(ctx context.Context, params json.RawMessage)
// RPCError lets a handler choose the JSON-RPC error code returned to the client.
type RPCError struct {
Code int
Message string
}
func (e *RPCError) Error() string { return e.Message }
// Conn is one NDJSON JSON-RPC 2.0 connection over a reader/writer pair (stdin/
// stdout in production). It dispatches inbound requests and notifications to
// registered handlers, and can itself send outbound notifications (session/update)
// and requests (session/request_permission), correlating replies by id.
//
// Writes are serialized by a mutex, so handlers running on separate goroutines
// (a long session/prompt alongside a session/cancel) never interleave a line.
// It implements notifier, the dependency the dispatch sink takes.
type Conn struct {
r io.Reader
wmu sync.Mutex
enc *json.Encoder
nextID atomic.Int64
pmu sync.Mutex
pending map[int64]chan rpcResult
reqH map[string]RequestHandler
notH map[string]NotificationHandler
wg sync.WaitGroup
closeOnce sync.Once
closed chan struct{}
}
// rpcResult is the outcome of an outbound request, delivered to the waiter.
type rpcResult struct {
result json.RawMessage
err error
}
// rpcError is the JSON-RPC error object on the wire.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
// outbound is a JSON-RPC frame we send. omitempty fields select between request,
// notification, success response, and error response shapes.
type outbound struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// inbound is a parsed JSON-RPC frame we received. The combination of id/method
// presence distinguishes request, notification, and response.
type inbound struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
// NewConn wires a connection over r (inbound) and w (outbound). Register handlers
// with Handle / HandleNotify before calling Serve. The encoder disables HTML
// escaping so payloads match main's JSON.stringify output byte-for-byte.
func NewConn(r io.Reader, w io.Writer) *Conn {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return &Conn{
r: r,
enc: enc,
pending: make(map[int64]chan rpcResult),
reqH: make(map[string]RequestHandler),
notH: make(map[string]NotificationHandler),
closed: make(chan struct{}),
}
}
// Handle registers a request handler for method. Not safe to call concurrently
// with Serve; wire all handlers up first.
func (c *Conn) Handle(method string, h RequestHandler) { c.reqH[method] = h }
// HandleNotify registers a notification handler for method.
func (c *Conn) HandleNotify(method string, h NotificationHandler) { c.notH[method] = h }
// Serve reads inbound frames until the reader ends or ctx is cancelled. Each
// inbound request/notification runs on its own goroutine so a long-running prompt
// does not block cancellation or permission replies. When the read loop ends it
// cancels in-flight handlers (so prompts abort) and waits for them to return —
// flushing fast responses and unwinding aborted ones — before failing any
// outstanding outbound requests. Returns nil on clean EOF.
func (c *Conn) Serve(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
br := bufio.NewReaderSize(c.r, 64<<10)
var loopErr error
for {
line, err := readLine(br)
if len(line) > 0 {
c.dispatch(ctx, line)
}
if err != nil {
if !errors.Is(err, io.EOF) {
loopErr = err
}
break
}
if err := ctx.Err(); err != nil {
loopErr = err
break
}
}
cancel() // abort in-flight handlers (prompts unwind via ctx)
c.wg.Wait() // let them flush their responses before we tear down
c.shutdown() // fail any still-pending outbound requests
return loopErr
}
// readLine reads one NDJSON line (without the trailing newline), enforcing the
// size cap. It returns the line and any read error; on EOF it still returns the
// trailing partial line so a final newline-less frame is processed.
func readLine(br *bufio.Reader) ([]byte, error) {
var buf []byte
for {
chunk, err := br.ReadSlice('\n')
buf = append(buf, chunk...)
if len(buf) > maxMessageBytes {
return nil, errors.New("acp: message exceeds size limit")
}
if err == bufio.ErrBufferFull {
continue
}
// Trim the trailing newline (and CR) if present.
n := len(buf)
for n > 0 && (buf[n-1] == '\n' || buf[n-1] == '\r') {
n--
}
return trimSpace(buf[:n]), err
}
}
// trimSpace drops leading/trailing ASCII whitespace without allocating.
func trimSpace(b []byte) []byte {
i, j := 0, len(b)
for i < j && isSpace(b[i]) {
i++
}
for j > i && isSpace(b[j-1]) {
j--
}
return b[i:j]
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
// dispatch parses one frame and routes it. Requests and notifications fan out to
// goroutines; responses resolve inline (they are cheap and need ordering only
// against the pending map, which is mutex-guarded).
func (c *Conn) dispatch(ctx context.Context, line []byte) {
var in inbound
if err := json.Unmarshal(line, &in); err != nil {
c.writeError(json.RawMessage("null"), ErrParse, "parse error")
return
}
hasID := len(in.ID) > 0
switch {
case in.Method != "" && hasID:
c.wg.Add(1)
go func() {
defer c.wg.Done()
c.serveRequest(ctx, in.ID, in.Method, in.Params)
}()
case in.Method != "" && !hasID:
if h := c.notH[in.Method]; h != nil {
c.wg.Add(1)
go func() {
defer c.wg.Done()
h(ctx, in.Params)
}()
}
case in.Method == "" && hasID:
c.resolve(in)
default:
c.writeError(json.RawMessage("null"), ErrInvalidRequest, "invalid request")
}
}
// serveRequest runs a request handler and writes its response (or error).
func (c *Conn) serveRequest(ctx context.Context, id json.RawMessage, method string, params json.RawMessage) {
h := c.reqH[method]
if h == nil {
c.writeError(id, ErrMethodNotFound, "method not found: "+method)
return
}
result, err := h(ctx, params)
if err != nil {
code := ErrInternal
var re *RPCError
if errors.As(err, &re) {
code = re.Code
}
c.writeError(id, code, err.Error())
return
}
raw, err := json.Marshal(result)
if err != nil {
c.writeError(id, ErrInternal, "marshal result: "+err.Error())
return
}
_ = c.write(outbound{JSONRPC: "2.0", ID: id, Result: raw})
}
// resolve delivers a response to the goroutine waiting on its outbound request.
func (c *Conn) resolve(in inbound) {
id, err := strconv.ParseInt(string(in.ID), 10, 64)
if err != nil {
return // we only issue integer ids; an unparsable id isn't ours
}
c.pmu.Lock()
ch := c.pending[id]
delete(c.pending, id)
c.pmu.Unlock()
if ch == nil {
return
}
if in.Error != nil {
ch <- rpcResult{err: errors.New(in.Error.Message)}
return
}
ch <- rpcResult{result: in.Result}
}
// Notify sends a fire-and-forget notification. Satisfies notifier.
func (c *Conn) Notify(method string, params any) error {
raw, err := json.Marshal(params)
if err != nil {
return err
}
return c.write(outbound{JSONRPC: "2.0", Method: method, Params: raw})
}
// Request sends an outbound request and blocks until the peer responds, ctx is
// cancelled, or the connection closes. Satisfies notifier.
func (c *Conn) Request(ctx context.Context, method string, params any) (json.RawMessage, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, err
}
id := c.nextID.Add(1)
ch := make(chan rpcResult, 1)
c.pmu.Lock()
c.pending[id] = ch
c.pmu.Unlock()
defer func() {
c.pmu.Lock()
delete(c.pending, id)
c.pmu.Unlock()
}()
idRaw, _ := json.Marshal(id)
if err := c.write(outbound{JSONRPC: "2.0", ID: idRaw, Method: method, Params: raw}); err != nil {
return nil, err
}
select {
case res := <-ch:
return res.result, res.err
case <-ctx.Done():
return nil, ctx.Err()
case <-c.closed:
return nil, errors.New("acp: connection closed")
}
}
func (c *Conn) write(m outbound) error {
c.wmu.Lock()
defer c.wmu.Unlock()
return c.enc.Encode(m)
}
func (c *Conn) writeError(id json.RawMessage, code int, msg string) {
_ = c.write(outbound{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
}
// shutdown fails every in-flight outbound request so their goroutines unblock.
func (c *Conn) shutdown() {
c.closeOnce.Do(func() {
close(c.closed)
c.pmu.Lock()
for id, ch := range c.pending {
ch <- rpcResult{err: errors.New("acp: connection closed")}
delete(c.pending, id)
}
c.pmu.Unlock()
})
}