Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions backend/src/main/java/com/game/websocket/GameWebSocketHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ public class GameWebSocketHandler extends TextWebSocketHandler {
// Session id -> username, so afterConnectionClosed can clean up without
// relying on session attributes surviving an abrupt close
private final Map<String, String> usernameBySessionId = new ConcurrentHashMap<>();
// Session id -> per-page-load tab id, so a rename-adopting reconnect from
// the same tab can be told apart from a genuine duplicate tab
private final Map<String, String> tabBySessionId = new ConcurrentHashMap<>();
// Username -> earliest eviction time; entries are cancelled by a reconnect
private final Map<String, Long> pendingEvictions = new ConcurrentHashMap<>();
// Per-room ordering for join notifications: without it, two simultaneous
Expand All @@ -76,6 +79,8 @@ public void afterConnectionEstablished(WebSocketSession rawSession) throws Excep
}
String username = URLDecoder.decode(rawUsername, StandardCharsets.UTF_8);
boolean isStatus = "status".equals(query.get("type"));
String rawTab = query.get("tab");
String tabId = (rawTab == null || rawTab.isEmpty()) ? "" : URLDecoder.decode(rawTab, StandardCharsets.UTF_8);

WebSocketSession session = new ConcurrentWebSocketSessionDecorator(
rawSession, SEND_TIME_LIMIT_MS, BUFFER_SIZE_LIMIT_BYTES);
Expand All @@ -87,17 +92,34 @@ public void afterConnectionEstablished(WebSocketSession rawSession) throws Excep
// refresh's old session is already closed, so it keeps its name).
WebSocketSession existing = playersByUsername.get(username);
if (existing != null && existing.isOpen() && !existing.getId().equals(session.getId())) {
String candidate;
do {
candidate = username + "-" + randomSuffix();
} while (playersByUsername.containsKey(candidate));
logger.info("Username {} already live in another tab, assigning {}", username, candidate);
username = candidate;
String existingTabId = tabBySessionId.get(existing.getId());
if (!tabId.isEmpty() && tabId.equals(existingTabId)) {
// Same tab reconnecting (e.g. right after adopting a
// rename): the old socket just hasn't been torn down yet.
// Keep the name and drop the stale session instead of
// renaming again - closeQuietly below runs before the new
// session is registered, so afterConnectionClosed's
// "still tracked under this id" check still sees the
// stale session as owner and doesn't touch our new one.
logger.info("Username {} reconnecting from same tab {}, dropping stale session", username, tabId);
closeQuietly(existing);
} else {
// Cap growth so repeated duplication can't produce an
// unbounded name
String base = username.length() > 20 ? username.substring(0, 20) : username;
String candidate;
do {
candidate = base + "-" + randomSuffix();
} while (playersByUsername.containsKey(candidate));
logger.info("Username {} already live in another tab, assigning {}", username, candidate);
username = candidate;
}
}
}

lastSeen.put(session.getId(), System.currentTimeMillis());
usernameBySessionId.put(session.getId(), username);
tabBySessionId.put(session.getId(), tabId);
if (isStatus) {
statusSessions.add(session);
} else {
Expand Down Expand Up @@ -219,6 +241,7 @@ private void handleMove(WebSocketSession session, String username, Map<String, O
public void afterConnectionClosed(WebSocketSession rawSession, CloseStatus status) {
String sessionId = rawSession.getId();
String username = usernameBySessionId.remove(sessionId);
tabBySessionId.remove(sessionId);
lastSeen.remove(sessionId);
statusSessions.removeIf(s -> s.getId().equals(sessionId));

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/UI/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,13 @@ const Username = styled.span`
font-weight: ${theme.typography.body.fontWeight};
color: ${theme.colors.muted};
font-style: italic;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 768px) {
margin-top: ${theme.spacing.xs};
max-width: 80vw;
}
`;

Expand Down
6 changes: 5 additions & 1 deletion frontend/src/utils/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class WebSocketService {
this.livenessTimer = null;
this.lastMessageAt = null;
this.pendingAcks = new Map(); // moveId -> { resolve, reject, timeoutId }
// Deliberately in-memory only (per page load): a reconnect of the same
// page keeps this id, while a duplicated tab (which clones sessionStorage)
// gets a fresh one - lets the server tell the two cases apart.
this.tabId = crypto.randomUUID();
}

get connected() {
Expand All @@ -43,7 +47,7 @@ class WebSocketService {
const base = isProd
? `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}`
: 'ws://localhost:8080';
return `${base}/ws?username=${this.username}`;
return `${base}/ws?username=${this.username}&tab=${this.tabId}`;
}

_openSocket() {
Expand Down
Loading