Compare commits
1 Commits
rpc-client
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5a5e5a67 |
@@ -90,9 +90,6 @@ type Options struct {
|
|||||||
lagMin int `json:"lag_min,omitempty"`
|
lagMin int `json:"lag_min,omitempty"`
|
||||||
lagMax int `json:"lag_max,omitempty"`
|
lagMax int `json:"lag_max,omitempty"`
|
||||||
lagWindowMin int `json:"lag_window_min,omitempty"`
|
lagWindowMin int `json:"lag_window_min,omitempty"`
|
||||||
|
|
||||||
rpcTimeout time.Duration `json:"rpc_timeout,omitempty"`
|
|
||||||
rpcMaxConcurrent int `json:"rpc_max_concurrent,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -111,8 +108,6 @@ func main() {
|
|||||||
flag.IntVar(&opts.lagMin, "lag-min", 1, "minimum confirmation lag in blocks (applied when the chain is stable)")
|
flag.IntVar(&opts.lagMin, "lag-min", 1, "minimum confirmation lag in blocks (applied when the chain is stable)")
|
||||||
flag.IntVar(&opts.lagMax, "lag-max", 12, "maximum confirmation lag in blocks (cap during heavy reorgs)")
|
flag.IntVar(&opts.lagMax, "lag-max", 12, "maximum confirmation lag in blocks (cap during heavy reorgs)")
|
||||||
flag.IntVar(&opts.lagWindowMin, "lag-window", 30, "minutes of recent reorg history used to size the adaptive lag")
|
flag.IntVar(&opts.lagWindowMin, "lag-window", 30, "minutes of recent reorg history used to size the adaptive lag")
|
||||||
flag.DurationVar(&opts.rpcTimeout, "rpc-timeout", frontend.DefaultRPCTimeout, "bound a single dragonxd JSON-RPC call; 0 disables. Without it one stuck call blocks every other caller")
|
|
||||||
flag.IntVar(&opts.rpcMaxConcurrent, "rpc-max-concurrent", frontend.DefaultRPCMaxConcurrent, "maximum dragonxd JSON-RPC calls in flight at once; matches the node's RPC worker threads")
|
|
||||||
|
|
||||||
// creating --version as a requirement of help2man
|
// creating --version as a requirement of help2man
|
||||||
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
|
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
|
||||||
@@ -124,13 +119,6 @@ func main() {
|
|||||||
// TODO support config from file and env vars
|
// TODO support config from file and env vars
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// A negative duration silently means "no timeout", the same as the
|
|
||||||
// documented 0, so reject it rather than quietly running unbounded.
|
|
||||||
if opts.rpcTimeout < 0 {
|
|
||||||
fmt.Fprintln(os.Stderr, "-rpc-timeout must not be negative; use 0 to disable the timeout")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if opts.confPath == "" {
|
if opts.confPath == "" {
|
||||||
flag.Usage()
|
flag.Usage()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -184,13 +172,13 @@ func main() {
|
|||||||
// sending transactions, but in the future it could back a different type
|
// sending transactions, but in the future it could back a different type
|
||||||
// of block streamer.
|
// of block streamer.
|
||||||
|
|
||||||
rpcClient, err := frontend.NewZRPCFromConf(opts.confPath, opts.rpcTimeout, opts.rpcMaxConcurrent)
|
rpcClient, err := frontend.NewZRPCFromConf(opts.confPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithFields(logrus.Fields{
|
log.WithFields(logrus.Fields{
|
||||||
"error": err,
|
"error": err,
|
||||||
}).Warn("DRAGONX.conf failed, will try empty credentials for rpc")
|
}).Warn("DRAGONX.conf failed, will try empty credentials for rpc")
|
||||||
|
|
||||||
rpcClient, err = frontend.NewZRPCFromCreds("127.0.0.1:21769", "", "", opts.rpcTimeout, opts.rpcMaxConcurrent)
|
rpcClient, err = frontend.NewZRPCFromCreds("127.0.0.1:21769", "", "")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithFields(logrus.Fields{
|
log.WithFields(logrus.Fields{
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
|
|
||||||
"git.hush.is/hush/lightwalletd/parser"
|
"git.hush.is/hush/lightwalletd/parser"
|
||||||
"git.hush.is/hush/lightwalletd/walletrpc"
|
"git.hush.is/hush/lightwalletd/walletrpc"
|
||||||
"git.hush.is/hush/lightwalletd/zrpc"
|
"github.com/btcsuite/btcd/rpcclient"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetSaplingInfo(rpcClient *zrpc.Client) (int, int, string, string, int, int, int, error) {
|
func GetSaplingInfo(rpcClient *rpcclient.Client) (int, int, string, string, int, int, int, error) {
|
||||||
result, rpcErr := rpcClient.RawRequest("getblockchaininfo", make([]json.RawMessage, 0))
|
result, rpcErr := rpcClient.RawRequest("getblockchaininfo", make([]json.RawMessage, 0))
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -79,7 +79,7 @@ func GetSaplingInfo(rpcClient *zrpc.Client) (int, int, string, string, int, int,
|
|||||||
return int(saplingHeight), int(blockHeight), chainName, branchID, int(difficulty), int(longestchain), int(notarized), nil
|
return int(saplingHeight), int(blockHeight), chainName, branchID, int(difficulty), int(longestchain), int(notarized), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCoinsupply(rpcClient *zrpc.Client) (string, string, int, int, int, int, error) {
|
func GetCoinsupply(rpcClient *rpcclient.Client) (string, string, int, int, int, int, error) {
|
||||||
result1, rpcErr := rpcClient.RawRequest("coinsupply", make([]json.RawMessage, 0))
|
result1, rpcErr := rpcClient.RawRequest("coinsupply", make([]json.RawMessage, 0))
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -144,7 +144,7 @@ func GetCoinsupply(rpcClient *zrpc.Client) (string, string, int, int, int, int,
|
|||||||
return result, coin, height, supply, zfunds, total, nil
|
return result, coin, height, supply, zfunds, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBlockFromRPC(rpcClient *zrpc.Client, height int) (*walletrpc.CompactBlock, error) {
|
func getBlockFromRPC(rpcClient *rpcclient.Client, height int) (*walletrpc.CompactBlock, error) {
|
||||||
params := make([]json.RawMessage, 2)
|
params := make([]json.RawMessage, 2)
|
||||||
params[0] = json.RawMessage("\"" + strconv.Itoa(height) + "\"")
|
params[0] = json.RawMessage("\"" + strconv.Itoa(height) + "\"")
|
||||||
params[1] = json.RawMessage("0")
|
params[1] = json.RawMessage("0")
|
||||||
@@ -187,7 +187,7 @@ func getBlockFromRPC(rpcClient *zrpc.Client, height int) (*walletrpc.CompactBloc
|
|||||||
return block.ToCompact(), nil
|
return block.ToCompact(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BlockIngestor(rpcClient *zrpc.Client, cache *BlockCache, log *logrus.Entry,
|
func BlockIngestor(rpcClient *rpcclient.Client, cache *BlockCache, log *logrus.Entry,
|
||||||
stopChan chan bool, startHeight int) {
|
stopChan chan bool, startHeight int) {
|
||||||
reorgCount := 0
|
reorgCount := 0
|
||||||
height := startHeight
|
height := startHeight
|
||||||
@@ -263,7 +263,7 @@ func BlockIngestor(rpcClient *zrpc.Client, cache *BlockCache, log *logrus.Entry,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBlock(rpcClient *zrpc.Client, cache *BlockCache, height int) (*walletrpc.CompactBlock, error) {
|
func GetBlock(rpcClient *rpcclient.Client, cache *BlockCache, height int) (*walletrpc.CompactBlock, error) {
|
||||||
// Don't serve blocks above the advertised (lag-adjusted) tip, so wallets can
|
// Don't serve blocks above the advertised (lag-adjusted) tip, so wallets can
|
||||||
// neither sync nor anchor shielded spends into the unstable reorg zone.
|
// neither sync nor anchor shielded spends into the unstable reorg zone.
|
||||||
if !cache.HeightAllowed(height) {
|
if !cache.HeightAllowed(height) {
|
||||||
@@ -293,7 +293,7 @@ func GetBlock(rpcClient *zrpc.Client, cache *BlockCache, height int) (*walletrpc
|
|||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBlockRange(rpcClient *zrpc.Client, cache *BlockCache,
|
func GetBlockRange(rpcClient *rpcclient.Client, cache *BlockCache,
|
||||||
blockOut chan<- walletrpc.CompactBlock, errOut chan<- error, start, end int) {
|
blockOut chan<- walletrpc.CompactBlock, errOut chan<- error, start, end int) {
|
||||||
|
|
||||||
// Go over [start, end] inclusive
|
// Go over [start, end] inclusive
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ package common
|
|||||||
// it is walletrpc/service.proto:48, so every client and every operator probe
|
// it is walletrpc/service.proto:48, so every client and every operator probe
|
||||||
// reads it, and it is the only way to tell from off-box which build a node is
|
// reads it, and it is the only way to tell from off-box which build a node is
|
||||||
// running.
|
// running.
|
||||||
const Version = "0.1.3"
|
const Version = "0.1.2"
|
||||||
|
|
||||||
// VersionString is what GetLightdInfo advertises to clients.
|
// VersionString is what GetLightdInfo advertises to clients.
|
||||||
const VersionString = Version + "-dragonxlightd"
|
const VersionString = Version + "-dragonxlightd"
|
||||||
|
|||||||
40
deploy/README.md
Normal file
40
deploy/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# deploy/
|
||||||
|
|
||||||
|
Operational scripts, versioned here but **executed from outside this repository**.
|
||||||
|
|
||||||
|
A node's runtime directory (`/home/dev/lightwalletd`) holds only the binary and
|
||||||
|
its logs. The supervisor scripts live in `/home/dev/`:
|
||||||
|
|
||||||
|
| repo (source of truth) | deployed to | invoked by |
|
||||||
|
|------------------------|--------------------|---------------------------|
|
||||||
|
| `deploy/monitor_lwd.sh`| `/home/dev/monitor_lwd.sh` | `/home/dev/lwd_watchdog.sh` (cron, primary) |
|
||||||
|
|
||||||
|
## Why they are not run from here
|
||||||
|
|
||||||
|
This repository's working tree used to *be* the runtime directory, and
|
||||||
|
`monitor_lwd.sh` — the primary's live supervisor — was a tracked file inside it.
|
||||||
|
On 2026-08-26 a routine `git reset --hard` during an unrelated cherry-pick
|
||||||
|
reverted it to an older committed revision that (a) dropped `-cache-size 5000`,
|
||||||
|
making every relaunch warm the block cache from tip-400000, and (b) reintroduced
|
||||||
|
`wait "$LWD_PID" || true; EXIT_CODE=$?`, which reads the exit status of `|| true`
|
||||||
|
and is therefore always 0, so the monitor logged "exited cleanly. Not restarting."
|
||||||
|
and broke its loop on every exit including crashes — the bug behind an 11h48m
|
||||||
|
outage on 2026-08-21.
|
||||||
|
|
||||||
|
Nothing noticed at the time because the running monitor was executing an
|
||||||
|
already-deleted inode: the working copy was broken while the live process was
|
||||||
|
fine. It was recovered from `/proc/<pid>/fd/255`.
|
||||||
|
|
||||||
|
Deploying these from outside the working tree means no checkout, reset, rebase or
|
||||||
|
branch switch can reach a running supervisor.
|
||||||
|
|
||||||
|
## Changing one
|
||||||
|
|
||||||
|
Edit it here, commit, then copy to the node and let the next relaunch pick it up:
|
||||||
|
|
||||||
|
cp deploy/monitor_lwd.sh /home/dev/monitor_lwd.sh.stage
|
||||||
|
chmod 755 /home/dev/monitor_lwd.sh.stage
|
||||||
|
mv -f /home/dev/monitor_lwd.sh.stage /home/dev/monitor_lwd.sh
|
||||||
|
|
||||||
|
`mv`, not `cp`: a rename cannot disturb a running process, and the currently
|
||||||
|
running monitor keeps its own inode until it next restarts.
|
||||||
@@ -8,10 +8,17 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
# RUNTIME DIR IS EXPLICIT, not derived from this script's own location.
|
||||||
LWD_BIN="$SCRIPT_DIR/lightwalletd"
|
# This script used to live inside /home/dev/lightwalletd, which is a git
|
||||||
|
# working tree as well as the runtime directory -- so a routine `git reset
|
||||||
|
# --hard` or branch checkout there silently reverted the live supervisor to an
|
||||||
|
# older committed version. That happened on 2026-08-26; the running monitor
|
||||||
|
# survived only because it was executing an already-deleted inode. The script
|
||||||
|
# now lives outside the repo and names the runtime dir directly.
|
||||||
|
LWD_DIR="${LWD_DIR:-/home/dev/lightwalletd}"
|
||||||
|
LWD_BIN="$LWD_DIR/lightwalletd"
|
||||||
LWD_ARGS="-bind-addr lite.dragonx.is:9069 -conf-file $HOME/.hush/DRAGONX/DRAGONX.conf -no-tls -lag-min 4 -lag-max 12 -lag-window 30 -cache-size 5000"
|
LWD_ARGS="-bind-addr lite.dragonx.is:9069 -conf-file $HOME/.hush/DRAGONX/DRAGONX.conf -no-tls -lag-min 4 -lag-max 12 -lag-window 30 -cache-size 5000"
|
||||||
LOGFILE="$SCRIPT_DIR/lwd-monitor.log"
|
LOGFILE="$LWD_DIR/lwd-monitor.log"
|
||||||
PIDFILE="/tmp/lwd-monitor.pid"
|
PIDFILE="/tmp/lwd-monitor.pid"
|
||||||
STOPPING=0
|
STOPPING=0
|
||||||
RESTART_DELAY=5 # seconds to wait before restarting after a crash
|
RESTART_DELAY=5 # seconds to wait before restarting after a crash
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.hush.is/hush/lightwalletd/zrpc"
|
"github.com/btcsuite/btcd/rpcclient"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"git.hush.is/hush/lightwalletd/common"
|
"git.hush.is/hush/lightwalletd/common"
|
||||||
@@ -37,7 +37,7 @@ var (
|
|||||||
|
|
||||||
// getMempoolMonitor returns the process-wide mempool monitor, starting its poller on first use.
|
// getMempoolMonitor returns the process-wide mempool monitor, starting its poller on first use.
|
||||||
// The client/cache/log are bound once (they are process singletons on the SqlStreamer).
|
// The client/cache/log are bound once (they are process singletons on the SqlStreamer).
|
||||||
func getMempoolMonitor(client *zrpc.Client, cache *common.BlockCache, log *logrus.Entry) *mempoolMonitor {
|
func getMempoolMonitor(client *rpcclient.Client, cache *common.BlockCache, log *logrus.Entry) *mempoolMonitor {
|
||||||
mempoolMonitorOnce.Do(func() {
|
mempoolMonitorOnce.Do(func() {
|
||||||
sharedMempoolMonitor = &mempoolMonitor{
|
sharedMempoolMonitor = &mempoolMonitor{
|
||||||
subs: make(map[int]chan *walletrpc.RawTransaction),
|
subs: make(map[int]chan *walletrpc.RawTransaction),
|
||||||
@@ -87,7 +87,7 @@ func (m *mempoolMonitor) reset() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mempoolMonitor) run(client *zrpc.Client, cache *common.BlockCache, log *logrus.Entry) {
|
func (m *mempoolMonitor) run(client *rpcclient.Client, cache *common.BlockCache, log *logrus.Entry) {
|
||||||
lastHeight := cache.GetLatestBlock()
|
lastHeight := cache.GetLatestBlock()
|
||||||
for {
|
for {
|
||||||
// Track block height even while idle, so a new subscriber isn't immediately closed by a
|
// Track block height even while idle, so a new subscriber isn't immediately closed by a
|
||||||
|
|||||||
@@ -2,30 +2,13 @@ package frontend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.hush.is/hush/lightwalletd/zrpc"
|
"github.com/btcsuite/btcd/rpcclient"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
ini "gopkg.in/ini.v1"
|
ini "gopkg.in/ini.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRPCTimeout bounds a single JSON-RPC round trip to dragonxd.
|
func NewZRPCFromConf(confPath string) (*rpcclient.Client, error) {
|
||||||
//
|
|
||||||
// Why 120s and not something tighter: the slowest legitimate call this daemon
|
|
||||||
// makes is `coinsupply`, measured at 48s on first call and 3s afterwards.
|
|
||||||
// hush_coinsupply walks the block index back to genesis loading each block from
|
|
||||||
// disk, memoising newcoins/zfunds into the CBlockIndex as it goes, so the first
|
|
||||||
// call pays for the whole chain and later ones are nearly free. A timeout below
|
|
||||||
// that first-call cost would turn a slow-but-working call into a hard failure.
|
|
||||||
// 120s leaves ~2.5x headroom while still bounding a hang that is otherwise
|
|
||||||
// unbounded -- calls were seen running past five minutes.
|
|
||||||
const DefaultRPCTimeout = 120 * time.Second
|
|
||||||
|
|
||||||
// DefaultRPCMaxConcurrent matches dragonxd's DEFAULT_HTTP_THREADS. Asking for
|
|
||||||
// more in-flight calls than the node has worker threads only adds queueing.
|
|
||||||
const DefaultRPCMaxConcurrent = 8
|
|
||||||
|
|
||||||
func NewZRPCFromConf(confPath string, timeout time.Duration, maxConcurrent int) (*zrpc.Client, error) {
|
|
||||||
cfg, err := ini.Load(confPath)
|
cfg, err := ini.Load(confPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to read config file")
|
return nil, errors.Wrap(err, "failed to read config file")
|
||||||
@@ -36,10 +19,19 @@ func NewZRPCFromConf(confPath string, timeout time.Duration, maxConcurrent int)
|
|||||||
username := cfg.Section("").Key("rpcuser").String()
|
username := cfg.Section("").Key("rpcuser").String()
|
||||||
password := cfg.Section("").Key("rpcpassword").String()
|
password := cfg.Section("").Key("rpcpassword").String()
|
||||||
|
|
||||||
return NewZRPCFromCreds(net.JoinHostPort(rpcaddr, rpcport), username, password, timeout, maxConcurrent)
|
return NewZRPCFromCreds(net.JoinHostPort(rpcaddr, rpcport), username, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewZRPCFromCreds(addr, username, password string, timeout time.Duration, maxConcurrent int) (*zrpc.Client, error) {
|
func NewZRPCFromCreds(addr, username, password string) (*rpcclient.Client, error) {
|
||||||
// DragonX only supports HTTP POST mode and does not provide TLS by default.
|
// Connect to local DragonX RPC server using HTTP POST mode.
|
||||||
return zrpc.New(addr, username, password, timeout, maxConcurrent), nil
|
connCfg := &rpcclient.ConnConfig{
|
||||||
|
Host: addr,
|
||||||
|
User: username,
|
||||||
|
Pass: password,
|
||||||
|
HTTPPostMode: true, // DragonX only supports HTTP POST mode
|
||||||
|
DisableTLS: true, // DragonX does not provide TLS by default
|
||||||
|
}
|
||||||
|
// Notice the notification parameter is nil since notifications are
|
||||||
|
// not supported in HTTP POST mode.
|
||||||
|
return rpcclient.New(connCfg, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.hush.is/hush/lightwalletd/zrpc"
|
"github.com/btcsuite/btcd/rpcclient"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"git.hush.is/hush/lightwalletd/common"
|
"git.hush.is/hush/lightwalletd/common"
|
||||||
@@ -24,11 +24,11 @@ var (
|
|||||||
// the service type
|
// the service type
|
||||||
type SqlStreamer struct {
|
type SqlStreamer struct {
|
||||||
cache *common.BlockCache
|
cache *common.BlockCache
|
||||||
client *zrpc.Client
|
client *rpcclient.Client
|
||||||
log *logrus.Entry
|
log *logrus.Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSQLiteStreamer(client *zrpc.Client, cache *common.BlockCache, log *logrus.Entry) (walletrpc.CompactTxStreamerServer, error) {
|
func NewSQLiteStreamer(client *rpcclient.Client, cache *common.BlockCache, log *logrus.Entry) (walletrpc.CompactTxStreamerServer, error) {
|
||||||
return &SqlStreamer{cache, client, log}, nil
|
return &SqlStreamer{cache, client, log}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
177
zrpc/client.go
177
zrpc/client.go
@@ -1,177 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package zrpc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
ini "gopkg.in/ini.v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Live tests against a local dragonxd. Skipped unless ZRPC_CONF points at a
|
|
||||||
// DRAGONX.conf, so `go test ./...` stays hermetic.
|
|
||||||
func liveClient(t *testing.T, timeout time.Duration) *Client {
|
|
||||||
t.Helper()
|
|
||||||
conf := os.Getenv("ZRPC_CONF")
|
|
||||||
if conf == "" {
|
|
||||||
t.Skip("ZRPC_CONF not set; skipping live RPC test")
|
|
||||||
}
|
|
||||||
cfg, err := ini.Load(conf)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load conf: %v", err)
|
|
||||||
}
|
|
||||||
k := func(n string) string { return cfg.Section("").Key(n).String() }
|
|
||||||
return New(k("rpcbind")+":"+k("rpcport"), k("rpcuser"), k("rpcpassword"), timeout, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLiveSuccess(t *testing.T) {
|
|
||||||
c := liveClient(t, 30*time.Second)
|
|
||||||
res, err := c.RawRequest("getblockchaininfo", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getblockchaininfo: %v", err)
|
|
||||||
}
|
|
||||||
var f map[string]interface{}
|
|
||||||
if err := json.Unmarshal(res, &f); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
if f["chain"] != "main" {
|
|
||||||
t.Fatalf("chain = %v, want main", f["chain"])
|
|
||||||
}
|
|
||||||
t.Logf("ok: chain=%v blocks=%v", f["chain"], f["blocks"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// The error string must stay "<code>: <message>" -- common.GetSaplingInfo
|
|
||||||
// recovers the numeric code with strings.SplitN(err.Error(), ":", 2).
|
|
||||||
func TestLiveErrorStringShape(t *testing.T) {
|
|
||||||
c := liveClient(t, 30*time.Second)
|
|
||||||
p := []json.RawMessage{json.RawMessage(`"99999999"`)}
|
|
||||||
_, err := c.RawRequest("getblock", p)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected an error for an out-of-range height")
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(err.Error(), ":", 2)
|
|
||||||
code, perr := strconv.ParseInt(parts[0], 10, 32)
|
|
||||||
if perr != nil {
|
|
||||||
t.Fatalf("error string %q does not start with a numeric code", err.Error())
|
|
||||||
}
|
|
||||||
if code != -8 {
|
|
||||||
t.Logf("note: code %d (expected -8 for a bad height, but any numeric code proves the shape)", code)
|
|
||||||
}
|
|
||||||
t.Logf("ok: %q -> code %d", err.Error(), code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A timeout must actually abort the call rather than hanging.
|
|
||||||
func TestLiveTimeoutFires(t *testing.T) {
|
|
||||||
c := liveClient(t, 1*time.Nanosecond)
|
|
||||||
start := time.Now()
|
|
||||||
_, err := c.RawRequest("getblockchaininfo", nil)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected a timeout error")
|
|
||||||
}
|
|
||||||
if elapsed > 5*time.Second {
|
|
||||||
t.Fatalf("timeout did not fire promptly: %v", elapsed)
|
|
||||||
}
|
|
||||||
t.Logf("ok: timed out in %v with %v", elapsed, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNoMethod(t *testing.T) {
|
|
||||||
c := New("127.0.0.1:1", "u", "p", time.Second, 8)
|
|
||||||
if _, err := c.RawRequest("", nil); err == nil || err.Error() != "no method" {
|
|
||||||
t.Fatalf(`RawRequest("") = %v, want "no method"`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user