fix: reject duplicate WebSocket logins to prevent reconnect ping-pong

Server now checks IsUserConnected before accepting a new WebSocket and
returns an auth_error with a clear message instead of silently replacing
the old session. Client dispatcher surfaces the error via transient UI
state so the ConnectPage can display it.
This commit is contained in:
jevb
2026-03-18 02:41:55 +01:00
parent 6f35973c1b
commit b53c729f89
4 changed files with 28 additions and 23 deletions
@@ -4,6 +4,7 @@
import type { WsClient } from "./ws";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { setTransientError } from "@stores/ui.store";
import {
setChannels,
setActiveChannel,
@@ -67,6 +68,7 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
unsubs.push(
ws.on("auth_error", (payload) => {
log.error("Auth failed", { message: payload.message });
setTransientError(payload.message);
clearAuth();
}),
);
+8 -1
View File
@@ -9,7 +9,7 @@ import {
qs,
} from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import { openSettings, closeSettings } from "@stores/ui.store";
import { openSettings, closeSettings, uiStore, setTransientError } from "@stores/ui.store";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import type { HealthStatus, ServerProfile } from "@lib/profiles";
import { loadCredential } from "@lib/credentials";
@@ -788,6 +788,13 @@ export function createConnectPage(
});
settingsOverlay.mount(rootEl);
// Show any pending auth error (e.g. "already connected from another client")
const pendingError = uiStore.getState().transientError;
if (pendingError) {
transitionTo("error", pendingError);
setTransientError(null);
}
// Focus the first input
hostInput.focus();
}
+9 -22
View File
@@ -161,28 +161,6 @@ func (h *Hub) Run() {
case c := <-h.register:
h.mu.Lock()
// If an existing client has the same userID, close its send channel
// so writePump exits cleanly before the new client takes over.
// Also clean up any voice state the old client held.
if old, ok := h.clients[c.userID]; ok && old != c {
slog.Info("hub: replacing existing client", "user_id", c.userID)
oldChID, oldPC := old.clearVoice()
if oldPC != nil {
_ = oldPC.Close()
}
if oldChID > 0 {
if room := h.GetVoiceRoom(oldChID); room != nil {
room.RemoveParticipant(old.userID)
if room.IsEmpty() {
h.voiceRoomsMu.Lock()
delete(h.voiceRooms, oldChID)
h.voiceRoomsMu.Unlock()
}
}
_ = h.db.LeaveVoiceChannel(old.userID)
}
old.closeSend()
}
h.clients[c.userID] = c
slog.Info("hub: client registered", "user_id", c.userID, "total_clients", len(h.clients))
h.mu.Unlock()
@@ -255,6 +233,15 @@ func (h *Hub) CleanupVoiceForChannel(channelID int64) {
}
}
// IsUserConnected returns true if a client with the given userID is already
// registered in the hub. Safe to call from any goroutine.
func (h *Hub) IsUserConnected(userID int64) bool {
h.mu.RLock()
_, ok := h.clients[userID]
h.mu.RUnlock()
return ok
}
// Register queues a client for registration with the hub.
func (h *Hub) Register(c *Client) {
h.register <- c
+9
View File
@@ -43,6 +43,15 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
return
}
// Reject duplicate connections — prevent ping-pong reconnect loops.
if hub.IsUserConnected(user.ID) {
slog.Warn("ws duplicate login rejected", "user_id", user.ID, "remote", r.RemoteAddr)
ctx := r.Context()
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("already connected from another client"))
_ = conn.Close(websocket.StatusPolicyViolation, "already connected")
return
}
c := newClient(hub, conn, user, tokenHash)
hub.Register(c)