// Package hub manages the in-memory WebSocket connection pool for push-gateway. // // Hub is the single process-wide registry of all live WebSocket connections, // indexed by userID -> connID -> *Connection. Each Connection owns a send // channel consumed by a dedicated writer goroutine; Hub exposes Register / // Unregister / SendToUser / Broadcast operations. // // The Hub also tracks per-user connection counts to enforce the // MaxConnectionsPerUser limit (default 5) and provides CloseAll for graceful // shutdown. Cross-instance presence and message fanout are delegated to a // RedisClient (see internal/redis), keeping Hub free of any persistence layer. package hub import ( "errors" "sync" "time" "github.com/gorilla/websocket" "github.com/google/uuid" ) // ErrTooManyConnections is returned by Register when the per-user connection // cap has been reached. Callers should translate this into a 429 / // PUSH_TOO_MANY_CONNECTIONS response or a close frame with code 1008. var ErrTooManyConnections = errors.New("too many connections per user") // ErrHubClosing is returned by Register when the Hub is shutting down and no // new connections are accepted. var ErrHubClosing = errors.New("hub is closing") // sendBufferSize is the per-connection send channel capacity. A full channel // triggers message drop with metric increment (see Send). const sendBufferSize = 64 // Connection wraps a single WebSocket connection together with its asynchronous // send channel and lifecycle metadata. type Connection struct { UserID string ConnID string conn *websocket.Conn send chan []byte closeOnce sync.Once closed bool mu sync.Mutex lastPing time.Time } // Send delivers message to the connection's writer goroutine. When the send // channel is full the message is dropped (non-blocking) and the caller is // notified via the returned false value so it can increment the // push_gateway_messages_dropped_total{reason="channel_full"} metric. func (c *Connection) Send(message []byte) bool { c.mu.Lock() defer c.mu.Unlock() if c.closed { return false } select { case c.send <- message: return true default: return false } } // Outgoing exposes the send channel for the writer goroutine. The channel is // closed by Close, terminating the consumer loop. func (c *Connection) Outgoing() <-chan []byte { return c.send } // Close shuts down the connection's send channel exactly once. Subsequent // Send calls become no-ops. func (c *Connection) Close() { c.closeOnce.Do(func() { c.mu.Lock() c.closed = true c.mu.Unlock() close(c.send) }) } // LastPing returns the most recent heartbeat timestamp. func (c *Connection) LastPing() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.lastPing } // TouchPing updates the last heartbeat timestamp to now. func (c *Connection) TouchPing() { c.mu.Lock() defer c.mu.Unlock() c.lastPing = time.Now() } // Conn returns the underlying gorilla websocket.Conn for control-frame // operations (Ping/Pong/Close). Used by the reader goroutine. func (c *Connection) Conn() *websocket.Conn { return c.conn } // Hub is the process-wide registry of live WebSocket connections. // // clients maps userID -> connID -> *Connection so a single user may hold // multiple simultaneous connections (multi-device). counters caches the // per-user connection count to make the MaxConnectionsPerUser check O(1). type Hub struct { mu sync.RWMutex clients map[string]map[string]*Connection counters map[string]int closing bool // maxConnsPerUser caps the number of simultaneous connections per user. maxConnsPerUser int // onRegister / onUnregister are optional hooks used by the Redis presence // layer to maintain the cross-instance online SET. They are invoked outside // the Hub lock to avoid blocking the registry on Redis round-trips. onRegister func(userID, connID string) onUnregister func(userID, connID string) } // NewHub returns a Hub with the given per-user connection cap. func NewHub(maxConnsPerUser int) *Hub { if maxConnsPerUser <= 0 { maxConnsPerUser = 5 } return &Hub{ clients: make(map[string]map[string]*Connection), counters: make(map[string]int), maxConnsPerUser: maxConnsPerUser, } } // SetPresenceHooks registers callbacks invoked after Register / Unregister. // Used by the Redis presence layer to keep edu:push:online: in sync. func (h *Hub) SetPresenceHooks(onRegister, onUnregister func(userID, connID string)) { h.mu.Lock() defer h.mu.Unlock() h.onRegister = onRegister h.onUnregister = onUnregister } // ActiveConnections returns the total number of live connections across all // users. Exposed via /healthz and the push_gateway_active_connections gauge. func (h *Hub) ActiveConnections() int { h.mu.RLock() defer h.mu.RUnlock() count := 0 for _, conns := range h.clients { count += len(conns) } return count } // UserCount returns the number of distinct online users (used by /healthz). func (h *Hub) UserCount() int { h.mu.RLock() defer h.mu.RUnlock() return len(h.clients) } // HasUser reports whether at least one local connection exists for userID. // Used by the /internal/push path to decide between local delivery and Redis // Pub/Sub fanout. func (h *Hub) HasUser(userID string) bool { h.mu.RLock() defer h.mu.RUnlock() conns, ok := h.clients[userID] return ok && len(conns) > 0 } // IsClosing reports whether CloseAll has been invoked. The /readyz probe uses // this to return 503 during graceful shutdown so the load balancer stops // sending new WebSocket upgrades while existing connections drain. func (h *Hub) IsClosing() bool { h.mu.RLock() defer h.mu.RUnlock() return h.closing } // Register adds a new connection for userID. Returns ErrTooManyConnections // when the per-user cap is exceeded and ErrHubClosing when the Hub is shutting // down. The returned Connection must be Unregistered by the caller on close. func (h *Hub) Register(userID string, conn *websocket.Conn) (*Connection, error) { h.mu.Lock() if h.closing { h.mu.Unlock() return nil, ErrHubClosing } if h.counters[userID] >= h.maxConnsPerUser { h.mu.Unlock() return nil, ErrTooManyConnections } connID := uuid.NewString() c := &Connection{ UserID: userID, ConnID: connID, conn: conn, send: make(chan []byte, sendBufferSize), lastPing: time.Now(), } if h.clients[userID] == nil { h.clients[userID] = make(map[string]*Connection) } h.clients[userID][connID] = c h.counters[userID]++ onRegister := h.onRegister h.mu.Unlock() if onRegister != nil { onRegister(userID, connID) } return c, nil } // Unregister removes a connection and closes its send channel. Safe to call // multiple times for the same Connection. func (h *Hub) Unregister(c *Connection) { h.mu.Lock() conns, ok := h.clients[c.UserID] if !ok { h.mu.Unlock() return } if _, exists := conns[c.ConnID]; exists { delete(conns, c.ConnID) h.counters[c.UserID]-- if h.counters[c.UserID] <= 0 { delete(h.counters, c.UserID) } if len(conns) == 0 { delete(h.clients, c.UserID) } } onUnregister := h.onUnregister h.mu.Unlock() c.Close() if onUnregister != nil { onUnregister(c.UserID, c.ConnID) } } // SendToUser delivers message to every local connection of userID. Returns // the number of connections the message was successfully queued to. A zero // return value signals "user not online locally" so the caller can fall back // to Redis Pub/Sub fanout. func (h *Hub) SendToUser(userID string, message []byte) int { h.mu.RLock() conns := h.clients[userID] // Snapshot the slice under the read lock to avoid holding it during Send. snapshot := make([]*Connection, 0, len(conns)) for _, c := range conns { snapshot = append(snapshot, c) } h.mu.RUnlock() delivered := 0 for _, c := range snapshot { if c.Send(message) { delivered++ } } return delivered } // Broadcast delivers message to every live connection in this instance. // Cross-instance broadcast is handled separately via Redis Pub/Sub. func (h *Hub) Broadcast(message []byte) int { h.mu.RLock() // Count inline: calling ActiveConnections() here would re-enter RLock // while we already hold it, which can deadlock if a writer is waiting. total := 0 for _, conns := range h.clients { total += len(conns) } snapshot := make([]*Connection, 0, total) for _, conns := range h.clients { for _, c := range conns { snapshot = append(snapshot, c) } } h.mu.RUnlock() delivered := 0 for _, c := range snapshot { if c.Send(message) { delivered++ } } return delivered } // CloseAll marks the Hub as closing (rejecting further Register calls) and // sends a close frame to every live connection. Used during graceful shutdown. // The caller should wait for connections to drain after CloseAll returns. func (h *Hub) CloseAll() { h.mu.Lock() h.closing = true // Count inline instead of calling ActiveConnections() to avoid reentrant // RLock while holding the write lock (Go's sync.RWMutex is non-reentrant). total := 0 for _, conns := range h.clients { total += len(conns) } all := make([]*Connection, 0, total) for _, conns := range h.clients { for _, c := range conns { all = append(all, c) } } h.mu.Unlock() for _, c := range all { // Send a graceful close frame (1001 going away) and close the send // channel so the writer goroutine exits. _ = c.conn.WriteControl( websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down"), time.Now().Add(2*time.Second), ) c.Close() } } // ForEachUser iterates over all locally online userIDs. Used by the Redis // presence layer to rebuild the online SET on startup (ISSUE-058). func (h *Hub) ForEachUser(fn func(userID string)) { h.mu.RLock() defer h.mu.RUnlock() for userID := range h.clients { fn(userID) } }