mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-27 12:29:35 +00:00
fix(extension): harden reconnect, ping, and offline-queue flush
Address client-side rate-limit edge cases that could trip the server's per-socket event budget or per-IP connection limit: - Pace the offline event-queue flush after (re)connect in batches (10 per 3s) instead of one synchronous burst, so a reconnect after a long outage no longer dumps the whole backlog and gets kicked. - Wrap socket.send() in try/catch and re-queue on failure (race with a server-side disconnect) instead of losing the event. - Ping liveness: tolerate one missed PONG; only force a reconnect after 2 consecutive misses (~20s) instead of a single hard 5s timeout. - Reconnect backoff: tune to ~8 attempts/60s (under the connection limit) and add +/-20% jitter to de-synchronize reconnect herds. All magic numbers extracted into named constants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+101
-26
@@ -63,6 +63,7 @@ let storageInitialized = false;
|
||||
let pendingLogs = [];
|
||||
let pendingHistory = [];
|
||||
let eventQueue = [];
|
||||
let flushTimer = null; // paces draining of eventQueue after (re)connect
|
||||
let isNamespaceJoined = false;
|
||||
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
|
||||
let localSeq = 0; // Monotonically increasing command sequence for this peer
|
||||
@@ -75,6 +76,7 @@ let pingInterval = null;
|
||||
let pingTimeout = null;
|
||||
let pendingPingT = null;
|
||||
let currentPingMs = null;
|
||||
let missedPongs = 0;
|
||||
|
||||
// --- Keep-Alive Port Listener ---
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
@@ -198,8 +200,28 @@ let roomIdleSince = null;
|
||||
let lastContentHeartbeatAt = null;
|
||||
let connectIntent = false;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
|
||||
// keeping a single client comfortably under the server's per-IP connection
|
||||
// budget (10/min) even before jitter. Cumulative (no jitter): 1, 2.8, 6, 11.9,
|
||||
// 22.4, 34.4, 46.4, 58.4s → 8th attempt at ~58s.
|
||||
const _RECONNECT_BASE_DELAY = 1000;
|
||||
const _RECONNECT_MAX_DELAY = 12000;
|
||||
const _RECONNECT_FACTOR = 1.8;
|
||||
const _RECONNECT_GIVEUP_MS = 300000; // switch to slow mode after 5 min of fast retries
|
||||
const _RECONNECT_SLOW_DELAY = 300000; // slow-mode interval: every 5 min
|
||||
const _RECONNECT_JITTER = 0.2; // ±20% randomization to de-synchronize reconnect herds
|
||||
// Paced queue flush: after a (re)connect we drain the offline event backlog in
|
||||
// small batches instead of one synchronous burst, so we stay well under the
|
||||
// server's per-socket event budget (50 / 10s) and leave headroom for the
|
||||
// heartbeats/pings/commands that also count toward it. 10 per 3s ≈ 33/10s.
|
||||
const FLUSH_BATCH_SIZE = 10;
|
||||
const FLUSH_BATCH_INTERVAL_MS = 3000;
|
||||
// Ping liveness: a single unanswered ping is tolerated (transient network
|
||||
// blip); only MAX_MISSED_PONGS consecutive misses force a reconnect. With a
|
||||
// 15s interval and 5s timeout that means ~20s to detect a genuinely dead link.
|
||||
const PING_INTERVAL_MS = 15000;
|
||||
const PING_TIMEOUT_MS = 5000;
|
||||
const MAX_MISSED_PONGS = 2;
|
||||
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Force Sync Coordination
|
||||
@@ -350,6 +372,7 @@ function forceDisconnect() {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
forceSyncAcks.clear();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
@@ -547,12 +570,7 @@ async function connect() {
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
while (eventQueue.length > 0) {
|
||||
const queuedMsg = eventQueue.shift();
|
||||
emit(queuedMsg.event, queuedMsg.data);
|
||||
}
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({ eventQueue: [] });
|
||||
flushEventQueue();
|
||||
} else if (msg.startsWith('42')) {
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
@@ -571,6 +589,7 @@ async function connect() {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
stopPing();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
|
||||
if (!connectIntent && !currentRoom) {
|
||||
isForceSyncInitiator = false;
|
||||
@@ -689,17 +708,22 @@ function scheduleReconnect() {
|
||||
const elapsed = Date.now() - reconnectStartTime;
|
||||
reconnectAttempts++;
|
||||
|
||||
if (!reconnectFailed && (elapsed > 300000 || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
|
||||
if (!reconnectFailed && (elapsed > _RECONNECT_GIVEUP_MS || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
|
||||
reconnectFailed = true;
|
||||
addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn');
|
||||
}
|
||||
|
||||
const delay = reconnectFailed
|
||||
? 300000
|
||||
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
const baseDelay = reconnectFailed
|
||||
? _RECONNECT_SLOW_DELAY
|
||||
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(_RECONNECT_FACTOR, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
|
||||
// Jitter de-synchronizes herds: many clients dropped by the same server
|
||||
// blip won't all reconnect on the same tick and exhaust the connection
|
||||
// budget in lockstep. Applied in both fast and slow mode.
|
||||
const jitterFactor = 1 - _RECONNECT_JITTER + Math.random() * 2 * _RECONNECT_JITTER;
|
||||
const delay = Math.round(baseDelay * jitterFactor);
|
||||
|
||||
if (reconnectFailed) {
|
||||
addLog(`Slow reconnect in 5min (attempt ${reconnectAttempts})`, 'info');
|
||||
addLog(`Slow reconnect in ~5min (attempt ${reconnectAttempts})`, 'info');
|
||||
} else {
|
||||
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
|
||||
}
|
||||
@@ -717,17 +741,58 @@ function scheduleReconnect() {
|
||||
function emit(event, data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
const msg = `42${JSON.stringify([event, data])}`;
|
||||
socket.send(msg);
|
||||
} else {
|
||||
eventQueue.push({ event, data });
|
||||
if (eventQueue.length > 50) {
|
||||
eventQueue.shift();
|
||||
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
||||
try {
|
||||
socket.send(msg);
|
||||
} catch (e) {
|
||||
// The socket can close between the readyState check and send()
|
||||
// (race with a server-side disconnect). Re-queue so the event is
|
||||
// retried on the next successful (re)connect instead of being lost.
|
||||
addLog(`Send failed, re-queueing ${event}: ${e.message}`, 'warn');
|
||||
queueEvent(event, data);
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue });
|
||||
} else {
|
||||
queueEvent(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
function queueEvent(event, data) {
|
||||
eventQueue.push({ event, data });
|
||||
if (eventQueue.length > 50) {
|
||||
eventQueue.shift();
|
||||
addLog('Event queue cap reached, dropping oldest event', 'warn');
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the offline event queue in paced batches. A reconnect after a long
|
||||
* outage can leave up to 50 queued events; dumping them in one tick would
|
||||
* exceed the server's per-socket event budget and get us disconnected right
|
||||
* after rejoining. We send FLUSH_BATCH_SIZE events, then wait
|
||||
* FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across
|
||||
* subsequent batches; if the connection drops mid-drain, the rest stay queued.
|
||||
*/
|
||||
function flushEventQueue() {
|
||||
if (flushTimer) return; // a drain is already in progress
|
||||
const drainBatch = () => {
|
||||
flushTimer = null;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
return; // lost the connection — leave the rest queued for next connect
|
||||
}
|
||||
let sent = 0;
|
||||
while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) {
|
||||
const queuedMsg = eventQueue.shift();
|
||||
emit(queuedMsg.event, queuedMsg.data);
|
||||
sent++;
|
||||
}
|
||||
chrome.storage.session.set({ eventQueue }).catch(() => {});
|
||||
if (eventQueue.length > 0) {
|
||||
flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
drainBatch();
|
||||
}
|
||||
|
||||
function addToHistory(action, senderId) {
|
||||
const historyEntry = {
|
||||
action,
|
||||
@@ -751,16 +816,23 @@ function sendPing() {
|
||||
emit(EVENTS.PING, { t });
|
||||
if (pingTimeout) clearTimeout(pingTimeout);
|
||||
pingTimeout = setTimeout(() => {
|
||||
if (pendingPingT === t) {
|
||||
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
|
||||
pendingPingT = null;
|
||||
pingTimeout = null;
|
||||
if (pendingPingT !== t) return; // a PONG arrived in time
|
||||
// This ping went unanswered. Tolerate transient blips: only force a
|
||||
// reconnect after MAX_MISSED_PONGS consecutive misses, not the first.
|
||||
pendingPingT = null;
|
||||
missedPongs++;
|
||||
if (missedPongs >= MAX_MISSED_PONGS) {
|
||||
addLog(`${missedPongs} consecutive pings unanswered — force disconnecting to trigger reconnect`, 'warn');
|
||||
missedPongs = 0;
|
||||
forceDisconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
} else {
|
||||
addLog(`Ping unanswered (${missedPongs}/${MAX_MISSED_PONGS}) — retrying next interval`, 'warn');
|
||||
}
|
||||
pingTimeout = null;
|
||||
}, 5000);
|
||||
}, PING_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function startPing() {
|
||||
@@ -768,7 +840,8 @@ function startPing() {
|
||||
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
|
||||
currentPingMs = null;
|
||||
pendingPingT = null;
|
||||
pingInterval = setInterval(sendPing, 15000);
|
||||
missedPongs = 0;
|
||||
pingInterval = setInterval(sendPing, PING_INTERVAL_MS);
|
||||
sendPing();
|
||||
}
|
||||
|
||||
@@ -783,6 +856,7 @@ function stopPing() {
|
||||
}
|
||||
currentPingMs = null;
|
||||
pendingPingT = null;
|
||||
missedPongs = 0;
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
@@ -1123,6 +1197,7 @@ function handleServerEvent(event, data) {
|
||||
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
|
||||
if (pendingPingT === data.t) {
|
||||
pendingPingT = null;
|
||||
missedPongs = 0;
|
||||
if (pingTimeout) {
|
||||
clearTimeout(pingTimeout);
|
||||
pingTimeout = null;
|
||||
|
||||
Reference in New Issue
Block a user