decred.org/dcrdex@v1.0.5/client/websocket/websocket.go (about) 1 // This code is available on the terms of the project LICENSE.md file, 2 // also available online at https://blueoakcouncil.org/license/1.0.0. 3 4 package websocket 5 6 import ( 7 "context" 8 "encoding/json" 9 "net/http" 10 "sync" 11 "sync/atomic" 12 "time" 13 14 "decred.org/dcrdex/client/core" 15 "decred.org/dcrdex/client/orderbook" 16 "decred.org/dcrdex/dex" 17 "decred.org/dcrdex/dex/msgjson" 18 "decred.org/dcrdex/dex/ws" 19 ) 20 21 var ( 22 // Time allowed to read the next pong message from the peer. The 23 // default is intended for production, but leaving as a var instead of const 24 // to facilitate testing. 25 pongWait = 60 * time.Second 26 // Send pings to peer with this period. Must be less than pongWait. The 27 // default is intended for production, but leaving as a var instead of const 28 // to facilitate testing. 29 pingPeriod = (pongWait * 9) / 10 30 // A client id counter. 31 cidCounter int32 32 ) 33 34 type bookFeed struct { 35 core.BookFeed 36 loop *dex.StartStopWaiter 37 host string 38 base, quote uint32 39 } 40 41 // wsClient is a persistent websocket connection to a client. 42 type wsClient struct { 43 *ws.WSLink 44 cid int32 45 46 feedMtx sync.RWMutex 47 feed *bookFeed 48 } 49 50 func newWSClient(addr string, conn ws.Connection, hndlr func(msg *msgjson.Message) *msgjson.Error, logger dex.Logger) *wsClient { 51 return &wsClient{ 52 WSLink: ws.NewWSLink(addr, conn, pingPeriod, hndlr, logger), 53 cid: atomic.AddInt32(&cidCounter, 1), 54 } 55 } 56 57 func (cl *wsClient) shutDownFeed() { 58 if cl.feed != nil { 59 cl.feed.loop.Stop() 60 cl.feed.loop.WaitForShutdown() 61 cl.feed = nil 62 } 63 } 64 65 // Core specifies the needed methods for Server to operate. Satisfied by *core.Core. 66 type Core interface { 67 SyncBook(dex string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) 68 AckNotes([]dex.Bytes) 69 } 70 71 // Server is a websocket hub that tracks all running websocket clients, allows 72 // sending notifications to all of them, and manages per-client order book 73 // subscriptions. 74 type Server struct { 75 core Core 76 log dex.Logger 77 wg sync.WaitGroup 78 79 clientsMtx sync.RWMutex 80 clients map[int32]*wsClient 81 } 82 83 // New returns a new websocket Server. 84 func New(core Core, log dex.Logger) *Server { 85 return &Server{ 86 core: core, 87 log: log, 88 clients: make(map[int32]*wsClient), 89 } 90 } 91 92 // Shutdown gracefully shuts down all connected clients, waiting for them to 93 // disconnect and any running goroutines and message handlers to return. 94 func (s *Server) Shutdown() { 95 s.clientsMtx.Lock() 96 for _, cl := range s.clients { 97 cl.Disconnect() 98 } 99 s.clientsMtx.Unlock() 100 // Each upgraded connection handler must return. This also waits for running 101 // marketSyncers and response handlers as long as dex/ws.(*WSLink) operates 102 // as designed and each (*Server).connect goroutine waits for the link's 103 // WaitGroup before returning. 104 s.wg.Wait() 105 } 106 107 // HandleConnect handles the websocket connection request, creating a 108 // ws.Connection and a connect thread. Since the http.Request's Context is 109 // canceled after ServerHTTP returns, a separate context must be provided to be 110 // able to cancel the hijacked connection handler at a later time since this 111 // function is not blocking. 112 func (s *Server) HandleConnect(ctx context.Context, w http.ResponseWriter, r *http.Request) { 113 wsConn, err := ws.NewConnection(w, r, pongWait) 114 if err != nil { 115 s.log.Errorf("ws connection error: %v", err) 116 return 117 } 118 119 // wsConn.SetReadLimit(65536) // if websocket reads need to be larger than ws.defaultReadLimit 120 121 // Launch the handler for the upgraded connection. Shutdown will wait for 122 // these to return. 123 s.wg.Add(1) 124 go func() { 125 defer s.wg.Done() 126 s.connect(ctx, wsConn, r.RemoteAddr) 127 }() 128 } 129 130 // connect handles a new websocket client by creating a new wsClient, starting 131 // it, and blocking until the connection closes. This method should be 132 // run as a goroutine. 133 func (s *Server) connect(ctx context.Context, conn ws.Connection, addr string) { 134 s.log.Debugf("New websocket client %s", addr) 135 // Create a new websocket client to handle the new websocket connection 136 // and wait for it to shut down. Once it has shutdown (and hence 137 // disconnected), remove it. 138 var cl *wsClient 139 cl = newWSClient(addr, conn, func(msg *msgjson.Message) *msgjson.Error { 140 return s.handleMessage(cl, msg) 141 }, s.log.SubLogger(addr)) 142 143 // Lock the clients map before starting the connection listening so that 144 // synchronized map accesses are guaranteed to reflect this connection. 145 // Also, ensuring only live connections are in the clients map notify from 146 // sending before it is connected. 147 s.clientsMtx.Lock() 148 cm := dex.NewConnectionMaster(cl) 149 err := cm.ConnectOnce(ctx) // we discard the cm anyway, but good practice 150 if err != nil { 151 s.clientsMtx.Unlock() 152 s.log.Errorf("websocketHandler client connect: %v", err) 153 return 154 } 155 156 // Add the client to the map only after it is connected so that notify does 157 // not attempt to send to non-existent connection. 158 s.clients[cl.cid] = cl 159 s.clientsMtx.Unlock() 160 161 defer func() { 162 cl.feedMtx.Lock() 163 cl.shutDownFeed() 164 cl.feedMtx.Unlock() 165 166 s.clientsMtx.Lock() 167 delete(s.clients, cl.cid) 168 s.clientsMtx.Unlock() 169 }() 170 171 cm.Wait() // also waits for any handleMessage calls in (*WSLink).inHandler 172 s.log.Tracef("Disconnected websocket client %s", addr) 173 } 174 175 // Notify sends a notification to the websocket client. 176 func (s *Server) Notify(route string, payload any) { 177 msg, err := msgjson.NewNotification(route, payload) 178 if err != nil { 179 s.log.Errorf("%q notification encoding error: %v", route, err) 180 return 181 } 182 s.clientsMtx.RLock() 183 defer s.clientsMtx.RUnlock() 184 for _, cl := range s.clients { 185 if err = cl.Send(msg); err != nil { 186 s.log.Warnf("Failed to send %v notification to client %v at %v: %v", 187 msg.Route, cl.cid, cl.Addr(), err) 188 } 189 } 190 } 191 192 // handleMessage handles the websocket message, calling the right handler for 193 // the route. 194 func (s *Server) handleMessage(conn *wsClient, msg *msgjson.Message) *msgjson.Error { 195 s.log.Tracef("message of type %d received for route %s", msg.Type, msg.Route) 196 if msg.Type == msgjson.Request { 197 handler, found := wsHandlers[msg.Route] 198 if !found { 199 return msgjson.NewError(msgjson.UnknownMessageType, "unknown route %q", msg.Route) 200 } 201 return handler(s, conn, msg) 202 } 203 // Web server doesn't send requests, only responses and notifications, so 204 // a response-type message from a client is an error. 205 return msgjson.NewError(msgjson.UnknownMessageType, "web server only handles requests") 206 } 207 208 // All request handlers must be defined with this signature. 209 type wsHandler func(*Server, *wsClient, *msgjson.Message) *msgjson.Error 210 211 // wsHandlers is the map used by the server to locate the router handler for a 212 // request. 213 var wsHandlers = map[string]wsHandler{ 214 "loadmarket": wsLoadMarket, 215 "loadcandles": wsLoadCandles, 216 "unmarket": wsUnmarket, 217 "acknotes": wsAckNotes, 218 } 219 220 // marketLoad is sent by websocket clients to subscribe to a market and request 221 // the order book. 222 type marketLoad struct { 223 Host string `json:"host"` 224 Base uint32 `json:"base"` 225 Quote uint32 `json:"quote"` 226 } 227 228 type candlesLoad struct { 229 marketLoad 230 Dur string `json:"dur"` 231 } 232 233 // marketSyncer is used to synchronize market subscriptions. The marketSyncer 234 // manages a map of clients who are subscribed to the market, and distributes 235 // order book updates when received. 236 type marketSyncer struct { 237 log dex.Logger 238 feed core.BookFeed 239 cl *wsClient 240 } 241 242 // newMarketSyncer is the constructor for a marketSyncer, returned as a running 243 // *dex.StartStopWaiter. 244 func newMarketSyncer(cl *wsClient, feed core.BookFeed, log dex.Logger) *dex.StartStopWaiter { 245 ssWaiter := dex.NewStartStopWaiter(&marketSyncer{ 246 feed: feed, 247 cl: cl, 248 log: log, 249 }) 250 ssWaiter.Start(context.Background()) // wrapping Run with a cancel bound to Stop 251 return ssWaiter 252 } 253 254 // Run starts the marketSyncer listening for BookUpdates, which it relays to the 255 // websocket client as notifications. 256 func (m *marketSyncer) Run(ctx context.Context) { 257 out: 258 for { 259 select { 260 case update, ok := <-m.feed.Next(): 261 if !ok { 262 // We are skipping m.feed.Close if the feed were closed (external sig). 263 return 264 } 265 note, err := msgjson.NewNotification(update.Action, update) 266 if err != nil { 267 m.log.Errorf("error encoding notification message: %v", err) 268 break out 269 } 270 err = m.cl.Send(note) 271 if err != nil { 272 m.log.Debugf("send error. ending market feed: %v", err) 273 break out 274 } 275 case <-ctx.Done(): 276 break out 277 } 278 } 279 m.feed.Close() 280 } 281 282 // wsLoadMarket is the handler for the 'loadmarket' websocket route. Subscribes 283 // the client to the notification feed and sends the order book. 284 func wsLoadMarket(s *Server, cl *wsClient, msg *msgjson.Message) *msgjson.Error { 285 req := new(marketLoad) 286 err := json.Unmarshal(msg.Payload, req) 287 if err != nil { 288 return msgjson.NewError(msgjson.RPCInternal, "error unmarshalling marketload payload: %v", err) 289 } 290 _, msgErr := loadMarket(s, cl, req) 291 return msgErr 292 } 293 294 func loadMarket(s *Server, cl *wsClient, req *marketLoad) (*bookFeed, *msgjson.Error) { 295 name, err := dex.MarketName(req.Base, req.Quote) 296 if err != nil { 297 return nil, msgjson.NewError(msgjson.UnknownMarketError, "unknown market: %v", err) 298 } 299 300 _, feed, err := s.core.SyncBook(req.Host, req.Base, req.Quote) 301 if err != nil { 302 return nil, msgjson.NewError(msgjson.RPCOrderBookError, "error getting order feed: %v", err) 303 } 304 305 cl.feedMtx.Lock() 306 defer cl.feedMtx.Unlock() 307 cl.shutDownFeed() 308 cl.feed = &bookFeed{ 309 BookFeed: feed, 310 loop: newMarketSyncer(cl, feed, s.log.SubLogger(name)), 311 host: req.Host, 312 base: req.Base, 313 quote: req.Quote, 314 } 315 return cl.feed, nil 316 } 317 318 func wsLoadCandles(s *Server, cl *wsClient, msg *msgjson.Message) *msgjson.Error { 319 req := new(candlesLoad) 320 err := json.Unmarshal(msg.Payload, req) 321 if err != nil { 322 return msgjson.NewError(msgjson.RPCInternal, "error unmarshalling candlesLoad payload: %v", err) 323 } 324 cl.feedMtx.RLock() 325 feed := cl.feed 326 cl.feedMtx.RUnlock() 327 // If market hasn't been initialized/chosen yet (client should do it in a separate 328 // 'loadmarket' request), or if client wants to change currently chosen market (requesting 329 // candles for market that's different from currently chosen implies that) - we can 330 // try to load it here. 331 if feed == nil || 332 (feed.host != req.Host || feed.base != req.Base || feed.quote != req.Quote) { 333 var msgErr *msgjson.Error 334 feed, msgErr = loadMarket(s, cl, &req.marketLoad) 335 if msgErr != nil { 336 return msgErr 337 } 338 } 339 err = feed.Candles(req.Dur) 340 if err != nil { 341 return msgjson.NewError(msgjson.RPCInternal, "%v", err) 342 } 343 return nil 344 } 345 346 // wsUnmarket is the handler for the 'unmarket' websocket route. This empty 347 // message is sent when the user leaves the markets page. This closes the feed, 348 // and potentially unsubscribes from orderbook with the server if there are no 349 // other consumers 350 func wsUnmarket(_ *Server, cl *wsClient, _ *msgjson.Message) *msgjson.Error { 351 cl.feedMtx.Lock() 352 cl.shutDownFeed() 353 cl.feedMtx.Unlock() 354 355 return nil 356 } 357 358 type ackNoteIDs []dex.Bytes 359 360 // wsAckNotes is the handler for the 'acknotes' websocket route. It informs the 361 // Core that the user has seen the specified notifications. 362 func wsAckNotes(s *Server, _ *wsClient, msg *msgjson.Message) *msgjson.Error { 363 ids := make(ackNoteIDs, 0) 364 err := msg.Unmarshal(&ids) 365 if err != nil { 366 s.log.Errorf("error acking notifications: %v", err) 367 return nil 368 } 369 s.core.AckNotes(ids) 370 return nil 371 }