Review follow-ups to 73a93f1. Bound in-flight calls. rpcclient's single sendPostHandler goroutine imposed an accidental ceiling of one concurrent RPC; removing it without putting anything in its place left no bound at all. grpc-go supplies none either -- this server sets no MaxConcurrentStreams, so the default is math.MaxUint32 -- and dragonxd answers RPC with 8 worker threads behind a 4096-deep queue, shared on the pool node with getblocktemplate. Overload would therefore surface as mining latency rather than as an error we could back off on. MaxConnsPerHost blocks the caller at the limit instead of dialling more, which is the backpressure wanted; MaxIdleConnsPerHost alone would only cap reuse and let us exceed the limit while churning connections. Default 8, matching the node's DEFAULT_HTTP_THREADS, tunable with -rpc-max-concurrent. Even 8 removes all of the head-of-line blocking this work set out to fix. IdleConnTimeout 90s -> 20s. dragonxd closes idle connections at 30s (DEFAULT_HTTP_SERVER_TIMEOUT, applied via evhttp_set_timeout and not overridden in DRAGONX.conf). At 90s we were always the second to close, so a request could be written into a connection the server had already sent a FIN for, and Go will not retry a POST once bytes are on the wire. Closing first removes the race. Reject a negative -rpc-timeout, which silently meant "unbounded", the same as the documented 0. The check has to run after flag.Parse(); it was initially placed before it and never fired. Also correct the coinsupply note: hush_coinsupply walks the block index back to genesis, loading each block from disk and memoising newcoins and zfunds into the CBlockIndex, so the first call pays for the whole chain and later ones are nearly free. It is not a UTXO-set scan, as the earlier comment claimed. The measured 48s/3s figures are unchanged. Verified: five concurrent GetLightdInfo calls all return grpc-status 0 with no errors, 46 blocks ingested, and the daemon holds 2 sockets to the node rather than one per request; a negative timeout exits 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo
178 lines
6.4 KiB
Go
178 lines
6.4 KiB
Go
// 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 "<code>: <message>", 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 "<code>: <message>". 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
|
|
}
|