diff --git a/Client/tauri-client/src/lib/dispatcher.ts b/Client/tauri-client/src/lib/dispatcher.ts index a8b07d4a..57f60785 100644 --- a/Client/tauri-client/src/lib/dispatcher.ts +++ b/Client/tauri-client/src/lib/dispatcher.ts @@ -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(); }), ); diff --git a/Client/tauri-client/src/pages/ConnectPage.ts b/Client/tauri-client/src/pages/ConnectPage.ts index 8ab092da..9d110342 100644 --- a/Client/tauri-client/src/pages/ConnectPage.ts +++ b/Client/tauri-client/src/pages/ConnectPage.ts @@ -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(); } diff --git a/Server/ws/hub.go b/Server/ws/hub.go index 2fbdf1ee..041a5739 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -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 diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 4c93224e..3eefb8e6 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -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)