perf: optimize reconnect flow - aggressive backoff (500ms→5s), reconnect UI status, 30s keepAlive

This commit is contained in:
Koala
2026-05-25 10:29:19 +02:00
parent f23c7eb3c8
commit f59d30569e
5 changed files with 73 additions and 66 deletions
+4 -2
View File
@@ -40,10 +40,11 @@ Maintains continuous synchronized viewing when watching series:
## 5. Peer Lifecycle & Dual Heartbeat
To maintain a clean room state and eliminate "Ghost Peers":
- **Session Heartbeat (Background)**: Every 1 minute, `background.js` sends an "I'm alive" signal to the server. This keeps you in the room even if no video is playing.
- **Session Heartbeat (Background)**: Every 30 seconds, `background.js` sends an "I'm alive" signal to the server. This keeps you in the room even if no video is playing.
- **Video Heartbeat (Content)**: Every 15 seconds, `content.js` sends current playback metadata (time, title, state) if a video is found.
- **Server Pruning**: The server runs a "Reaper" every 2 minutes. If a peer has sent **zero** activity (no events and no heartbeats) for 5 minutes, they are forcefully disconnected.
- **Immediate Cleanup**: Rooms are deleted instantly when the last peer leaves or disconnects.
- **Reconnect Strategy**: Aggressive backoff — 500ms base, 1.5x multiplier, capped at 5s. Max 20 attempts before marking as failed. Events are queued during disconnect and flushed after namespace rejoin.
> [!CAUTION]
> **Identity Rule**: Differentiate between `peerId` and `socket.id`. Use `socket.id` exclusively for ephemeral transport routing on the server. Use `peerId` exclusively for identity, state management, and room tracking across the stack.
@@ -54,7 +55,8 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
- **Storm Prevention**: When dispatching state updates in response to a new user joining (e.g., an active lobby state), ensure ONLY the initiator (or a designated leader) calls `emit()` to prevent $O(N)$ broadcast storms.
## 7. Security & Stability
- **Service Worker Lifecycle**: Uses `chrome.alarms` to prevent the Manifest V3 service worker from suspending while in an active room.
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS.
- **Noise Filtering**: Uses a curated blacklist of domains (Search Engines, Social Media) to declutter the "Target Tab" selector in the popup.
- **Diagnostics**: A "Dev" tab provides real-time access to the underlying `<video>` state (`readyState`, `paused`, `currentTime`) for easier troubleshooting.
+1 -1
View File
@@ -155,7 +155,7 @@ While in a room, two heartbeats keep the session alive:
| Heartbeat | Interval | Source | Purpose |
|:----------|:---------|:-------|:--------|
| **Background** | 1 minute | `background.js` | Signals "I'm still connected" and handles 5-min auto-reconnect fallback |
| **Background** | 30 seconds | `background.js` | Signals "I'm still connected" and triggers aggressive reconnect (500ms base, max 5s) |
| **Content** | 15 seconds | `content.js` | Sends video metadata: `currentTime`, `mediaTitle`, `playbackState`, `volume`, `muted` |
- **Server Reaper**: Every 2 minutes, the server checks for peers with no activity for 5+ minutes and disconnects them ("dead peer pruning").
+4 -7
View File
@@ -4,16 +4,13 @@ This document tracks planned features, improvements, and their implementation de
---
## Antworten auf offenen Fragen
## Offene technische Fragen
### 1. Graceful Shutdown
**Korrektur:** Der Server hat bereits Graceful Shutdown implementiert (`server/index.js:481-499`). Bei SIGTERM/SIGINT wird allen Clients eine Neustart-Nachricht gesendet, der HTTP-Server geschlossen und nach 5s erzwungen beendet. **Kein Handlungsbedarf.**
### 6. Service Worker Fallback bei Room-State Verlust
### 1. Service Worker Fallback bei Room-State Verlust
Manifest V3 suspendiert den Service Worker nach ~30s Inaktivität. `chrome.alarms` weckt ihn auf, aber:
- **Problem:** Wenn der SW neu startet, sind alle Variablen (`currentRoom`, `socket`, `isNamespaceJoined`) weg
- **Aktueller Stand:** `chrome.storage.session` persistiert `currentRoom`, `peerId`, `eventQueue` — der SW stellt diese beim Start wieder her (`restoreSession()`)
- **Lücke:** Der WebSocket muss neu aufgebaut werden. Das passiert automatisch via `connect()`, aber es gibt eine **Zeitlücke** von 2-5 Sekunden in der Events verloren gehen können. **Verbesserung:** Queue-Events während Reconnect, visualisiere "Reconnecting..." im Popup.
- **Aktueller Stand:** `chrome.storage.session` persistiert `currentRoom`, `peerId`, `eventQueue` — der SW stellt diese beim Start wieder her (`ensureState()`)
- **Gelöst:** WebSocket wird automatisch via `connect()` neu aufgebaut. Events werden während Reconnect gequeued und nach Namespace-Join geflushed. "Reconnecting..." Status wird im Popup + Badge angezeigt. KeepAlive-Alarm auf 30s reduziert. Reconnect-Backoff: 500ms Basis, max 5s (statt vorher 1s→30s).
### 7. Tests für Extensions
Stimmt, sind aufwändig. Praktische Ansätze:
+48 -51
View File
@@ -2,8 +2,6 @@ import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, A
// --- State Management ---
let socket = null;
let reconnectDelay = 1000;
const MAX_RECONNECT_DELAY = 30000;
let isConnecting = false;
let peerId = null; // initialized via getPeerId()
let currentRoom = null;
@@ -102,11 +100,12 @@ function ensureState() {
ensureState();
let reconnectTimer = null;
let reconnectStartTime = null; // New: track when reconnection started
let reconnectFailed = false; // New: true if we hit the 5-min cap
let slowReconnectTimer = null; // Infinite slow background reconnect timer
let isSlowReconnectAttempt = false; // True during slow background reconnect execution
let lastSlowReconnectAttempt = 0;
let reconnectStartTime = null;
let reconnectFailed = false;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 20;
const _RECONNECT_BASE_DELAY = 500;
const _RECONNECT_MAX_DELAY = 5000;
// Force Sync Coordination
let isForceSyncInitiator = false;
@@ -211,9 +210,6 @@ async function connect() {
if (isConnecting) return;
isConnecting = true;
const isCurrentSlowRetry = isSlowReconnectAttempt;
isSlowReconnectAttempt = false;
let finalUrl = '';
try {
// --- Phase 1: Storage ---
@@ -245,12 +241,12 @@ async function connect() {
return;
}
if (reconnectFailed && !isCurrentSlowRetry) {
if (reconnectFailed) {
isConnecting = false;
return; // Let keepAlive alarm handle the 5-min retry interval
return;
}
broadcastConnectionStatus('connecting');
broadcastConnectionStatus('reconnecting');
const isCustomServer = settings.serverUrl && settings.useCustomServer;
finalUrl = isCustomServer ? settings.serverUrl : OFFICIAL_SERVER_URL;
@@ -273,7 +269,7 @@ async function connect() {
throw new Error(`[URL Error] ${e.message}`);
}
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}...`, 'info');
addLog(`Connecting to ${isCustomServer ? finalUrl : 'Official Server'}... (attempt ${reconnectAttempts + 1})`, 'info');
// --- Phase 4: WebSocket Init ---
try {
@@ -291,15 +287,10 @@ async function connect() {
// --- Phase 5: Event Listeners ---
socket.onopen = () => {
reconnectDelay = 1000;
reconnectAttempts = 0;
addLog('WebSocket Connection Opened', 'success');
reconnectStartTime = null;
reconnectFailed = false;
isSlowReconnectAttempt = false;
if (slowReconnectTimer) {
clearTimeout(slowReconnectTimer);
slowReconnectTimer = null;
}
chrome.storage.session.set({ reconnectFailed: false });
isNamespaceJoined = false;
socket.send('40');
@@ -368,7 +359,7 @@ async function connect() {
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
}
broadcastConnectionStatus('disconnected');
addLog(`Disconnected. Retrying in ${reconnectDelay / 1000}s...`, 'warn');
addLog('Disconnected. Scheduling reconnect...', 'warn');
scheduleReconnect();
};
@@ -394,11 +385,15 @@ function broadcastConnectionStatus(status) {
function updateBadgeStatus() {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
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') {
chrome.action.setBadgeText({ text: '...' });
chrome.action.setBadgeBackgroundColor({ color: '#f59e0b' });
} else if (status === 'connecting') {
chrome.action.setBadgeText({ text: '...' });
chrome.action.setBadgeBackgroundColor({ color: '#fbbf24' });
@@ -438,29 +433,42 @@ function showNotification(senderName, action) {
function scheduleReconnect() {
if (reconnectTimer) return;
isSlowReconnectAttempt = false;
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. Entering slow background retry mode.', 'error');
addLog('Reconnection failed after 5 minutes.', 'error');
broadcastConnectionStatus('reconnect_failed');
return;
}
reconnectAttempts++;
// Cap at max attempts to prevent infinite loops
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
reconnectFailed = true;
chrome.storage.session.set({ reconnectFailed: true });
addLog('Reconnection failed after max attempts.', 'error');
broadcastConnectionStatus('reconnect_failed');
return;
}
// 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);
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
connect();
}, reconnectDelay);
}, delay);
}
// Slow reconnect logic is now handled in the keepAlive alarm
@@ -908,20 +916,13 @@ async function routeToContent(action, payload) {
}
// --- Keep-Alive Mechanism ---
chrome.alarms.create('keepAlive', { periodInMinutes: 1 });
chrome.alarms.create('keepAlive', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
await ensureState();
if (alarm.name === 'keepAlive') {
chrome.storage.session.get('keepAlive', () => {});
if (!socket || socket.readyState !== WebSocket.OPEN) {
if (reconnectFailed) {
if (Date.now() - lastSlowReconnectAttempt >= 300000) {
lastSlowReconnectAttempt = Date.now();
isSlowReconnectAttempt = true;
addLog('Alarm triggered 5-min slow reconnect attempt', 'info');
connect();
}
} else {
if (!reconnectFailed) {
connect();
}
} else if (currentRoom) {
@@ -978,18 +979,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (message.type === 'CONNECT') {
reconnectFailed = false;
reconnectStartTime = null;
isSlowReconnectAttempt = false;
if (slowReconnectTimer) {
clearTimeout(slowReconnectTimer);
slowReconnectTimer = null;
}
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false });
const settings = await getSettings();
if (settings.roomId) {
leaveOldRoomIfSwitching(settings.roomId);
}
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
// Already connected, but maybe room changed or we need to refresh room state
if (settings.roomId) {
emit(EVENTS.JOIN_ROOM, {
roomId: settings.roomId,
@@ -1007,18 +1003,18 @@ async function handleAsyncMessage(message, sender, sendResponse) {
} else if (message.type === 'RETRY_CONNECT') {
reconnectFailed = false;
reconnectStartTime = null;
reconnectDelay = 1000;
isSlowReconnectAttempt = false;
if (slowReconnectTimer) {
clearTimeout(slowReconnectTimer);
slowReconnectTimer = null;
reconnectAttempts = 0;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
chrome.storage.session.set({ reconnectFailed: false });
connect();
sendResponse({ status: 'ok' });
} else if (message.type === 'GET_STATUS') {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : 'disconnected');
const isReconnecting = !isConnected && reconnectAttempts > 0 && !reconnectFailed;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
if (reconnectFailed) status = 'reconnect_failed';
sendResponse({
status,
@@ -1026,7 +1022,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId: currentTabId,
episodeLobby: episodeLobby
episodeLobby: episodeLobby,
reconnectAttempts
});
} else if (message.type === 'LEAVE_ROOM') {
emit(EVENTS.LEAVE_ROOM, { peerId });
+16 -5
View File
@@ -545,11 +545,15 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
function applyConnectionStatus(status) {
const connected = status === 'connected';
const connecting = status === 'connecting';
const reconnecting = status === 'reconnecting';
const failed = status === 'reconnect_failed';
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : (connecting ? 'status-online' : 'status-offline')));
elements.connDot.className = 'status-dot ' + (connected ? 'status-online' : (failed ? 'status-offline' : ((connecting || reconnecting) ? 'status-online' : 'status-offline')));
if (connecting) {
if (reconnecting) {
elements.connDot.style.background = '#f59e0b';
elements.connDot.style.boxShadow = '0 0 8px #f59e0b';
} else if (connecting) {
elements.connDot.style.background = '#fbbf24';
elements.connDot.style.boxShadow = '0 0 8px #fbbf24';
} else if (failed) {
@@ -560,13 +564,13 @@ function applyConnectionStatus(status) {
elements.connDot.style.boxShadow = '';
}
elements.connText.textContent = connected ? 'Connected' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected'));
elements.connText.textContent = connected ? 'Connected' : (reconnecting ? 'Reconnecting...' : (connecting ? 'Connecting...' : (failed ? 'Failed' : 'Disconnected')));
elements.retryBtn.style.display = failed ? 'block' : 'none';
// Update Join Button during auto-transition
if (connecting) {
if (connecting || reconnecting) {
elements.joinBtn.disabled = true;
elements.joinBtn.textContent = '🚀 Joining...';
elements.joinBtn.textContent = connecting ? '🚀 Joining...' : '🔄 Reconnecting...';
} else {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
@@ -1041,6 +1045,13 @@ chrome.runtime.onMessage.addListener((msg) => {
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = 'Join Room';
}
if (msg.status === 'reconnecting') {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (res && res.reconnectAttempts !== undefined) {
elements.connText.textContent = `Reconnecting... (${res.reconnectAttempts})`;
}
});
}
} else if (msg.type === 'HISTORY_UPDATE') {
updateHistory(msg.history);
} else if (msg.type === 'ROOM_LIST') {