332 lines
9.6 KiB
Go
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()
|
|
})
|
|
}
|