Fix custom server reconnection and reconnect strategy

- Fix: CONNECT/RETRY_CONNECT always force-disconnect before reconnecting
  (prevents JOIN_ROOM going to official server when custom is selected)
- Add forceDisconnect() helper: clears socket, eventQueue, episodeLobby,
  expectedAcksCount, broadcast status, persists cleanup to storage
- Fix: save useCustomServer to storage on Join Room click (no race condition)
- Fix: trigger RETRY_CONNECT on server mode toggle and custom URL change
- Fix: show error when Custom selected but no URL entered (no silent fallback)
- Fix: button label changed to 'Join / Create Room' in all 6 locales
- Rewrite scheduleReconnect() for two-phase strategy:
  Phase 1 (aggressive): 500ms-5s backoff, max 20 attempts or 5 minutes
  Phase 2 (slow): retry every 5 minutes indefinitely, never give up
- Persist reconnectAttempts/reconnectStartTime/reconnectFailed to session storage
- Remove 'reconnect_failed' status; slow mode shows as 'reconnecting' with retry button
- Add scheduleReconnect() call on offline path to continue retry cycle
This commit is contained in:
Timo
2026-06-01 13:06:23 +02:00
parent 2b5da0dbb7
commit 8cc622bda0
8 changed files with 107 additions and 75 deletions
+72 -58
View File
@@ -55,7 +55,7 @@ function ensureState() {
chrome.storage.session.get([
'logs', 'history', 'currentRoom', 'lastActionState',
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'currentTabId', 'currentTabTitle',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount'
], (data) => {
clearTimeout(storageTimeout);
@@ -79,6 +79,7 @@ function ensureState() {
}
if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed;
if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime;
if (data.reconnectAttempts !== undefined) reconnectAttempts = data.reconnectAttempts;
// Recover Force Sync Timeout
if (data.forceSyncDeadline) {
@@ -271,6 +272,50 @@ function addLog(message, type = 'info') {
}
// --- WebSocket Client ---
function forceDisconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (episodeLobbyTimeout) {
clearTimeout(episodeLobbyTimeout);
episodeLobbyTimeout = null;
}
episodeLobby = null;
if (forceSyncTimeout) {
clearTimeout(forceSyncTimeout);
forceSyncTimeout = null;
}
if (socket) {
socket.onopen = null;
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
socket.close();
socket = null;
}
isConnecting = false;
isNamespaceJoined = false;
isForceSyncInitiator = false;
expectedAcksCount = 0;
forceSyncAcks.clear();
eventQueue = [];
chrome.storage.session.set({
isForceSyncInitiator: false,
forceSyncAcks: [],
forceSyncDeadline: null,
expectedAcksCount: 0,
eventQueue: [],
episodeLobby: null
}).catch(() => {});
if (currentRoom) {
currentRoom.peers = [];
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
}
async function connect() {
if (isConnecting) return;
isConnecting = true;
@@ -303,11 +348,7 @@ async function connect() {
addLog('Browser is offline. Waiting...', 'warn');
broadcastConnectionStatus('offline');
isConnecting = false;
return;
}
if (reconnectFailed) {
isConnecting = false;
scheduleReconnect();
return;
}
@@ -353,10 +394,10 @@ async function connect() {
// --- Phase 5: Event Listeners ---
socket.onopen = () => {
reconnectAttempts = 0;
addLog('WebSocket Connection Opened', 'success');
reconnectStartTime = null;
reconnectFailed = false;
chrome.storage.session.set({ reconnectFailed: false });
addLog('WebSocket Connection Opened', 'success');
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }).catch(() => {});
isNamespaceJoined = false;
socket.send('40');
};
@@ -452,13 +493,10 @@ function broadcastConnectionStatus(status) {
function updateBadgeStatus() {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
const isReconnecting = !isConnected && reconnectAttempts > 0;
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
if (reconnectFailed) {
chrome.action.setBadgeText({ text: 'ERR' });
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
} else if (status === 'reconnecting') {
if (status === 'reconnecting') {
chrome.action.setBadgeText({ text: '...' });
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
} else if (status === 'connecting') {
@@ -517,36 +555,27 @@ function showNotification(senderName, action) {
function scheduleReconnect() {
if (reconnectTimer) return;
if (reconnectFailed) {
return;
}
if (!reconnectStartTime) reconnectStartTime = Date.now();
// Check 5 minute cap (300,000ms)
if (Date.now() - reconnectStartTime > 300000) {
reconnectFailed = true;
chrome.storage.session.set({ reconnectFailed: true });
addLog('Reconnection failed after 5 minutes.', 'error');
broadcastConnectionStatus('reconnect_failed');
return;
}
const elapsed = Date.now() - reconnectStartTime;
reconnectAttempts++;
// Cap at max attempts to prevent infinite loops
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
if (!reconnectFailed && (elapsed > 300000 || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
reconnectFailed = true;
chrome.storage.session.set({ reconnectFailed: true });
addLog('Reconnection failed after max attempts.', 'error');
broadcastConnectionStatus('reconnect_failed');
return;
addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn');
}
// Aggressive reconnect: 500ms base, cap at 5s, no exponential growth beyond that
const delay = Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
const delay = reconnectFailed
? 300000
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
if (reconnectFailed) {
addLog(`Slow reconnect in 5min (attempt ${reconnectAttempts})`, 'info');
} else {
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
}
chrome.storage.session.set({ reconnectFailed, reconnectAttempts, reconnectStartTime }).catch(() => {});
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
@@ -1196,42 +1225,26 @@ async function handleAsyncMessage(message, sender, sendResponse) {
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false });
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
const settings = await getSettings();
if (settings.roomId) {
leaveOldRoomIfSwitching(settings.roomId);
}
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
if (settings.roomId) {
emit(EVENTS.JOIN_ROOM, {
roomId: settings.roomId,
password: settings.password,
peerId,
username: settings.username,
tabTitle: currentTabTitle,
protocolVersion: PROTOCOL_VERSION
});
}
} else {
connect();
}
forceDisconnect();
connect();
sendResponse({ status: 'ok' });
} else if (message.type === 'RETRY_CONNECT') {
reconnectFailed = false;
reconnectStartTime = null;
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
chrome.storage.session.set({ reconnectFailed: false });
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
forceDisconnect();
connect();
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_STATUS') {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
const isReconnecting = !isConnected && reconnectAttempts > 0;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
if (reconnectFailed) status = 'reconnect_failed';
sendResponse({
status,
peerId,
@@ -1239,7 +1252,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
lastActionState,
targetTabId: currentTabId,
episodeLobby: episodeLobby,
reconnectAttempts
reconnectAttempts,
reconnectSlowMode: reconnectFailed
});
} else if (message.type === 'LEAVE_ROOM') {
emit(EVENTS.LEAVE_ROOM, { peerId });
+1 -1
View File
@@ -26,7 +26,7 @@
"LABEL_PASSWORD_TOOLTIP": "Optionales Passwort, um den Raumzugang zu beschränken",
"PLACEHOLDER_PASSWORD": "Raum-Passwort (optional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Passwort für den Raum (leer lassen, wenn keines vorhanden)",
"BTN_JOIN_ROOM": "Raum beitreten",
"BTN_JOIN_ROOM": "Raum beitreten / erstellen",
"BTN_JOIN_ROOM_TOOLTIP": "Mit dem Raum verbinden",
"LABEL_PUBLIC_ROOMS": "Öffentliche Räume",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Liste der öffentlich verfügbaren Räume auf diesem Server",
+1 -1
View File
@@ -26,7 +26,7 @@
"LABEL_PASSWORD_TOOLTIP": "Optional password to restrict room access",
"PLACEHOLDER_PASSWORD": "Room Password (optional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Password for the room (leave empty if none)",
"BTN_JOIN_ROOM": "Join Room",
"BTN_JOIN_ROOM": "Join / Create Room",
"BTN_JOIN_ROOM_TOOLTIP": "Connect to the room",
"LABEL_PUBLIC_ROOMS": "Public Rooms",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "List of publicly available rooms on this server",
+1 -1
View File
@@ -26,7 +26,7 @@
"LABEL_PASSWORD_TOOLTIP": "Contraseña opcional para restringir el acceso a la sala",
"PLACEHOLDER_PASSWORD": "Contraseña de la sala (opcional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Contraseña para la sala (dejar vacío si no hay)",
"BTN_JOIN_ROOM": "Unirse a la sala",
"BTN_JOIN_ROOM": "Unirse / Crear sala",
"BTN_JOIN_ROOM_TOOLTIP": "Conectarse a la sala",
"LABEL_PUBLIC_ROOMS": "Salas públicas",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponibles en este servidor",
+1 -1
View File
@@ -26,7 +26,7 @@
"LABEL_PASSWORD_TOOLTIP": "Mot de passe optionnel pour restreindre l'accès au salon",
"PLACEHOLDER_PASSWORD": "Mot de passe du salon (optionnel)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Mot de passe du salon (laisser vide si aucun)",
"BTN_JOIN_ROOM": "Rejoindre le salon",
"BTN_JOIN_ROOM": "Rejoindre / Créer le salon",
"BTN_JOIN_ROOM_TOOLTIP": "Se connecter au salon",
"LABEL_PUBLIC_ROOMS": "Salons publics",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Liste des salons publics disponibles sur ce serveur",
+1 -1
View File
@@ -26,7 +26,7 @@
"LABEL_PASSWORD_TOOLTIP": "Senha opcional para restringir o acesso à sala",
"PLACEHOLDER_PASSWORD": "Senha da sala (opcional)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Senha para a sala (deixe em branco se não houver)",
"BTN_JOIN_ROOM": "Entrar na sala",
"BTN_JOIN_ROOM": "Entrar / Criar sala",
"BTN_JOIN_ROOM_TOOLTIP": "Conectar à sala",
"LABEL_PUBLIC_ROOMS": "Salas públicas",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Lista de salas públicas disponíveis neste servidor",
+1 -1
View File
@@ -26,7 +26,7 @@
"LABEL_PASSWORD_TOOLTIP": "Дополнительный пароль для ограничения доступа к комнате",
"PLACEHOLDER_PASSWORD": "Пароль комнаты (опционально)",
"PLACEHOLDER_PASSWORD_TOOLTIP": "Пароль от комнаты (оставьте пустым, если его нет)",
"BTN_JOIN_ROOM": "Войти в комнату",
"BTN_JOIN_ROOM": "Войти / Создать комнату",
"BTN_JOIN_ROOM_TOOLTIP": "Подключиться к комнате",
"LABEL_PUBLIC_ROOMS": "Публичные комнаты",
"LABEL_PUBLIC_ROOMS_TOOLTIP": "Список публично доступных комнат на этом сервере",
+29 -11
View File
@@ -59,6 +59,7 @@ let localPeerId = null;
let lastPeersJson = null;
let lastKnownPeers = [];
let isDevTabVisible = false;
let reconnectSlowMode = false;
let joinBtnTimeout = null;
let forceSyncResetTimer = null;
let popupIntervals = [];
@@ -128,6 +129,7 @@ async function init() {
}
if (res) {
localPeerId = res.peerId;
reconnectSlowMode = res.reconnectSlowMode || false;
applyConnectionStatus(res.status);
updatePeerList(res.peers);
lastKnownPeers = res.peers || [];
@@ -671,10 +673,9 @@ function applyConnectionStatus(status) {
const connected = status === 'connected';
const connecting = status === 'connecting';
const reconnecting = status === 'reconnecting';
const failed = status === 'reconnect_failed';
if (elements.connDot) {
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : ((connecting || reconnecting) ? 'status-online' : 'status-offline')));
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : ((connecting || reconnecting) ? 'status-online' : 'status-offline'));
if (reconnecting) {
elements.connDot.style.background = '#f59e0b';
@@ -682,7 +683,7 @@ function applyConnectionStatus(status) {
} else if (connecting) {
elements.connDot.style.background = '#fbbf24';
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
} else if (failed) {
} else if (!connected) {
elements.connDot.style.background = '#ef4444';
elements.connDot.style.boxShadow = 'none';
} else {
@@ -692,15 +693,15 @@ function applyConnectionStatus(status) {
}
if (elements.connText) {
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : (failed ? getMessage('STATUS_FAILED') : getMessage('STATUS_DISCONNECTED'))));
elements.connText.textContent = connected ? getMessage('STATUS_CONNECTED') : (reconnecting ? getMessage('STATUS_RECONNECTING') : (connecting ? getMessage('STATUS_CONNECTING') : getMessage('STATUS_DISCONNECTED')));
}
if (elements.retryBtn) {
elements.retryBtn.style.display = failed ? 'block' : 'none';
elements.retryBtn.style.display = reconnecting && reconnectSlowMode ? 'block' : 'none';
}
if (elements.joinBtn) {
if (connecting || reconnecting) {
elements.joinBtn.disabled = true;
elements.joinBtn.disabled = !reconnectSlowMode;
elements.joinBtn.textContent = connecting ? getMessage('BTN_STATE_JOINING') : getMessage('BTN_STATE_RECONNECTING');
} else {
elements.joinBtn.disabled = false;
@@ -858,9 +859,12 @@ function setServerMode(custom) {
elements.serverOfficial.classList.toggle('active', !custom);
elements.serverCustom.classList.toggle('active', custom);
elements.serverUrl.style.display = custom ? 'block' : 'none';
chrome.storage.sync.get(['useCustomServer'], (data) => {
chrome.storage.sync.get(['useCustomServer', 'serverUrl'], (data) => {
if (data.useCustomServer !== custom) {
chrome.storage.sync.set({ useCustomServer: custom });
if (!custom || data.serverUrl) {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
}
}
});
}
@@ -919,6 +923,9 @@ elements.serverUrl.addEventListener('change', () => {
elements.serverUrl.value = url;
chrome.storage.sync.set({ serverUrl: url });
}
if (elements.serverCustom.classList.contains('active') && url) {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
}
});
elements.tabs.forEach(btn => {
@@ -993,6 +1000,12 @@ elements.joinBtn.addEventListener('click', async () => {
const useCustom = elements.serverCustom.classList.contains('active');
// Proactive URL Validation
if (useCustom && !serverUrl) {
showError(getMessage('ERR_INVALID_SERVER_URL'));
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
return;
}
if (useCustom && serverUrl) {
try {
const urlToCheck = serverUrl.includes('://') ? serverUrl : 'ws://' + serverUrl;
@@ -1008,7 +1021,7 @@ elements.joinBtn.addEventListener('click', async () => {
const roomId = roomIdInput || Math.random().toString(36).substring(2, 8).toUpperCase();
const password = elements.password.value;
await chrome.storage.sync.set({ serverUrl, roomId, password });
await chrome.storage.sync.set({ serverUrl, roomId, password, useCustomServer: useCustom });
elements.roomId.value = roomId;
// Tell background to connect
@@ -1338,20 +1351,25 @@ chrome.runtime.onMessage.addListener((msg) => {
updatePeerList(msg.peers);
if (msg.peers) detectPeerChanges(msg.peers);
} else if (msg.type === 'CONNECTION_STATUS') {
applyConnectionStatus(msg.status);
if (msg.status !== 'reconnecting') {
applyConnectionStatus(msg.status);
reconnectSlowMode = false;
}
if (msg.status === 'connected') {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.peers) updatePeerList(res.peers);
if (res && res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
});
}
if (msg.status === 'disconnected' || msg.status === 'reconnect_failed') {
if (msg.status === 'disconnected') {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
}
if (msg.status === 'reconnecting') {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (chrome.runtime.lastError) return;
if (res && res.reconnectSlowMode !== undefined) reconnectSlowMode = res.reconnectSlowMode;
applyConnectionStatus(msg.status);
if (res && res.reconnectAttempts !== undefined) {
if (elements.connText) {
elements.connText.textContent = `Reconnecting... (${res.reconnectAttempts})`;