mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-07 01:43:15 +00:00
perf: optimize reconnect flow - aggressive backoff (500ms→5s), reconnect UI status, 30s keepAlive
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user