From 27e57862c0c16be5dba819bf1f68f76f63f75664 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:15:01 +0200 Subject: [PATCH 1/8] docs: shorten v2.3.1 changelog entry --- docs/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e3fe719..ef66a52 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,11 +7,11 @@ All notable changes to the KoalaSync browser extension and relay server. ## [v2.3.1] — 2026-06-15 ### Fixed -- **Server: Race condition on concurrent peer joins**: Added a per-peerId serialization lock (`peerJoinLocks`) that prevents two connections with the same `peerId` from both passing the deduplication check and simultaneously registering in `peerToSocket`. Previously, rapid reconnects could leave stale mappings that caused cross-room ACK/PING misdelivery. -- **Server: Crash-safe error handling in teardown paths**: Wrapped `removePeerFromRoom` calls in the disconnect, leave, and reaper handlers with try/catch to prevent an unhandled exception in any teardown path from crashing the process. -- **Server: Smart unhandled rejection handling**: Replaced the immediate `process.exit(1)` on any `unhandledRejection` with a rate-limited approach — the server now exits only after 5 unhandled rejections within 60 seconds, surviving transient errors while still failing fast on cascading crashes. -- **Server: Reduced GC pressure in admin health metrics**: Replaced the three-pass `Array.from()` / `map()` / `reduce()` / `filter()` pattern in `buildHealthPayload()` with a single `for-of` loop, eliminating temporary array allocations proportional to the number of active rooms. -- **Server: Test isolation for rate-limit denial counters**: `stopServerForTests()` now resets the `rateLimitDenied` counters between test runs. +- **Server: Concurrent peer join race condition and teardown error handling** + +### Changed +- **Server: Smart unhandled rejection handling (exits after 5/min instead of 1)** +- **Server: Optimized admin health metrics allocation** --- From ec8f56a85d2e52a7ac949d1043f98e6db0db307b Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:08:29 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(extension):=20lazy-connect=20=E2=80=94?= =?UTF-8?q?=20only=20maintain=20WebSocket=20when=20actively=20in=20room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add connectIntent flag to gate all reconnect attempts - Only auto-connect on startup if roomId exists in storage - Close socket + stop reconnect after leaveRoom or idleLeave - Preserve existing behavior 1:1 when actively in a room - Guard force-sync state and peer list clearing during transient disconnects - Guard WEB_JOIN_REQUEST against empty sanitized roomId --- extension/background.js | 65 +++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/extension/background.js b/extension/background.js index f8c4aa0..7ca9ff9 100644 --- a/extension/background.js +++ b/extension/background.js @@ -176,6 +176,7 @@ let reconnectAttempts = 0; let currentServerUrl = null; let roomIdleSince = null; let lastContentHeartbeatAt = null; +let connectIntent = false; const MAX_RECONNECT_ATTEMPTS = 20; const _RECONNECT_BASE_DELAY = 500; const _RECONNECT_MAX_DELAY = 5000; @@ -404,7 +405,9 @@ function clearTargetTabForIdle() { async function leaveRoomAfterIdleGrace(reason) { if (!currentRoom) return; + connectIntent = false; emit(EVENTS.LEAVE_ROOM, { peerId }); + forceDisconnect(); currentRoom = null; currentTabId = null; currentTabTitle = null; @@ -457,7 +460,9 @@ async function connect() { addLog('Browser is offline. Waiting...', 'warn'); broadcastConnectionStatus('offline'); isConnecting = false; - scheduleReconnect(); + if (currentRoom || connectIntent) { + scheduleReconnect(); + } return; } @@ -564,25 +569,32 @@ async function connect() { isNamespaceJoined = false; stopPing(); - isForceSyncInitiator = false; - forceSyncAcks.clear(); - if (forceSyncTimeout) clearTimeout(forceSyncTimeout); - chrome.storage.session.set({ - isForceSyncInitiator: false, - forceSyncAcks: [], - forceSyncDeadline: null - }).catch(() => {}); + if (!connectIntent && !currentRoom) { + isForceSyncInitiator = false; + forceSyncAcks.clear(); + if (forceSyncTimeout) clearTimeout(forceSyncTimeout); + chrome.storage.session.set({ + isForceSyncInitiator: false, + forceSyncAcks: [], + forceSyncDeadline: null + }).catch(() => {}); + } - if (currentRoom) { + if (currentRoom && !connectIntent) { currentRoom.peers = []; if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {}); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); } broadcastConnectionStatus('disconnected'); - addLog('Disconnected. Scheduling reconnect...', 'warn'); - socket = null; - scheduleReconnect(); + if (currentRoom || connectIntent) { + addLog('Disconnected. Scheduling reconnect...', 'warn'); + socket = null; + scheduleReconnect(); + } else { + addLog('Disconnected. No active session — staying disconnected.', 'info'); + socket = null; + } }; socket.onerror = () => { @@ -597,7 +609,9 @@ async function connect() { const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error'); addLog(errMsg, logType); broadcastConnectionStatus('disconnected'); - scheduleReconnect(); + if (currentRoom || connectIntent) { + scheduleReconnect(); + } } } @@ -738,6 +752,9 @@ function sendPing() { addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn'); pendingPingT = null; forceDisconnect(); + if (currentRoom || connectIntent) { + scheduleReconnect(); + } } pingTimeout = null; }, 5000); @@ -1334,7 +1351,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name === 'keepAlive') { chrome.storage.session.get('keepAlive', () => {}); if (!socket || socket.readyState !== WebSocket.OPEN) { - if (!reconnectFailed) { + if (!reconnectFailed && (currentRoom || connectIntent)) { connect(); } } else if (currentRoom) { @@ -1417,6 +1434,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (message.type === 'CONNECT') { const settings = await getSettings(); + connectIntent = !!settings.roomId; const desiredUrl = resolveServerUrl(settings); if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { @@ -1439,7 +1457,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { if (desiredUrl !== currentServerUrl) forceDisconnect(); - connect(); + if (settings.roomId) connect(); } else if (settings.roomId) { emit(EVENTS.JOIN_ROOM, { roomId: settings.roomId, @@ -1452,6 +1470,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'RETRY_CONNECT') { + connectIntent = true; reconnectFailed = false; reconnectStartTime = null; reconnectAttempts = 0; @@ -1480,6 +1499,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { ping: currentPingMs }); } else if (message.type === 'LEAVE_ROOM') { + connectIntent = false; resetAudioProcessingInTab(currentTabId); emit(EVENTS.LEAVE_ROOM, { peerId }); currentRoom = null; @@ -1510,8 +1530,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { episodeLobby: null, expectedAcksCount: 0 }); + chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {}); addLog('Left Room', 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); + forceDisconnect(); sendResponse({ status: 'ok' }); } else if (message.type === 'CLEAR_LOGS') { logs = []; @@ -1526,6 +1548,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'WEB_JOIN_REQUEST') { const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message; const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : ''; + connectIntent = !!roomId; + if (!roomId) { sendResponse({ error: 'invalid_room_id' }); return; } chrome.storage.local.set({ roomId, password, @@ -1554,7 +1578,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { if (desiredUrl !== currentServerUrl) forceDisconnect(); connect(); - } else { + } else if (roomId) { emit(EVENTS.JOIN_ROOM, { roomId, password, @@ -1939,5 +1963,8 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => { } }); -// Initial Connect -connect(); +// Initial Connect — only if user has an active room configuration +getSettings().then(settings => { + connectIntent = !!settings.roomId; + if (connectIntent) connect(); +}).catch(() => connectIntent = false); From 1685b6a32747ddc915db1ec6d2b7592b3e7e8d33 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:19:18 +0200 Subject: [PATCH 3/8] fix(popup): join timeout checks connection status instead of blind 15s timer Lazy-connect introduces a new state where the user clicks 'Raum erstellen' and the WebSocket takes 1-2s to establish (previously always pre-connected). The old 15s blind timeout would re-enable the button while background was still connecting, causing silent no-op clicks. Now polls GET_STATUS and extends the window if still connecting. --- extension/popup.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/extension/popup.js b/extension/popup.js index baa6013..3a76b5d 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1195,11 +1195,23 @@ elements.joinBtn.addEventListener('click', async () => { if (joinBtnTimeout) clearTimeout(joinBtnTimeout); joinBtnTimeout = setTimeout(() => { - elements.joinBtn.disabled = false; - elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); - joinBtnTimeout = null; - isProcessingConnection = false; - showError(getMessage('ERR_CONN_TIMEOUT')); + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => { + if (res && res.status === 'connecting') { + joinBtnTimeout = setTimeout(() => { + elements.joinBtn.disabled = false; + elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); + joinBtnTimeout = null; + isProcessingConnection = false; + showError(getMessage('ERR_CONN_TIMEOUT')); + }, 15000); + return; + } + elements.joinBtn.disabled = false; + elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); + joinBtnTimeout = null; + isProcessingConnection = false; + if (res && res.status !== 'connected') showError(getMessage('ERR_CONN_TIMEOUT')); + }); }, 15000); const serverUrl = elements.serverUrl.value.trim(); From f12bb0a5329eacc74674575eaaa04fbec250f78a Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:22:17 +0200 Subject: [PATCH 4/8] fix(extension): reset reconnectAttempts on leave to prevent stale reconnecting status After LEAVE_ROOM or idle-leave, reconnectAttempts was not reset. GET_STATUS would return 'reconnecting' instead of 'disconnected', showing wrong status in popup and leaving Retry button visible. --- extension/background.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/extension/background.js b/extension/background.js index 7ca9ff9..19ea3dc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -406,6 +406,9 @@ function clearTargetTabForIdle() { async function leaveRoomAfterIdleGrace(reason) { if (!currentRoom) return; connectIntent = false; + reconnectFailed = false; + reconnectAttempts = 0; + chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); emit(EVENTS.LEAVE_ROOM, { peerId }); forceDisconnect(); currentRoom = null; @@ -1500,6 +1503,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { }); } else if (message.type === 'LEAVE_ROOM') { connectIntent = false; + reconnectFailed = false; + reconnectAttempts = 0; + chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); resetAudioProcessingInTab(currentTabId); emit(EVENTS.LEAVE_ROOM, { peerId }); currentRoom = null; From 3f8cf33dc96c153a219dbeafb91e7650403c5877 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:40:15 +0200 Subject: [PATCH 5/8] fix: WEB_JOIN_REQUEST sendResponse leak + popup validation state cleanup - WEB_JOIN_REQUEST: add missing sendResponse when already in target room - popup join: reset isProcessingConnection + clear timeout on validation failures --- extension/background.js | 1 + extension/popup.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/extension/background.js b/extension/background.js index 19ea3dc..89a3ec9 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1571,6 +1571,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { for (const tab of tabs) { chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); } + sendResponse({ status: 'already_joined' }); return; } diff --git a/extension/popup.js b/extension/popup.js index 3a76b5d..d1b9ebf 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1222,6 +1222,8 @@ elements.joinBtn.addEventListener('click', async () => { showError(getMessage('ERR_INVALID_SERVER_URL')); elements.joinBtn.disabled = false; elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); + if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; } + isProcessingConnection = false; return; } if (useCustom && serverUrl) { @@ -1232,6 +1234,7 @@ elements.joinBtn.addEventListener('click', async () => { showError(getMessage('ERR_INVALID_SERVER_URL')); elements.joinBtn.disabled = false; elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); + if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; } isProcessingConnection = false; return; } From b2da17ab6207211fdaf0353e493e8c055d69596c Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 05:39:17 +0200 Subject: [PATCH 6/8] fix(lazy-connect): harden connection lifecycle against race conditions and edge cases - Prevent concurrent connect() by removing isConnecting reset from forceDisconnect(); the guard in connect() now reliably blocks re-entry since no external caller can defeat it. - Reset isConnecting at end of connect() so subsequent calls can proceed without waiting for the async '40' handler. - Clear connectIntent and cancel reconnect timer on server ERROR when no currentRoom exists, preventing infinite reconnect loops after failed join attempts (e.g. wrong password via invite link). - Move connectIntent assignment after !roomId guard in WEB_JOIN_REQUEST to avoid clobbering existing intent on invalid input. - Broadcast JOIN_STATUS failure to popup and bridge tabs when WEB_JOIN_REQUEST receives an invalid room ID, so the website join page receives feedback instead of hanging forever. - Clear joinBtnTimeout on CONNECTION_STATUS connected/disconnected to avoid stale timeout error messages in the popup. --- extension/background.js | 23 ++++++++++++++++++++--- extension/popup.js | 3 +++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/extension/background.js b/extension/background.js index 89a3ec9..9eb8320 100644 --- a/extension/background.js +++ b/extension/background.js @@ -343,7 +343,6 @@ function forceDisconnect() { socket = null; } currentServerUrl = null; - isConnecting = false; isNamespaceJoined = false; isForceSyncInitiator = false; expectedAcksCount = 0; @@ -606,6 +605,8 @@ async function connect() { addLog('WebSocket Error: Connection failed', logType); }; + isConnecting = false; + } catch (e) { isConnecting = false; const logType = reconnectAttempts > 1 ? 'error' : 'warn'; @@ -856,6 +857,14 @@ function handleServerEvent(event, data) { break; case EVENTS.ERROR: isConnecting = false; + // If we get a server error before successfully joining a room, + // clear connectIntent to prevent an infinite reconnect loop. + if (!currentRoom) { + connectIntent = false; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + reconnectAttempts = 0; + reconnectFailed = false; + } broadcastConnectionStatus('disconnected'); addLog(`Server Error: ${data.message}`, 'error'); chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => { @@ -1554,8 +1563,16 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'WEB_JOIN_REQUEST') { const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message; const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : ''; - connectIntent = !!roomId; - if (!roomId) { sendResponse({ error: 'invalid_room_id' }); return; } + if (!roomId) { + const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' }; + chrome.runtime.sendMessage(errMsg).catch(() => {}); + chrome.tabs.query({}, (tabs) => { + tabs.forEach(tab => chrome.tabs.sendMessage(tab.id, errMsg).catch(() => {})); + }); + sendResponse({ status: 'invalid_room_id' }); + return; + } + connectIntent = true; chrome.storage.local.set({ roomId, password, diff --git a/extension/popup.js b/extension/popup.js index d1b9ebf..4f6a142 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1623,6 +1623,9 @@ chrome.runtime.onMessage.addListener((msg) => { updatePeerList(msg.peers); if (msg.peers) detectPeerChanges(msg.peers); } else if (msg.type === 'CONNECTION_STATUS') { + if (msg.status === 'connected' || msg.status === 'disconnected') { + if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; } + } if (msg.status === 'connected') { clearConnectionErrorTimer(); } From 5f0a1894513e29734ac0571ee880989e257587b2 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:35:13 +0200 Subject: [PATCH 7/8] fix(popup): trigger CONNECT when popup opens with saved roomId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If user has a room configured but the background is not connected (e.g. service worker was killed, startup race, or stale state), the popup now sends CONNECT on open. Previously it only called GET_STATUS and showed 'disconnected' with 0 logs — the user had no way to know whether the extension was working. --- extension/popup.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/extension/popup.js b/extension/popup.js index 4f6a142..fcce5b4 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -255,6 +255,13 @@ async function init() { updatePeerList(res.peers); lastKnownPeers = res.peers || []; if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers); + + // If user has a room configured but background is not connected, + // trigger connection now — the popup opening is explicit user intent. + if (res.status === 'disconnected' && localData.roomId) { + chrome.runtime.sendMessage({ type: 'CONNECT' }).catch(() => {}); + applyConnectionStatus('connecting'); + } // Populate Tabs using the background's targetTabId await populateTabs(res.peers, res.targetTabId); From 91aa76e842304c3f10f88cfdf224b335689064bb Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:44:57 +0200 Subject: [PATCH 8/8] fix: revert premature isConnecting reset + remove popup disconnected flicker - background.js: remove isConnecting=false at line 608 (was set before WebSocket handshake completed, defeating the guard for the entire CONNECTING phase). isConnecting is already properly reset in onclose, onopen/40 handler, catch block, and socket guard path. - background.js: restore isConnecting=false in forceDisconnect() so subsequent connect() calls can proceed after intentional disconnect. - popup.js: remove hardcoded applyConnectionStatus('disconnected') that caused a guaranteed red flicker on every popup open. Status is now set only by the async GET_STATUS response. --- extension/background.js | 3 +-- extension/popup.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/extension/background.js b/extension/background.js index 9eb8320..9f44a08 100644 --- a/extension/background.js +++ b/extension/background.js @@ -343,6 +343,7 @@ function forceDisconnect() { socket = null; } currentServerUrl = null; + isConnecting = false; isNamespaceJoined = false; isForceSyncInitiator = false; expectedAcksCount = 0; @@ -605,8 +606,6 @@ async function connect() { addLog('WebSocket Error: Connection failed', logType); }; - isConnecting = false; - } catch (e) { isConnecting = false; const logType = reconnectAttempts > 1 ? 'error' : 'warn'; diff --git a/extension/popup.js b/extension/popup.js index fcce5b4..4c39bc9 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -237,8 +237,7 @@ async function init() { refreshLogs(); refreshHistory(); - // Default connection status (localized) before async check - applyConnectionStatus('disconnected'); + // Initial Status Check (status shows via GET_STATUS below) // Initial Status Check chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {