mirror of
https://github.com/tendermint/tendermint.git
synced 2026-02-06 03:50:46 +00:00
* rpc: Strip down the base RPC client interface. Prior to this change, the RPC client interface requires implementing the entire Service interface, but most of the methods of Service are not needed by the concrete clients. Dissociate the Client interface from the Service interface. - Extract only those methods of Service that are necessary to make the existing clients work. - Update the clients to combine Start/Onstart and Stop/OnStop. This does not change what the clients do to start or stop. Only the websocket clients make use of this functionality anyway. The websocket implementation uses some plumbing from the BaseService helper. We should be able to excising that entirely, but the current interface dependencies among the clients would require a much larger change, and one that leaks into other (non-RPC) packages. As a less-invasive intermediate step, preserve the existing client behaviour (and tests) by extracting the necessary subset of the BaseService functionality to an analogous RunState helper for clients. I plan to obsolete that type in a future PR, but for now this makes a useful waypoint. Related: - Clean up client implementations. - Update mocks.
270 lines
6.5 KiB
Go
270 lines
6.5 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tmsync "github.com/tendermint/tendermint/internal/libs/sync"
|
|
tmjson "github.com/tendermint/tendermint/libs/json"
|
|
tmpubsub "github.com/tendermint/tendermint/libs/pubsub"
|
|
rpcclient "github.com/tendermint/tendermint/rpc/client"
|
|
ctypes "github.com/tendermint/tendermint/rpc/coretypes"
|
|
jsonrpcclient "github.com/tendermint/tendermint/rpc/jsonrpc/client"
|
|
)
|
|
|
|
// WSOptions for the WS part of the HTTP client.
|
|
type WSOptions struct {
|
|
Path string // path (e.g. "/ws")
|
|
|
|
jsonrpcclient.WSOptions // WSClient options
|
|
}
|
|
|
|
// DefaultWSOptions returns default WS options.
|
|
// See jsonrpcclient.DefaultWSOptions.
|
|
func DefaultWSOptions() WSOptions {
|
|
return WSOptions{
|
|
Path: "/websocket",
|
|
WSOptions: jsonrpcclient.DefaultWSOptions(),
|
|
}
|
|
}
|
|
|
|
// Validate performs a basic validation of WSOptions.
|
|
func (wso WSOptions) Validate() error {
|
|
if len(wso.Path) <= 1 {
|
|
return errors.New("empty Path")
|
|
}
|
|
if wso.Path[0] != '/' {
|
|
return errors.New("leading slash is missing in Path")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// wsEvents is a wrapper around WSClient, which implements EventsClient.
|
|
type wsEvents struct {
|
|
*rpcclient.RunState
|
|
ws *jsonrpcclient.WSClient
|
|
|
|
mtx tmsync.RWMutex
|
|
subscriptions map[string]*wsSubscription
|
|
}
|
|
|
|
type wsSubscription struct {
|
|
res chan ctypes.ResultEvent
|
|
id string
|
|
query string
|
|
}
|
|
|
|
var _ rpcclient.EventsClient = (*wsEvents)(nil)
|
|
|
|
func newWsEvents(remote string, wso WSOptions) (*wsEvents, error) {
|
|
// validate options
|
|
if err := wso.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid WSOptions: %w", err)
|
|
}
|
|
|
|
// remove the trailing / from the remote else the websocket endpoint
|
|
// won't parse correctly
|
|
if remote[len(remote)-1] == '/' {
|
|
remote = remote[:len(remote)-1]
|
|
}
|
|
|
|
w := &wsEvents{
|
|
subscriptions: make(map[string]*wsSubscription),
|
|
}
|
|
w.RunState = rpcclient.NewRunState("wsEvents", nil)
|
|
|
|
var err error
|
|
w.ws, err = jsonrpcclient.NewWSWithOptions(remote, wso.Path, wso.WSOptions)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("can't create WS client: %w", err)
|
|
}
|
|
w.ws.OnReconnect(func() {
|
|
// resubscribe immediately
|
|
w.redoSubscriptionsAfter(0 * time.Second)
|
|
})
|
|
w.ws.SetLogger(w.Logger)
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// Start starts the websocket client and the event loop.
|
|
func (w *wsEvents) Start() error {
|
|
if err := w.ws.Start(); err != nil {
|
|
return err
|
|
}
|
|
go w.eventListener()
|
|
return nil
|
|
}
|
|
|
|
// IsRunning reports whether the websocket client is running.
|
|
func (w *wsEvents) IsRunning() bool { return w.ws.IsRunning() }
|
|
|
|
// Stop shuts down the websocket client.
|
|
func (w *wsEvents) Stop() error { return w.ws.Stop() }
|
|
|
|
// Subscribe implements EventsClient by using WSClient to subscribe given
|
|
// subscriber to query. By default, it returns a channel with cap=1. Error is
|
|
// returned if it fails to subscribe.
|
|
//
|
|
// When reading from the channel, keep in mind there's a single events loop, so
|
|
// if you don't read events for this subscription fast enough, other
|
|
// subscriptions will slow down in effect.
|
|
//
|
|
// The channel is never closed to prevent clients from seeing an erroneous
|
|
// event.
|
|
//
|
|
// It returns an error if wsEvents is not running.
|
|
func (w *wsEvents) Subscribe(ctx context.Context, subscriber, query string,
|
|
outCapacity ...int) (out <-chan ctypes.ResultEvent, err error) {
|
|
|
|
if !w.IsRunning() {
|
|
return nil, rpcclient.ErrClientNotRunning
|
|
}
|
|
|
|
if err := w.ws.Subscribe(ctx, query); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outCap := 1
|
|
if len(outCapacity) > 0 {
|
|
outCap = outCapacity[0]
|
|
}
|
|
|
|
outc := make(chan ctypes.ResultEvent, outCap)
|
|
w.mtx.Lock()
|
|
defer w.mtx.Unlock()
|
|
// subscriber param is ignored because Tendermint will override it with
|
|
// remote IP anyway.
|
|
w.subscriptions[query] = &wsSubscription{res: outc, query: query}
|
|
|
|
return outc, nil
|
|
}
|
|
|
|
// Unsubscribe implements EventsClient by using WSClient to unsubscribe given
|
|
// subscriber from query.
|
|
//
|
|
// It returns an error if wsEvents is not running.
|
|
func (w *wsEvents) Unsubscribe(ctx context.Context, subscriber, query string) error {
|
|
if !w.IsRunning() {
|
|
return rpcclient.ErrClientNotRunning
|
|
}
|
|
|
|
if err := w.ws.Unsubscribe(ctx, query); err != nil {
|
|
return err
|
|
}
|
|
|
|
w.mtx.Lock()
|
|
info, ok := w.subscriptions[query]
|
|
if ok {
|
|
if info.id != "" {
|
|
delete(w.subscriptions, info.id)
|
|
}
|
|
delete(w.subscriptions, info.query)
|
|
}
|
|
w.mtx.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnsubscribeAll implements EventsClient by using WSClient to unsubscribe
|
|
// given subscriber from all the queries.
|
|
//
|
|
// It returns an error if wsEvents is not running.
|
|
func (w *wsEvents) UnsubscribeAll(ctx context.Context, subscriber string) error {
|
|
if !w.IsRunning() {
|
|
return rpcclient.ErrClientNotRunning
|
|
}
|
|
|
|
if err := w.ws.UnsubscribeAll(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
w.mtx.Lock()
|
|
w.subscriptions = make(map[string]*wsSubscription)
|
|
w.mtx.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// After being reconnected, it is necessary to redo subscription to server
|
|
// otherwise no data will be automatically received.
|
|
func (w *wsEvents) redoSubscriptionsAfter(d time.Duration) {
|
|
time.Sleep(d)
|
|
|
|
ctx := context.Background()
|
|
|
|
w.mtx.Lock()
|
|
defer w.mtx.Unlock()
|
|
|
|
for q, info := range w.subscriptions {
|
|
if q != "" && q == info.id {
|
|
continue
|
|
}
|
|
err := w.ws.Subscribe(ctx, q)
|
|
if err != nil {
|
|
w.Logger.Error("failed to resubscribe", "query", q, "err", err)
|
|
delete(w.subscriptions, q)
|
|
}
|
|
}
|
|
}
|
|
|
|
func isErrAlreadySubscribed(err error) bool {
|
|
return strings.Contains(err.Error(), tmpubsub.ErrAlreadySubscribed.Error())
|
|
}
|
|
|
|
func (w *wsEvents) eventListener() {
|
|
for {
|
|
select {
|
|
case resp, ok := <-w.ws.ResponsesCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if resp.Error != nil {
|
|
w.Logger.Error("WS error", "err", resp.Error.Error())
|
|
// Error can be ErrAlreadySubscribed or max client (subscriptions per
|
|
// client) reached or Tendermint exited.
|
|
// We can ignore ErrAlreadySubscribed, but need to retry in other
|
|
// cases.
|
|
if !isErrAlreadySubscribed(resp.Error) {
|
|
// Resubscribe after 1 second to give Tendermint time to restart (if
|
|
// crashed).
|
|
w.redoSubscriptionsAfter(1 * time.Second)
|
|
}
|
|
continue
|
|
}
|
|
|
|
result := new(ctypes.ResultEvent)
|
|
err := tmjson.Unmarshal(resp.Result, result)
|
|
if err != nil {
|
|
w.Logger.Error("failed to unmarshal response", "err", err)
|
|
continue
|
|
}
|
|
|
|
w.mtx.RLock()
|
|
out, ok := w.subscriptions[result.Query]
|
|
if ok {
|
|
if _, idOk := w.subscriptions[result.SubscriptionID]; !idOk {
|
|
out.id = result.SubscriptionID
|
|
w.subscriptions[result.SubscriptionID] = out
|
|
}
|
|
}
|
|
|
|
w.mtx.RUnlock()
|
|
if ok {
|
|
select {
|
|
case out.res <- *result:
|
|
case <-w.Quit():
|
|
return
|
|
}
|
|
}
|
|
case <-w.Quit():
|
|
return
|
|
}
|
|
}
|
|
}
|