// Package zrpc is a minimal JSON-RPC client for talking to dragonxd. // // It replaces github.com/btcsuite/btcd/rpcclient, of which lightwalletd used // exactly one method: RawRequest. That package is unusable here for two // reasons, both of which this package exists to fix: // // 1. NO TIMEOUT, AND NO WAY TO SET ONE. rpcclient builds its http.Client in an // unexported newHTTPClient() and its ConnConfig exposes no Timeout field, so // a request can hang forever. Calls were observed hanging for over five // minutes against a healthy node that answered the same query from the CLI // in 2ms. // // 2. EVERY CALL IN THE PROCESS IS SERIALISED. In HTTP POST mode rpcclient runs // a single sendPostHandler goroutine which invokes handleSendPostMessage // SYNCHRONOUSLY, so at most one RPC is ever in flight. Combined with (1), // one stuck call blocks the block ingestor, the mempool monitor and every // user-facing gRPC handler indefinitely. A timeout alone would not fix this: // bounding the caller's wait still leaves the shared goroutine stuck on // http.Client.Do, so everything queued behind it stays blocked. Only a // timeout on the HTTP client itself aborts the in-flight request, and only // dropping the shared goroutine lets independent callers proceed. // // The wire format, request envelope, ID sequence and error semantics are // deliberately byte-identical to rpcclient's RawRequest. In particular // RPCError.Error() must render as ": ", because callers parse // the code back out of the string (see common.GetSaplingInfo, which checks for // code -8 via strings.SplitN(err.Error(), ":", 2)). package zrpc import ( "bytes" "encoding/json" "errors" "fmt" "io/ioutil" "net/http" "sync/atomic" "time" ) // RPCError is a JSON-RPC error object returned by dragonxd. type RPCError struct { Code int64 `json:"code,omitempty"` Message string `json:"message,omitempty"` } // Error renders as ": ". Callers depend on this exact shape to // recover the numeric code; do not change it. func (e *RPCError) Error() string { return fmt.Sprintf("%d: %s", e.Code, e.Message) } type request struct { Jsonrpc string `json:"jsonrpc"` Method string `json:"method"` Params []json.RawMessage `json:"params"` ID int64 `json:"id"` } type rawResponse struct { Result json.RawMessage `json:"result"` Error *RPCError `json:"error"` } // Client is safe for concurrent use by multiple goroutines. type Client struct { url string user string pass string http *http.Client nextID int64 } // New returns a client for a dragonxd JSON-RPC endpoint. // // timeout of 0 means no timeout, which reproduces the old unbounded behaviour // and should not be used in production. // // maxConcurrent bounds how many requests may be in flight at once. This is not // optional book-keeping: rpcclient's single goroutine imposed an accidental // ceiling of ONE, and removing it without putting anything in its place would // let a burst of gRPC handlers fan out arbitrarily wide. grpc-go places no // limit of its own here -- this server sets no MaxConcurrentStreams, so the // default is math.MaxUint32. dragonxd serves RPC with 8 worker threads // (DEFAULT_HTTP_THREADS) behind a 4096-deep work queue, and on the pool node // those threads are shared with getblocktemplate, so overload shows up as // queueing latency for mining rather than as an error we could back off on. // A small number still removes all of the head-of-line blocking. func New(addr, user, pass string, timeout time.Duration, maxConcurrent int) *Client { if maxConcurrent < 1 { maxConcurrent = 1 } return &Client{ url: "http://" + addr, user: user, pass: pass, http: &http.Client{ // Covers the whole exchange: connect, write, response headers and // body read. This is the bound that was missing. Timeout: timeout, Transport: &http.Transport{ // dragonxd's HTTP server supports keep-alive. rpcclient set // Close=true and opened a fresh TCP connection per request, // which left hundreds of sockets in TIME_WAIT on a busy node. // // MaxConnsPerHost is the real concurrency bound: it BLOCKS a // caller once the limit is reached rather than dialling more, // which is the backpressure we want. MaxIdleConnsPerHost only // caps reuse, so on its own it would let us exceed the limit // and go back to churning connections. MaxConnsPerHost: maxConcurrent, MaxIdleConns: maxConcurrent, MaxIdleConnsPerHost: maxConcurrent, // Must stay BELOW dragonxd's own idle timeout, which is 30s // (DEFAULT_HTTP_SERVER_TIMEOUT in httpserver.h, applied via // evhttp_set_timeout and not overridden in DRAGONX.conf). // Whoever closes second loses a race against a FIN already in // flight, and Go will not retry a POST once bytes are on the // wire -- so we close first. IdleConnTimeout: 20 * time.Second, }, }, } } // RawRequest sends a JSON-RPC request and returns the raw result. A JSON-RPC // error from the server is returned as *RPCError. func (c *Client) RawRequest(method string, params []json.RawMessage) (json.RawMessage, error) { if method == "" { return nil, errors.New("no method") } // Marshal parameters as "[]" instead of "null" when none are passed. if params == nil { params = []json.RawMessage{} } body, err := json.Marshal(&request{ Jsonrpc: "1.0", Method: method, Params: params, ID: atomic.AddInt64(&c.nextID, 1), }) if err != nil { return nil, err } httpReq, err := http.NewRequest("POST", c.url, bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.SetBasicAuth(c.user, c.pass) httpResp, err := c.http.Do(httpReq) if err != nil { return nil, err } defer httpResp.Body.Close() respBytes, err := ioutil.ReadAll(httpResp.Body) if err != nil { return nil, fmt.Errorf("error reading json reply: %v", err) } var resp rawResponse if err := json.Unmarshal(respBytes, &resp); err != nil { // Not a valid JSON-RPC response: surface the status and raw body, the // same way rpcclient did. dragonxd returns non-JSON bodies for some // auth and workqueue failures, and callers log this verbatim. return nil, fmt.Errorf("status code: %d, response: %q", httpResp.StatusCode, string(respBytes)) } if resp.Error != nil { return nil, resp.Error } return resp.Result, nil }