Log messages are lower-case event names with their details as key/value
attributes ("error" for errors), in place of formatted strings.
Loggable package take a WithLogger option as part of their "New*" call,
defaulting to slog.Default(). Each tags its lines with its `component`.
Some debug loggers print directly rather than using slog when in
terminal logging mode.
182 lines
4.3 KiB
Go
182 lines
4.3 KiB
Go
package websocket
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"sync"
|
|
|
|
ws "github.com/coder/websocket"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/config"
|
|
)
|
|
|
|
// Hub tracks connected clients, WebSocket (ServeHTTP) and plain TCP
|
|
// (ServeTCP) alike, and sends pushed messages to all of them.
|
|
type Hub struct {
|
|
conf config.JSCServerWebSocket
|
|
log *slog.Logger
|
|
|
|
m sync.RWMutex
|
|
clients map[*client]struct{}
|
|
closed bool
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// defaultMaxMessageSize is the largest message read from a client when
|
|
// max_message_size is not set.
|
|
const defaultMaxMessageSize = 32 << 10
|
|
|
|
// Option configures a Hub.
|
|
type Option func(*Hub)
|
|
|
|
// WithLogger makes the hub log to logger instead of slog.Default().
|
|
func WithLogger(logger *slog.Logger) Option {
|
|
return func(h *Hub) {
|
|
h.log = logger
|
|
}
|
|
}
|
|
|
|
// NewHub creates a hub. It logs to slog.Default() unless given WithLogger.
|
|
func NewHub(conf config.JSCServerWebSocket, opts ...Option) *Hub {
|
|
hub := &Hub{
|
|
conf: conf,
|
|
clients: make(map[*client]struct{}),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(hub)
|
|
}
|
|
return hub
|
|
}
|
|
|
|
// logger returns the logger the hub logs to, tagged with its component.
|
|
func (h *Hub) logger() *slog.Logger {
|
|
log := h.log
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return log.With("component", "ws")
|
|
}
|
|
|
|
// Push sends a message of type msgType to every client. The payload is
|
|
// marshalled once and the same bytes are queued for every client; a client
|
|
// whose queue is full is closed as a slow consumer. A payload that can't be
|
|
// marshalled is logged and nothing is sent.
|
|
func (h *Hub) Push(msgType string, payload any) {
|
|
var rawPayload json.RawMessage
|
|
if payload != nil {
|
|
var err error
|
|
if rawPayload, err = json.Marshal(payload); err != nil {
|
|
h.logger().Error("marshal push payload", "type", msgType, "error", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
msg, err := json.Marshal(Envelope{
|
|
Type: msgType,
|
|
Payload: rawPayload,
|
|
})
|
|
if err != nil {
|
|
h.logger().Error("marshal push", "type", msgType, "error", err)
|
|
return
|
|
}
|
|
|
|
var slow []*client
|
|
h.m.RLock()
|
|
for client := range h.clients {
|
|
if !client.trySend(msg) {
|
|
slow = append(slow, client)
|
|
}
|
|
}
|
|
h.m.RUnlock()
|
|
|
|
for _, client := range slow {
|
|
h.logger().Warn("dropping slow client", "client", client.id, "ip", client.ip)
|
|
go client.close(ws.StatusPolicyViolation, "slow consumer")
|
|
}
|
|
}
|
|
|
|
// Count returns the number of connected clients.
|
|
func (h *Hub) Count() int {
|
|
h.m.RLock()
|
|
defer h.m.RUnlock()
|
|
return len(h.clients)
|
|
}
|
|
|
|
// Shutdown closes every connection with 1001 (going away) and waits for the
|
|
// handlers to return. If ctx expires first, the remaining connections are
|
|
// closed at once and ctx's error is returned. New connections are refused
|
|
// afterwards.
|
|
func (h *Hub) Shutdown(ctx context.Context) error {
|
|
h.m.Lock()
|
|
h.closed = true
|
|
clientShutdowns := make([]*client, 0, len(h.clients))
|
|
for client := range h.clients {
|
|
clientShutdowns = append(clientShutdowns, client)
|
|
}
|
|
h.m.Unlock()
|
|
|
|
if len(clientShutdowns) == 0 {
|
|
// Nothing to close. Clients that just left are only finishing their
|
|
// unregister, so this wait is short and ctx doesn't apply.
|
|
h.wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
for _, client := range clientShutdowns {
|
|
go client.close(ws.StatusGoingAway, "server shutting down")
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
h.wg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
// Both may be ready; a finished shutdown is not a timeout.
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
default:
|
|
}
|
|
for _, client := range clientShutdowns {
|
|
client.conn.CloseNow() //nolint:errcheck,gosec // out of time; dropping it
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// maxMessageSize is the largest message read from a client: max_message_size,
|
|
// or defaultMaxMessageSize if that is 0 or less.
|
|
func (h *Hub) maxMessageSize() int {
|
|
if h.conf.MaxMessageSize <= 0 {
|
|
return defaultMaxMessageSize
|
|
}
|
|
return int(h.conf.MaxMessageSize)
|
|
}
|
|
|
|
// register adds a client to the hub. Hub clients receive all push messages and
|
|
// have their lifecycles tracked by the hub.
|
|
func (h *Hub) register(c *client) bool {
|
|
h.m.Lock()
|
|
defer h.m.Unlock()
|
|
if h.closed {
|
|
return false
|
|
}
|
|
h.clients[c] = struct{}{}
|
|
h.wg.Add(1)
|
|
return true
|
|
}
|
|
|
|
// unregister removes a client from the hub.
|
|
func (h *Hub) unregister(c *client) {
|
|
h.m.Lock()
|
|
delete(h.clients, c)
|
|
h.m.Unlock()
|
|
h.wg.Done()
|
|
}
|