634 lines
19 KiB
Go
634 lines
19 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"io"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var ErrConnectionLimit = errors.New("ws: connection limit reached")
|
|
|
|
// ReconnectConfig controls Run only. Dial never retries. Zero MaxAttempts and
|
|
// MaxElapsed mean unlimited retries/time when Enabled is explicitly true.
|
|
// A stable connection resets both retry budgets; short flaps do not.
|
|
type ReconnectConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
MinBackoff time.Duration `json:"minBackoff"`
|
|
MaxBackoff time.Duration `json:"maxBackoff"`
|
|
StableAfter time.Duration `json:"stableAfter"`
|
|
MaxAttempts int `json:"maxAttempts"`
|
|
MaxElapsed time.Duration `json:"maxElapsed"`
|
|
}
|
|
|
|
type ClientConfig struct {
|
|
Session SessionConfig `json:"session"`
|
|
HandshakeTimeout time.Duration `json:"handshakeTimeout"`
|
|
MaxConnections int `json:"maxConnections"`
|
|
Subprotocols []string `json:"subprotocols"`
|
|
RequireSubprotocol bool `json:"requireSubprotocol"`
|
|
EnableCompression bool `json:"enableCompression"`
|
|
// ProxyMode is environment (default), direct, or url. ProxyURL may contain
|
|
// credentials and must not be logged. Explicit proxy URLs ignore NO_PROXY.
|
|
// Supported proxy schemes are http, socks5, and socks5h (remote DNS).
|
|
ProxyMode string `json:"proxyMode"`
|
|
ProxyURL string `json:"proxyUrl"`
|
|
Reconnect ReconnectConfig `json:"reconnect"`
|
|
Headers http.Header `json:"-"`
|
|
// HeaderProvider is called before each handshake. Return a fresh header map;
|
|
// the client snapshots it. Provider values replace static Headers values.
|
|
// Implementations must honor context cancellation and support concurrent calls.
|
|
HeaderProvider func(context.Context) (http.Header, error) `json:"-"`
|
|
// Prepare creates per-session handlers after a successful handshake. When
|
|
// set, its result replaces Dial/Run handlers. It must honor cancellation.
|
|
Prepare func(context.Context) (Handlers, error) `json:"-"`
|
|
TLSConfig *tls.Config `json:"-"`
|
|
// ShouldReconnect overrides default error classification, not budgets or
|
|
// explicit client/context shutdown. It must be safe for concurrent Run calls.
|
|
ShouldReconnect func(error) bool `json:"-"`
|
|
}
|
|
|
|
func (cfg ClientConfig) Validate() error { _, _, err := normalizeClientConfig(cfg); return err }
|
|
|
|
func normalizeClientConfig(cfg ClientConfig) (ClientConfig, func(*http.Request) (*url.URL, error), error) {
|
|
var err error
|
|
cfg.Session, err = normalizeSessionConfig(cfg.Session)
|
|
if err != nil {
|
|
return cfg, nil, err
|
|
}
|
|
if cfg.HandshakeTimeout < 0 || cfg.MaxConnections < 0 || cfg.MaxConnections > 65536 || cfg.Reconnect.MinBackoff < 0 || cfg.Reconnect.MaxBackoff < 0 || cfg.Reconnect.StableAfter < 0 || cfg.Reconnect.MaxElapsed < 0 || cfg.Reconnect.MaxAttempts < 0 {
|
|
return cfg, nil, errors.New("ws: invalid client limits or durations")
|
|
}
|
|
if cfg.HandshakeTimeout == 0 {
|
|
cfg.HandshakeTimeout = 10 * time.Second
|
|
}
|
|
if cfg.MaxConnections == 0 {
|
|
cfg.MaxConnections = 64
|
|
}
|
|
if cfg.Reconnect.MinBackoff == 0 {
|
|
cfg.Reconnect.MinBackoff = 100 * time.Millisecond
|
|
}
|
|
if cfg.Reconnect.MaxBackoff == 0 {
|
|
cfg.Reconnect.MaxBackoff = 30 * time.Second
|
|
}
|
|
if cfg.Reconnect.StableAfter == 0 {
|
|
cfg.Reconnect.StableAfter = 30 * time.Second
|
|
}
|
|
if cfg.Reconnect.MinBackoff > cfg.Reconnect.MaxBackoff {
|
|
return cfg, nil, errors.New("ws: minBackoff exceeds maxBackoff")
|
|
}
|
|
cfg.Headers, err = clientHeaders(cfg.Headers)
|
|
if err != nil {
|
|
return cfg, nil, err
|
|
}
|
|
if len(cfg.Subprotocols) > 32 {
|
|
return cfg, nil, errors.New("ws: too many subprotocols")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, protocol := range cfg.Subprotocols {
|
|
if !headerToken(protocol) || len(protocol) > 256 || seen[protocol] {
|
|
return cfg, nil, errors.New("ws: invalid or duplicate subprotocol")
|
|
}
|
|
seen[protocol] = true
|
|
}
|
|
if cfg.RequireSubprotocol && len(cfg.Subprotocols) == 0 {
|
|
return cfg, nil, errors.New("ws: required subprotocol list is empty")
|
|
}
|
|
cfg.Subprotocols = append([]string(nil), cfg.Subprotocols...)
|
|
if cfg.TLSConfig != nil {
|
|
cfg.TLSConfig = cfg.TLSConfig.Clone()
|
|
}
|
|
if cfg.ProxyMode != "url" && cfg.ProxyURL != "" {
|
|
return cfg, nil, errors.New("ws: proxyUrl requires url proxyMode")
|
|
}
|
|
var proxy func(*http.Request) (*url.URL, error)
|
|
switch cfg.ProxyMode {
|
|
case "", "environment":
|
|
proxy = func(r *http.Request) (*url.URL, error) {
|
|
u, err := http.ProxyFromEnvironment(r)
|
|
if err != nil || u == nil {
|
|
return u, err
|
|
}
|
|
return supportedProxy(u)
|
|
}
|
|
case "direct":
|
|
case "url":
|
|
u, e := url.Parse(cfg.ProxyURL)
|
|
if e != nil || u == nil || u.Hostname() == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") || !validPort(u) {
|
|
return cfg, nil, errors.New("ws: invalid proxy URL")
|
|
}
|
|
u, e = supportedProxy(u)
|
|
if e != nil {
|
|
return cfg, nil, e
|
|
}
|
|
proxy = http.ProxyURL(u)
|
|
default:
|
|
return cfg, nil, errors.New("ws: invalid proxyMode")
|
|
}
|
|
return cfg, proxy, nil
|
|
}
|
|
|
|
func supportedProxy(u *url.URL) (*url.URL, error) {
|
|
copyURL := *u
|
|
switch copyURL.Scheme {
|
|
case "http", "socks5":
|
|
case "socks5h":
|
|
copyURL.Scheme = "socks5"
|
|
default:
|
|
return nil, errors.New("ws: proxy scheme must be http, socks5 or socks5h")
|
|
}
|
|
return ©URL, nil
|
|
}
|
|
|
|
func validPort(u *url.URL) bool {
|
|
if strings.HasSuffix(u.Host, ":") {
|
|
return false
|
|
}
|
|
if p := u.Port(); p != "" {
|
|
n, err := strconv.Atoi(p)
|
|
return err == nil && n > 0 && n <= 65535
|
|
}
|
|
return true
|
|
}
|
|
|
|
func headerToken(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
func clientHeaders(input http.Header) (http.Header, error) {
|
|
if len(input) > 128 {
|
|
return nil, errors.New("ws: too many handshake headers")
|
|
}
|
|
out := make(http.Header, len(input))
|
|
size := 0
|
|
for key, values := range input {
|
|
name := http.CanonicalHeaderKey(key)
|
|
if !headerToken(key) || len(key) > 256 {
|
|
return nil, errors.New("ws: invalid handshake header name")
|
|
}
|
|
switch name {
|
|
case "Host", "Connection", "Upgrade", "Sec-Websocket-Key", "Sec-Websocket-Version", "Sec-Websocket-Extensions", "Sec-Websocket-Protocol", "Content-Length", "Transfer-Encoding", "Trailer":
|
|
return nil, errors.New("ws: reserved handshake header")
|
|
}
|
|
if _, ok := out[name]; ok {
|
|
return nil, errors.New("ws: duplicate handshake header")
|
|
}
|
|
for _, value := range values {
|
|
size += len(key) + len(value)
|
|
for i := 0; i < len(value); i++ {
|
|
if value[i] < 32 && value[i] != '\t' || value[i] == 127 {
|
|
return nil, errors.New("ws: invalid handshake header value")
|
|
}
|
|
}
|
|
}
|
|
if size > 64<<10 {
|
|
return nil, errors.New("ws: handshake headers exceed limit")
|
|
}
|
|
out[name] = append([]string(nil), values...)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Client owns its sessions. Concurrent Dial and Run calls are independent and
|
|
// share only limits and lifecycle. One Run owns one reconnecting connection;
|
|
// every successful handshake produces a new Session with a new ID.
|
|
type Client struct {
|
|
config ClientConfig
|
|
dialer websocket.Dialer
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
mu sync.Mutex
|
|
closed bool
|
|
active int
|
|
connections int
|
|
dialAttempts, handshakeFailures, reconnects uint64
|
|
sessions map[string]*Session
|
|
releases map[string]func()
|
|
done chan struct{}
|
|
}
|
|
|
|
func NewClient(cfg ClientConfig) (*Client, error) {
|
|
cfg, proxy, err := normalizeClientConfig(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Client{config: cfg, dialer: websocket.Dialer{Proxy: proxy, HandshakeTimeout: cfg.HandshakeTimeout, TLSClientConfig: cfg.TLSConfig, Subprotocols: cfg.Subprotocols, EnableCompression: cfg.EnableCompression}, ctx: ctx, cancel: cancel, sessions: make(map[string]*Session), releases: make(map[string]func()), done: make(chan struct{})}, nil
|
|
}
|
|
|
|
func (c *Client) begin(connection bool) error {
|
|
for {
|
|
c.mu.Lock()
|
|
if c.closed {
|
|
c.mu.Unlock()
|
|
return ErrClosed
|
|
}
|
|
if connection && c.connections >= c.config.MaxConnections {
|
|
// Done may wake a caller before its registry watcher gets CPU time.
|
|
// Retire finished sessions synchronously before reporting overload.
|
|
var release func()
|
|
for id, s := range c.sessions {
|
|
select {
|
|
case <-s.Done():
|
|
release = c.releases[id]
|
|
default:
|
|
}
|
|
if release != nil {
|
|
break
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
if release != nil {
|
|
release()
|
|
continue
|
|
}
|
|
return ErrConnectionLimit
|
|
}
|
|
c.active++
|
|
if connection {
|
|
c.connections++
|
|
c.dialAttempts++
|
|
}
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
}
|
|
func (c *Client) end(connection bool, id string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if id != "" {
|
|
delete(c.sessions, id)
|
|
delete(c.releases, id)
|
|
}
|
|
c.active--
|
|
if connection {
|
|
c.connections--
|
|
}
|
|
if c.closed && c.active == 0 {
|
|
close(c.done)
|
|
}
|
|
}
|
|
|
|
// Dial performs exactly one handshake. Its timeout ends at the handshake;
|
|
// ctx continues to own the resulting session's lifetime. No message is replayed.
|
|
func (c *Client) Dial(ctx context.Context, endpoint string, handlers Handlers) (*Session, error) {
|
|
return c.dial(ctx, endpoint, handlers, 0)
|
|
}
|
|
func (c *Client) dial(ctx context.Context, endpoint string, handlers Handlers, budget time.Duration) (*Session, error) {
|
|
if ctx == nil {
|
|
return nil, errors.New("ws: nil context")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
u, err := url.Parse(endpoint)
|
|
if err != nil || u == nil || u.Hostname() == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" || (u.Scheme != "ws" && u.Scheme != "wss") || !validPort(u) {
|
|
return nil, errors.New("ws: invalid endpoint")
|
|
}
|
|
if err = c.begin(true); err != nil {
|
|
return nil, err
|
|
}
|
|
transferred := false
|
|
sessionCtx, cancelSession := context.WithCancel(ctx)
|
|
stopSessionShutdown := context.AfterFunc(c.ctx, cancelSession)
|
|
defer func() {
|
|
if !transferred {
|
|
c.mu.Lock()
|
|
c.handshakeFailures++
|
|
c.mu.Unlock()
|
|
stopSessionShutdown()
|
|
cancelSession()
|
|
c.end(true, "")
|
|
}
|
|
}()
|
|
handshakeTimeout := c.config.HandshakeTimeout
|
|
if budget > 0 && budget < handshakeTimeout {
|
|
handshakeTimeout = budget
|
|
}
|
|
hctx, cancel := context.WithTimeout(ctx, handshakeTimeout)
|
|
stop := context.AfterFunc(c.ctx, cancel)
|
|
defer func() { stop(); cancel() }()
|
|
headers := c.config.Headers.Clone()
|
|
if c.config.HeaderProvider != nil {
|
|
provided, e := provideHeaders(c.config.HeaderProvider, hctx)
|
|
if e != nil {
|
|
return nil, &OpError{Op: "headers", Cause: e}
|
|
}
|
|
provided, e = clientHeaders(provided)
|
|
if e != nil {
|
|
return nil, &OpError{Op: "headers", Cause: e}
|
|
}
|
|
for k, v := range provided {
|
|
headers[k] = v
|
|
}
|
|
}
|
|
dialer := c.dialer
|
|
// Gorilla applies the context deadline to handshake I/O, but canceling a
|
|
// deadline-less context after TCP connect does not interrupt its HTTP read.
|
|
// Retain the raw socket only through the handshake so Close/Stop cancels
|
|
// stalled HTTP and proxy handshakes immediately, without timing out sessions.
|
|
var stopHandshake func() bool
|
|
dialer.NetDialContext = func(dialctx context.Context, network, address string) (net.Conn, error) {
|
|
conn, err := (&net.Dialer{Timeout: c.config.HandshakeTimeout, KeepAlive: 30 * time.Second}).DialContext(dialctx, network, address)
|
|
if err == nil {
|
|
stopHandshake = context.AfterFunc(hctx, func() { _ = conn.Close() })
|
|
}
|
|
return conn, err
|
|
}
|
|
if dialer.TLSClientConfig != nil {
|
|
dialer.TLSClientConfig = dialer.TLSClientConfig.Clone()
|
|
}
|
|
conn, response, err := dialer.DialContext(hctx, endpoint, headers)
|
|
if stopHandshake != nil {
|
|
stopHandshake()
|
|
}
|
|
if err == nil && hctx.Err() != nil {
|
|
_ = conn.Close()
|
|
err = hctx.Err()
|
|
}
|
|
if err != nil {
|
|
status := 0
|
|
if response != nil {
|
|
status = response.StatusCode
|
|
if response.Body != nil {
|
|
_ = response.Body.Close()
|
|
}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
if c.ctx.Err() != nil {
|
|
return nil, ErrClosed
|
|
}
|
|
if hctx.Err() != nil {
|
|
err = hctx.Err()
|
|
} else if deadline, ok := hctx.Deadline(); ok && !time.Now().Before(deadline) {
|
|
var timed net.Error
|
|
if errors.As(err, &timed) && timed.Timeout() {
|
|
err = errors.Join(context.DeadlineExceeded, err)
|
|
}
|
|
}
|
|
return nil, &HandshakeError{StatusCode: status, Cause: err}
|
|
}
|
|
if c.config.RequireSubprotocol && conn.Subprotocol() == "" {
|
|
_ = conn.Close()
|
|
return nil, &HandshakeError{StatusCode: http.StatusSwitchingProtocols, Cause: websocket.ErrBadHandshake}
|
|
}
|
|
if c.config.Prepare != nil {
|
|
handlers, err = prepareClient(c.config.Prepare, sessionCtx)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, &OpError{Op: "prepare", Cause: err}
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
if c.closed {
|
|
c.mu.Unlock()
|
|
_ = conn.Close()
|
|
return nil, ErrClosed
|
|
}
|
|
s := newSession(conn, c.config.Session, handlers, sessionCtx)
|
|
c.sessions[s.ID()] = s
|
|
var releaseOnce sync.Once
|
|
release := func() { releaseOnce.Do(func() { cancelSession(); c.end(true, s.ID()) }) }
|
|
c.releases[s.ID()] = release
|
|
transferred = true
|
|
stopSessionShutdown()
|
|
c.mu.Unlock()
|
|
s.start()
|
|
go func() { <-s.Done(); release() }()
|
|
return s, nil
|
|
}
|
|
|
|
func provideHeaders(fn func(context.Context) (http.Header, error), ctx context.Context) (headers http.Header, err error) {
|
|
defer func() {
|
|
if recover() != nil {
|
|
err = errors.New("ws: header provider panicked")
|
|
}
|
|
}()
|
|
return fn(ctx)
|
|
}
|
|
func prepareClient(fn func(context.Context) (Handlers, error), ctx context.Context) (handlers Handlers, err error) {
|
|
defer func() {
|
|
if recover() != nil {
|
|
err = errors.New("ws: prepare callback panicked")
|
|
}
|
|
}()
|
|
return fn(ctx)
|
|
}
|
|
|
|
// Run blocks until canceled, closed, or retry policy/budget stops it. Reconnect
|
|
// must be enabled explicitly. Handlers are reused; OnOpen must restore any
|
|
// application subscriptions. Credentials are refreshed by HeaderProvider.
|
|
func (c *Client) Run(ctx context.Context, endpoint string, handlers Handlers) error {
|
|
if ctx == nil {
|
|
return errors.New("ws: nil context")
|
|
}
|
|
if err := c.begin(false); err != nil {
|
|
return err
|
|
}
|
|
defer c.end(false, "")
|
|
runctx, cancel := context.WithCancel(ctx)
|
|
stop := context.AfterFunc(c.ctx, cancel)
|
|
defer func() { stop(); cancel() }()
|
|
attempts := 0
|
|
episode := time.Now()
|
|
var previousError error = context.DeadlineExceeded
|
|
repeat := false
|
|
for {
|
|
var remaining time.Duration
|
|
if c.config.Reconnect.Enabled && c.config.Reconnect.MaxElapsed > 0 {
|
|
remaining = c.config.Reconnect.MaxElapsed - time.Since(episode)
|
|
if remaining <= 0 {
|
|
return previousError
|
|
}
|
|
}
|
|
started := time.Now()
|
|
if repeat {
|
|
c.mu.Lock()
|
|
c.reconnects++
|
|
c.mu.Unlock()
|
|
}
|
|
repeat = true
|
|
s, err := c.dial(runctx, endpoint, handlers, remaining)
|
|
if err == nil {
|
|
started = time.Now()
|
|
err = s.Wait(runctx)
|
|
if time.Since(started) >= c.config.Reconnect.StableAfter {
|
|
attempts = 0
|
|
episode = time.Now()
|
|
}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if c.ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
if !c.config.Reconnect.Enabled || !c.retryable(err) {
|
|
return err
|
|
}
|
|
previousError = err
|
|
rc := c.config.Reconnect
|
|
if rc.MaxAttempts > 0 && attempts >= rc.MaxAttempts {
|
|
return err
|
|
}
|
|
delay := retryBackoff(rc, attempts)
|
|
if rc.MaxElapsed > 0 && time.Since(episode)+delay >= rc.MaxElapsed {
|
|
return err
|
|
}
|
|
attempts++
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-runctx.Done():
|
|
timer.Stop()
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return nil
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func retryBackoff(cfg ReconnectConfig, attempt int) time.Duration {
|
|
d := cfg.MinBackoff
|
|
for i := 0; i < attempt && d < cfg.MaxBackoff; i++ {
|
|
if d > cfg.MaxBackoff/2 {
|
|
d = cfg.MaxBackoff
|
|
} else {
|
|
d *= 2
|
|
}
|
|
}
|
|
if d > 1 {
|
|
d = d/2 + time.Duration(rand.Int63n(int64(d-d/2))+1)
|
|
}
|
|
return d
|
|
}
|
|
func (c *Client) retryable(err error) bool {
|
|
if err == nil || errors.Is(err, ErrClosed) || errors.Is(err, context.Canceled) {
|
|
return false
|
|
}
|
|
if c.config.ShouldReconnect != nil {
|
|
return c.config.ShouldReconnect(err)
|
|
}
|
|
var handshake *HandshakeError
|
|
if errors.As(err, &handshake) {
|
|
switch handshake.StatusCode {
|
|
case 429, 500, 502, 503, 504:
|
|
return true
|
|
case 0:
|
|
default:
|
|
return false
|
|
}
|
|
if errors.Is(err, websocket.ErrBadHandshake) {
|
|
return false
|
|
}
|
|
}
|
|
var unknown x509.UnknownAuthorityError
|
|
var invalid x509.CertificateInvalidError
|
|
var hostname x509.HostnameError
|
|
if errors.As(err, &unknown) || errors.As(err, &invalid) || errors.As(err, &hostname) {
|
|
return false
|
|
}
|
|
var closeError *CloseError
|
|
if errors.As(err, &closeError) {
|
|
switch closeError.Code {
|
|
case 1001, 1006, 1011, 1012, 1013:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
var network net.Error
|
|
if errors.As(err, &network) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
return true
|
|
}
|
|
return handshake != nil || errors.Is(err, ErrNotConnected)
|
|
}
|
|
|
|
// Close is idempotent and nonblocking: it rejects new work, cancels handshakes
|
|
// and reconnect waits, and requests session close. Drain waits for all work,
|
|
// including user callbacks. A callback that ignores cancellation cannot be killed.
|
|
func (c *Client) Close() error {
|
|
c.mu.Lock()
|
|
if c.closed {
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
c.closed = true
|
|
sessions := make([]*Session, 0, len(c.sessions))
|
|
for _, s := range c.sessions {
|
|
sessions = append(sessions, s)
|
|
}
|
|
if c.active == 0 {
|
|
close(c.done)
|
|
}
|
|
c.mu.Unlock()
|
|
c.cancel()
|
|
for _, s := range sessions {
|
|
_ = s.Close(1001, "client shutdown")
|
|
}
|
|
return nil
|
|
}
|
|
func (c *Client) Stop(context.Context) error { return c.Close() }
|
|
func (c *Client) Drain(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return errors.New("ws: nil context")
|
|
}
|
|
select {
|
|
case <-c.done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
c.mu.Lock()
|
|
sessions := make([]*Session, 0, len(c.sessions))
|
|
for _, s := range c.sessions {
|
|
sessions = append(sessions, s)
|
|
}
|
|
c.mu.Unlock()
|
|
for _, s := range sessions {
|
|
s.Abort()
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
func (c *Client) Shutdown(ctx context.Context) error { _ = c.Close(); return c.Drain(ctx) }
|
|
|
|
type ClientStats struct {
|
|
ActiveConnections int
|
|
Handshakes int
|
|
Running int
|
|
Closed bool
|
|
// DialAttempts includes admitted attempts canceled before the HTTP handshake.
|
|
DialAttempts uint64
|
|
HandshakeFailures uint64
|
|
// Reconnects counts subsequent Dial attempts made by Run, successful or not.
|
|
Reconnects uint64
|
|
}
|
|
|
|
func (c *Client) Stats() ClientStats {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return ClientStats{ActiveConnections: len(c.sessions), Handshakes: c.connections - len(c.sessions), Running: c.active - c.connections, Closed: c.closed, DialAttempts: c.dialAttempts, HandshakeFailures: c.handshakeFailures, Reconnects: c.reconnects}
|
|
}
|