veza/veza-backend-api/internal/websocket/chat/hub.go
senke 196219f745 fix(backend): synchronous Hub.Shutdown to eliminate goleak failures
The chat Hub's Shutdown() only closed the done channel and returned
immediately, racing against goleak.VerifyNone in TestHub_*. Worse, the
broadcast saturation path spawned a fire-and-forget goroutine to send
on the unregister channel, which could leak if Run() exited mid-flight.

Fix:
- Add `stopped` channel closed by Run() on exit; Shutdown() waits on it.
- Buffer `unregister` (256) and replace the anonymous goroutine with a
  non-blocking select. Worst case the client is reaped on its next
  failed broadcast attempt.
- handler_messages_test.go's setupTestHandler started a Hub but never
  shut it down, leaking Run() goroutines into the hub_test.go run that
  followed. Register t.Cleanup(hub.Shutdown) and close the gorm sqlite
  connection too — the connectionOpener goroutine was the secondary leak.
2026-04-14 11:44:27 +02:00

223 lines
4.8 KiB
Go

package chat
import (
"context"
"sync"
"github.com/google/uuid"
"go.uber.org/zap"
)
type Hub struct {
clients map[*Client]bool
rooms map[uuid.UUID]map[*Client]bool
userIndex map[uuid.UUID][]*Client
register chan *Client
unregister chan *Client
broadcast chan *RoomBroadcast
done chan struct{} // TASK-DEBT-008: lifecycle - close to stop Run()
stopped chan struct{} // closed by Run() on exit; Shutdown waits on it
mu sync.RWMutex
logger *zap.Logger
presenceService *ChatPresenceService
}
type RoomBroadcast struct {
RoomID uuid.UUID
Data []byte
Exclude *Client
}
func NewHub(logger *zap.Logger, presenceService *ChatPresenceService) *Hub {
if logger == nil {
logger = zap.NewNop()
}
return &Hub{
clients: make(map[*Client]bool),
rooms: make(map[uuid.UUID]map[*Client]bool),
userIndex: make(map[uuid.UUID][]*Client),
register: make(chan *Client),
unregister: make(chan *Client, 256),
broadcast: make(chan *RoomBroadcast, 256),
done: make(chan struct{}),
stopped: make(chan struct{}),
logger: logger,
presenceService: presenceService,
}
}
func (h *Hub) Run() {
defer close(h.stopped)
for {
select {
case <-h.done:
return
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.userIndex[client.UserID] = append(h.userIndex[client.UserID], client)
h.mu.Unlock()
if h.presenceService != nil {
_ = h.presenceService.SetOnline(context.Background(), client.UserID)
}
h.logger.Info("Client registered",
zap.String("user_id", client.UserID.String()))
case client := <-h.unregister:
h.mu.Lock()
userOffline := false
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
for roomID, members := range h.rooms {
delete(members, client)
if len(members) == 0 {
delete(h.rooms, roomID)
}
}
clients := h.userIndex[client.UserID]
for i, c := range clients {
if c == client {
h.userIndex[client.UserID] = append(clients[:i], clients[i+1:]...)
break
}
}
if len(h.userIndex[client.UserID]) == 0 {
delete(h.userIndex, client.UserID)
userOffline = true
}
}
h.mu.Unlock()
if h.presenceService != nil && userOffline {
_ = h.presenceService.SetOffline(context.Background(), client.UserID)
}
h.logger.Info("Client unregistered",
zap.String("user_id", client.UserID.String()))
case msg := <-h.broadcast:
h.mu.RLock()
members, ok := h.rooms[msg.RoomID]
if ok {
for client := range members {
if msg.Exclude != nil && client == msg.Exclude {
continue
}
select {
case client.send <- msg.Data:
default:
// best-effort cleanup; if unregister is full, the client
// will be reaped on its next failed broadcast attempt
select {
case h.unregister <- client:
default:
}
}
}
}
h.mu.RUnlock()
}
}
}
func (h *Hub) JoinRoom(client *Client, roomID uuid.UUID) {
h.mu.Lock()
defer h.mu.Unlock()
if h.rooms[roomID] == nil {
h.rooms[roomID] = make(map[*Client]bool)
}
h.rooms[roomID][client] = true
}
func (h *Hub) LeaveRoom(client *Client, roomID uuid.UUID) {
h.mu.Lock()
defer h.mu.Unlock()
if members, ok := h.rooms[roomID]; ok {
delete(members, client)
if len(members) == 0 {
delete(h.rooms, roomID)
}
}
}
func (h *Hub) BroadcastToRoom(roomID uuid.UUID, data []byte, exclude *Client) {
h.broadcast <- &RoomBroadcast{
RoomID: roomID,
Data: data,
Exclude: exclude,
}
}
func (h *Hub) SendToUser(userID uuid.UUID, data []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
clients, ok := h.userIndex[userID]
if !ok {
return
}
for _, client := range clients {
select {
case client.send <- data:
default:
}
}
}
func (h *Hub) SendToClient(client *Client, data []byte) {
select {
case client.send <- data:
default:
}
}
func (h *Hub) Register(client *Client) {
h.register <- client
}
func (h *Hub) Unregister(client *Client) {
h.unregister <- client
}
func (h *Hub) IsUserOnline(userID uuid.UUID) bool {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.userIndex[userID]) > 0
}
func (h *Hub) GetRoomMembers(roomID uuid.UUID) []*Client {
h.mu.RLock()
defer h.mu.RUnlock()
members, ok := h.rooms[roomID]
if !ok {
return nil
}
result := make([]*Client, 0, len(members))
for client := range members {
result = append(result, client)
}
return result
}
func (h *Hub) GetConnectedUsersCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.userIndex)
}
// Shutdown stops the Hub's Run loop and waits for it to exit (TASK-DEBT-008: goroutine lifecycle).
// Synchronous so goleak does not race against an in-flight Run() goroutine.
func (h *Hub) Shutdown() {
close(h.done)
<-h.stopped
}