qkeeper/hub.go
Aleksandr Berkuta 5806fe84c4 init commit
2026-03-27 20:27:56 +03:00

258 lines
5.4 KiB
Go

package main
import (
"encoding/json"
"net/http"
"sync/atomic"
"github.com/gorilla/websocket"
)
// ---- WebSocket hub ----
// Hub serialises all queue mutations through a single goroutine (run).
// This eliminates data races without requiring a queue-level mutex.
type Hub struct {
clients map[*Client]bool
registerC chan *Client
unregisterC chan *Client
broadcastC chan []byte // pre-serialised JSON pushed to all clients
dispatchC chan dispatch // inbound client messages
timerC chan string // entry IDs for 60s-timer-triggered removal
stopC chan struct{}
activeCount atomic.Int32 // len(q.Entries); read by Registry.List without locking
}
type dispatch struct {
c *Client // nil for timer-triggered removals
payload []byte
}
func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
registerC: make(chan *Client, 16),
unregisterC: make(chan *Client, 16),
broadcastC: make(chan []byte, 256),
dispatchC: make(chan dispatch, 256),
timerC: make(chan string, 64),
stopC: make(chan struct{}),
}
}
// shutdown signals the hub goroutine to exit.
func (h *Hub) shutdown() {
close(h.stopC)
}
// run is the single goroutine that owns all hub and queue state.
func (h *Hub) run(q *QueueState, reg *Registry, queueID string) {
for {
select {
case <-h.stopC:
return
case c := <-h.registerC:
h.clients[c] = true
case c := <-h.unregisterC:
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
}
case msg := <-h.broadcastC:
for c := range h.clients {
select {
case c.send <- msg:
default:
// Slow client: drop and disconnect.
delete(h.clients, c)
close(c.send)
}
}
case d := <-h.dispatchC:
h.handleMessage(d.c, d.payload, q, reg, queueID)
case entryID := <-h.timerC:
if q.timerRemove(entryID) {
h.pushState(q)
}
}
}
}
// pushState broadcasts the full current queue state to all clients.
func (h *Hub) pushState(q *QueueState) {
h.activeCount.Store(int32(len(q.Entries)))
b := q.stateJSON()
select {
case h.broadcastC <- b:
default:
}
}
// sendTo sends a JSON-serialisable value to one client.
func (h *Hub) sendTo(c *Client, v interface{}) {
b, err := json.Marshal(v)
if err != nil {
return
}
select {
case c.send <- b:
default:
}
}
// ---- WebSocket client ----
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
token string // set after init/join; used for all subsequent actions
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func (h *Hub) wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
c := &Client{
hub: h,
conn: conn,
send: make(chan []byte, 64),
}
h.registerC <- c
go c.writePump()
c.readPump()
}
func (c *Client) readPump() {
defer func() {
c.hub.unregisterC <- c
c.conn.Close()
}()
c.conn.SetReadLimit(4096)
for {
_, raw, err := c.conn.ReadMessage()
if err != nil {
return
}
c.hub.dispatchC <- dispatch{c: c, payload: raw}
}
}
func (c *Client) writePump() {
defer c.conn.Close()
for msg := range c.send {
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
// ---- Message handling ----
type inMsg struct {
Type string `json:"type"`
Token string `json:"token,omitempty"`
Name string `json:"name,omitempty"`
EntryID string `json:"entryId,omitempty"`
}
func (h *Hub) handleMessage(c *Client, raw []byte, q *QueueState, reg *Registry, queueID string) {
var msg inMsg
if err := json.Unmarshal(raw, &msg); err != nil {
return
}
switch msg.Type {
case "init":
// Client sends their stored token (empty string if none).
c.token = msg.Token
var entryID interface{} // nil → JSON null
if msg.Token != "" {
if e := q.findByToken(msg.Token); e != nil {
entryID = e.ID
} else {
c.token = "" // stale token
}
}
h.sendTo(c, map[string]interface{}{"type": "hello", "yourEntryId": entryID})
// Send current state so client renders queue immediately.
h.sendTo(c, json.RawMessage(q.stateJSON()))
case "join":
// Anti-spam: reject if this client already has an active entry.
if c.token != "" {
if e := q.findByToken(c.token); e != nil {
h.sendTo(c, map[string]interface{}{"type": "hello", "yourEntryId": e.ID})
return
}
}
e, token := q.join(msg.Name)
c.token = token
h.sendTo(c, map[string]interface{}{
"type": "joined",
"yourToken": token,
"yourEntryId": e.ID,
})
h.pushState(q)
reg.Touch(queueID)
case "done":
if c.token == "" {
return
}
if q.selfRemove(c.token) {
c.token = ""
h.pushState(q)
}
case "mark_done":
if c.token == "" || msg.EntryID == "" {
return
}
actor := q.findByToken(c.token)
if actor == nil {
return
}
if q.markDone(actor.Number, msg.EntryID, h.timerC) {
h.pushState(q)
}
case "vote_remove":
if c.token == "" || msg.EntryID == "" {
return
}
actor := q.findByToken(c.token)
if actor == nil {
return
}
switch q.voteRemove(actor.Number, msg.EntryID, h.timerC) {
case "ok":
h.pushState(q)
case "already_voting":
onNum := q.findVoteTarget(actor.Number)
h.sendTo(c, map[string]interface{}{
"type": "error", "reason": "already_voting", "onNumber": onNum,
})
}
case "restore":
if c.token == "" {
return
}
if q.restore(c.token) {
h.pushState(q)
}
}
}