1 Commits

Author SHA1 Message Date
DragonX Developers
4b5a5e5a67 deploy: run the supervisor from outside the working tree
/home/dev/lightwalletd is simultaneously a git checkout and the runtime
directory, and monitor_lwd.sh -- the primary's live supervisor -- was a
tracked file inside it. So a routine `git reset --hard` during an
unrelated cherry-pick reverted the running supervisor to an older
committed revision. That happened on 2026-08-26. Nothing noticed,
because the running monitor was executing an already-deleted inode: the
file on disk was broken while the live process was fine. It was
recovered from /proc/<pid>/fd/255.

What the reverted copy would have reintroduced, had it ever restarted:
the loss of `-cache-size 5000`, so every relaunch warms the block cache
from tip-400000 instead of tip-5000; and `wait "$LWD_PID" || true;
EXIT_CODE=$?`, which reads the exit status of `|| true` and is therefore
always 0, so the monitor logs "exited cleanly. Not restarting." and
breaks its loop on every exit including crashes -- the bug behind an
11h48m outage on 2026-08-21.

Move it to deploy/, from where it is copied to /home/dev/ and run. The
runtime directory now holds only the binary and its logs, so no
checkout, reset, rebase or branch switch can reach a running supervisor.

The script no longer derives its paths from its own location: SCRIPT_DIR
became an explicit LWD_DIR, because the script and the runtime directory
are deliberately no longer the same place. lwd_watchdog.sh launches
"$MONITOR" by absolute path and refuses to run if it is missing.

Verified: run from /tmp, the relocated monitor resolves its binary and
log through LWD_DIR rather than its own directory, and launches with
-cache-size 5000 intact. The running monitor was not restarted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo
2026-08-26 12:00:45 -05:00
10 changed files with 81 additions and 317 deletions

View File

@@ -90,9 +90,6 @@ type Options struct {
lagMin int `json:"lag_min,omitempty"`
lagMax int `json:"lag_max,omitempty"`
lagWindowMin int `json:"lag_window_min,omitempty"`
rpcTimeout time.Duration `json:"rpc_timeout,omitempty"`
rpcMaxConcurrent int `json:"rpc_max_concurrent,omitempty"`
}
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.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.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
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
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 == "" {
flag.Usage()
os.Exit(1)
@@ -184,13 +172,13 @@ func main() {
// sending transactions, but in the future it could back a different type
// of block streamer.
rpcClient, err := frontend.NewZRPCFromConf(opts.confPath, opts.rpcTimeout, opts.rpcMaxConcurrent)
rpcClient, err := frontend.NewZRPCFromConf(opts.confPath)
if err != nil {
log.WithFields(logrus.Fields{
"error": err,
}).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 {
log.WithFields(logrus.Fields{

View File

@@ -10,12 +10,12 @@ import (
"git.hush.is/hush/lightwalletd/parser"
"git.hush.is/hush/lightwalletd/walletrpc"
"git.hush.is/hush/lightwalletd/zrpc"
"github.com/btcsuite/btcd/rpcclient"
"github.com/pkg/errors"
"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))
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
}
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))
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
}
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[0] = json.RawMessage("\"" + strconv.Itoa(height) + "\"")
params[1] = json.RawMessage("0")
@@ -187,7 +187,7 @@ func getBlockFromRPC(rpcClient *zrpc.Client, height int) (*walletrpc.CompactBloc
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) {
reorgCount := 0
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
// neither sync nor anchor shielded spends into the unstable reorg zone.
if !cache.HeightAllowed(height) {
@@ -293,7 +293,7 @@ func GetBlock(rpcClient *zrpc.Client, cache *BlockCache, height int) (*walletrpc
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) {
// Go over [start, end] inclusive

View File

@@ -8,7 +8,7 @@ package common
// 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
// running.
const Version = "0.1.3"
const Version = "0.1.2"
// VersionString is what GetLightdInfo advertises to clients.
const VersionString = Version + "-dragonxlightd"

40
deploy/README.md Normal file
View 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.

View File

@@ -8,10 +8,17 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LWD_BIN="$SCRIPT_DIR/lightwalletd"
# RUNTIME DIR IS EXPLICIT, not derived from this script's own location.
# 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"
LOGFILE="$SCRIPT_DIR/lwd-monitor.log"
LOGFILE="$LWD_DIR/lwd-monitor.log"
PIDFILE="/tmp/lwd-monitor.pid"
STOPPING=0
RESTART_DELAY=5 # seconds to wait before restarting after a crash

View File

@@ -6,7 +6,7 @@ import (
"sync"
"time"
"git.hush.is/hush/lightwalletd/zrpc"
"github.com/btcsuite/btcd/rpcclient"
"github.com/sirupsen/logrus"
"git.hush.is/hush/lightwalletd/common"
@@ -37,7 +37,7 @@ var (
// 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).
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() {
sharedMempoolMonitor = &mempoolMonitor{
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()
for {
// Track block height even while idle, so a new subscriber isn't immediately closed by a

View File

@@ -2,30 +2,13 @@ package frontend
import (
"net"
"time"
"git.hush.is/hush/lightwalletd/zrpc"
"github.com/btcsuite/btcd/rpcclient"
"github.com/pkg/errors"
ini "gopkg.in/ini.v1"
)
// DefaultRPCTimeout bounds a single JSON-RPC round trip to dragonxd.
//
// 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) {
func NewZRPCFromConf(confPath string) (*rpcclient.Client, error) {
cfg, err := ini.Load(confPath)
if err != nil {
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()
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) {
// DragonX only supports HTTP POST mode and does not provide TLS by default.
return zrpc.New(addr, username, password, timeout, maxConcurrent), nil
func NewZRPCFromCreds(addr, username, password string) (*rpcclient.Client, error) {
// Connect to local DragonX RPC server using HTTP POST mode.
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)
}

View File

@@ -10,7 +10,7 @@ import (
"strings"
"time"
"git.hush.is/hush/lightwalletd/zrpc"
"github.com/btcsuite/btcd/rpcclient"
"github.com/sirupsen/logrus"
"git.hush.is/hush/lightwalletd/common"
@@ -24,11 +24,11 @@ var (
// the service type
type SqlStreamer struct {
cache *common.BlockCache
client *zrpc.Client
client *rpcclient.Client
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
}

View File

@@ -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
}

View File

@@ -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)
}
}