lightwalletd used github.com/btcsuite/btcd/rpcclient for exactly one
method, RawRequest, and that package has two defects that together
produce the failure seen on the primary node:
1. It has no timeout and no way to set one. The http.Client is built
in an unexported newHTTPClient() and ConnConfig exposes no Timeout
field, so a call can hang forever. Calls were observed running past
five minutes against a healthy node that answered the same query
from dragonx-cli in 2ms.
2. In HTTP POST mode it runs ONE sendPostHandler goroutine which calls
handleSendPostMessage synchronously, so the whole process has at
most one RPC in flight. One stuck call therefore blocks the block
ingestor, the mempool monitor and every user-facing gRPC handler at
once.
These compound: a timeout alone would not have been enough, because
bounding the caller's wait still leaves the shared goroutine stuck
inside http.Client.Do with everything queued behind it. Only a timeout
on the HTTP client aborts the in-flight request, and only dropping the
shared goroutine lets independent callers proceed.
Patching the vendored copy is not an option here: go.mod declares
go 1.12, so automatic vendor mode (go >= 1.14) is off and the committed
vendor/ tree is silently ignored in favour of the module cache. It is
also stale -- vendor/modules.txt disagrees with go.mod on btcd,
protobuf, logrus, sqlite3 and six other modules, so -mod=vendor cannot
build at all, and `go mod vendor` would discard any patch.
Replace it with package zrpc: a ~150-line JSON-RPC client that is safe
for concurrent use and takes a timeout. The request envelope, ID
sequence, basic auth and error semantics are deliberately identical.
RPCError.Error() still renders as "<code>: <message>" because callers
recover the numeric code from the string -- common.GetSaplingInfo
checks for -8 via strings.SplitN(err.Error(), ":", 2) -- and the
non-JSON body path still reports `status code: %d, response: %q`.
Default timeout 120s, tunable with -rpc-timeout (0 disables). The bound
is set by the slowest legitimate call: coinsupply measured 48s against
a cold UTXO set, 3s once cached, so a tighter timeout would turn a
slow-but-working call into a hard failure.
Also drops rpcclient's Close=true, which opened a fresh TCP connection
per request and left hundreds of sockets in TIME_WAIT on a busy node;
idle connections are now reused and capped.
Verified against a live dragonxd: getblockchaininfo returns chain=main;
a bad height yields exactly "-8: Block height out of range" and parses
back to -8; a 1ns timeout aborts in 54us instead of hanging; and the
built daemon serves GetLightdInfo at the current height with no errors.
The pre-existing parser TestCompactBlocks failure is unrelated and
reproduces on the base commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo
174 lines
5.6 KiB
Go
174 lines
5.6 KiB
Go
package frontend
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.hush.is/hush/lightwalletd/zrpc"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"git.hush.is/hush/lightwalletd/common"
|
|
"git.hush.is/hush/lightwalletd/walletrpc"
|
|
)
|
|
|
|
// mempoolMonitor polls the node's mempool ONCE (regardless of how many wallets are streaming) and
|
|
// fans each new transaction out to every subscribed GetMempoolStream handler. This replaces
|
|
// per-connection polling: N connected wallets no longer each hit getrawmempool/getrawtransaction.
|
|
//
|
|
// Lifecycle matches the semantics the client's monitor loop expects: while a block is current the
|
|
// monitor emits each mempool tx to subscribers exactly once; when a new block is mined it resets
|
|
// and closes all subscriber channels, so each handler returns (closing its stream) and the client
|
|
// reconnects after re-syncing the block. The poller starts lazily on the first subscription and,
|
|
// when no clients are connected, idles without touching the node.
|
|
type mempoolMonitor struct {
|
|
mu sync.Mutex
|
|
subs map[int]chan *walletrpc.RawTransaction
|
|
nextID int
|
|
seen map[string]bool // txids already emitted for the current block
|
|
ordered []*walletrpc.RawTransaction // emitted txs in arrival order, replayed to late subscribers
|
|
}
|
|
|
|
var (
|
|
sharedMempoolMonitor *mempoolMonitor
|
|
mempoolMonitorOnce sync.Once
|
|
)
|
|
|
|
// 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 {
|
|
mempoolMonitorOnce.Do(func() {
|
|
sharedMempoolMonitor = &mempoolMonitor{
|
|
subs: make(map[int]chan *walletrpc.RawTransaction),
|
|
seen: make(map[string]bool),
|
|
}
|
|
go sharedMempoolMonitor.run(client, cache, log)
|
|
})
|
|
return sharedMempoolMonitor
|
|
}
|
|
|
|
// subscribe registers a stream. It returns the subscriber id, a channel of subsequent mempool txs,
|
|
// and a snapshot of txs already emitted this block (to be sent first). Registration and snapshot
|
|
// are taken atomically, so every tx reaches a subscriber exactly once (snapshot xor channel).
|
|
func (m *mempoolMonitor) subscribe() (int, chan *walletrpc.RawTransaction, []*walletrpc.RawTransaction) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
id := m.nextID
|
|
m.nextID++
|
|
ch := make(chan *walletrpc.RawTransaction, 256)
|
|
m.subs[id] = ch
|
|
snapshot := make([]*walletrpc.RawTransaction, len(m.ordered))
|
|
copy(snapshot, m.ordered)
|
|
return id, ch, snapshot
|
|
}
|
|
|
|
// unsubscribe removes a stream. Safe to call after a new-block reset already dropped it (the map
|
|
// lookup guards against a double close).
|
|
func (m *mempoolMonitor) unsubscribe(id int) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if ch, ok := m.subs[id]; ok {
|
|
delete(m.subs, id)
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
// reset is called when a new block is mined: forget this block's txs and close every subscriber
|
|
// stream so clients re-sync the block and reconnect.
|
|
func (m *mempoolMonitor) reset() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.seen = make(map[string]bool)
|
|
m.ordered = nil
|
|
for id, ch := range m.subs {
|
|
delete(m.subs, id)
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
func (m *mempoolMonitor) run(client *zrpc.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
|
|
// height that advanced during the idle period.
|
|
if h := cache.GetLatestBlock(); h != lastHeight {
|
|
lastHeight = h
|
|
m.reset()
|
|
}
|
|
|
|
m.mu.Lock()
|
|
idle := len(m.subs) == 0
|
|
m.mu.Unlock()
|
|
if idle {
|
|
// No one is listening; don't touch the node until a client subscribes.
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
|
|
height := uint64(lastHeight + 1)
|
|
|
|
// List the current mempool txids: getrawmempool(false) -> ["txid", ...].
|
|
mpParams := []json.RawMessage{json.RawMessage("false")}
|
|
result, rpcErr := client.RawRequest("getrawmempool", mpParams)
|
|
if rpcErr != nil {
|
|
log.Warningf("mempool monitor: getrawmempool failed: %s", rpcErr.Error())
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
var txids []string
|
|
if err := json.Unmarshal(result, &txids); err != nil {
|
|
log.Warningf("mempool monitor: cannot parse getrawmempool: %s", err.Error())
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
|
|
for _, txid := range txids {
|
|
m.mu.Lock()
|
|
already := m.seen[txid]
|
|
m.mu.Unlock()
|
|
if already {
|
|
continue
|
|
}
|
|
|
|
// Fetch the full serialized tx: getrawtransaction("txid") -> hex. Full bytes are
|
|
// required so shielded memos survive (a CompactTx's 52-byte prefix would not carry a
|
|
// memo). The mempool txid is already in RPC display byte order, so (unlike
|
|
// GetTransaction) no reversal is needed.
|
|
txParams := []json.RawMessage{json.RawMessage("\"" + txid + "\"")}
|
|
txResult, txErr := client.RawRequest("getrawtransaction", txParams)
|
|
if txErr != nil {
|
|
// The tx may have been mined or evicted between listing and fetch; skip it.
|
|
continue
|
|
}
|
|
var txhex string
|
|
if err := json.Unmarshal(txResult, &txhex); err != nil {
|
|
continue
|
|
}
|
|
txBytes, err := hex.DecodeString(txhex)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rtx := &walletrpc.RawTransaction{Data: txBytes, Height: height}
|
|
|
|
// Record + fan out under the lock. The send is non-blocking (default case), so a slow
|
|
// subscriber never stalls the poller or the other subscribers.
|
|
m.mu.Lock()
|
|
if !m.seen[txid] {
|
|
m.seen[txid] = true
|
|
m.ordered = append(m.ordered, rtx)
|
|
for _, ch := range m.subs {
|
|
select {
|
|
case ch <- rtx:
|
|
default:
|
|
// Slow subscriber; drop. Block sync will deliver the confirmed tx later.
|
|
}
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
}
|