From 204f1d0a8f004186dbb1d0b8010b48f8476b67e6 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:10:41 +0200 Subject: [PATCH] fix: harden room teardown and popup containment --- README.md | 2 +- extension/background.js | 119 ++++++++++-------------- extension/chat-crypto.test.mjs | 5 +- extension/manifest.base.json | 2 +- extension/media-frame-target.js | 1 + extension/media-frame-target.test.mjs | 3 + extension/popup-layout.test.mjs | 20 ++++ extension/popup.html | 14 +++ extension/target-tab-lifecycle.test.mjs | 33 +++++++ package-lock.json | 4 +- package.json | 2 +- scripts/test-server-ws.mjs | 40 ++++++++ server/index.js | 30 ++++-- shared/constants.js | 10 +- tests/e2e/popup-accessibility.spec.mjs | 31 ++++++ website/llms.txt | 2 +- website/template.html | 2 +- website/version.json | 4 +- 18 files changed, 238 insertions(+), 86 deletions(-) create mode 100644 extension/popup-layout.test.mjs diff --git a/README.md b/README.md index d4b449a..fcd8fa1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-
+
diff --git a/extension/background.js b/extension/background.js
index 656e049..c747bbe 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -1,4 +1,4 @@
-import { EVENTS, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
+import { EVENTS, ERROR_CODES, CONTROL_MODES, CAPABILITIES, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT, HEARTBEAT_INTERVAL } from './shared/constants.js';
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
@@ -988,15 +988,19 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
return true;
}
-async function leaveRoomAfterIdleGrace(reason) {
- if (!currentRoom) return;
+async function endRoomSession({ notifyServer = false, reason = 'Left Room' } = {}) {
+ webJoinCoordinator.invalidate();
connectIntent = false;
reconnectFailed = false;
reconnectAttempts = 0;
- chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
+ reconnectStartTime = null;
completeForceSyncBeforeTargetChange(null);
- emit(EVENTS.LEAVE_ROOM, { peerId });
- forceDisconnect();
+ if (notifyServer) emit(EVENTS.LEAVE_ROOM, { peerId });
+
+ // Stop room-specific polling before the content script itself is removed.
+ // Every terminal room exit must pass through the exact target identity while
+ // it is still available, regardless of who initiated the exit.
+ clearEpisodeLobbyState();
currentRoom = null;
clearChatActivity();
controlMode = CONTROL_MODES.EVERYONE;
@@ -1007,15 +1011,22 @@ async function leaveRoomAfterIdleGrace(reason) {
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
- if (currentTabId) await deactivateTargetTab(currentTabId);
+ if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
- clearEpisodeLobbyState();
await clearPendingTarget();
+
+ isForceSyncInitiator = false;
+ forceSyncAcks.clear();
+ expectedAcksCount = 0;
+ if (forceSyncTimeout) {
+ clearTimeout(forceSyncTimeout);
+ forceSyncTimeout = null;
+ }
await chrome.storage.session.set({
currentRoom: null,
chatActivityTimeline: [],
@@ -1026,17 +1037,30 @@ async function leaveRoomAfterIdleGrace(reason) {
currentTargetHasVideo: false,
roomIdleSince: null,
lastContentHeartbeatAt: null,
+ isForceSyncInitiator: false,
+ forceSyncAcks: [],
+ forceSyncDeadline: null,
+ expectedAcksCount: 0,
episodeLobby: null,
- hcmDesynced: false
+ hcmDesynced: false,
+ reconnectFailed: false,
+ reconnectAttempts: 0,
+ reconnectStartTime: null
}).catch(() => {});
chatSecretGuard = '';
invalidateChatSession();
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
- addLog(reason, 'info');
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
+ forceDisconnect();
+ addLog(reason, 'info');
updateBadgeStatus();
}
+async function leaveRoomAfterIdleGrace(reason) {
+ if (!currentRoom) return;
+ await endRoomSession({ notifyServer: true, reason });
+}
+
async function connect() {
if (isConnecting) return;
isConnecting = true;
@@ -1739,6 +1763,13 @@ async function handleServerEvent(event, data) {
}
case EVENTS.ERROR:
isConnecting = false;
+ const terminalRoomError = data.code === ERROR_CODES.ROOM_CLOSED
+ || data.code === ERROR_CODES.PEER_TIMED_OUT
+ || data.message === 'Room closed'
+ || data.message === 'Removed from room after inactivity';
+ if (currentRoom && terminalRoomError) {
+ await endRoomSession({ reason: `Room session ended: ${data.message}` });
+ }
// If we get a server error before successfully joining a room,
// clear persisted credentials as well, otherwise service-worker
// restart would immediately retry the rejected room.
@@ -3340,6 +3371,14 @@ async function selectedMediaTargetMoved(tabId) {
}
return false;
}
+ // A disappearing ad frame can make the parent-visibility handshake
+ // inconclusive while still leaving one hidden mirror as the only video
+ // candidate. Never rebuild toward an unconfirmed nested frame: its monitor
+ // or a later clean probe will announce it again if it is genuinely visible.
+ if (normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true) {
+ refreshMediaFrameMonitors(tabId).catch(() => {});
+ return false;
+ }
if (currentTargetHasVideo !== true) return true;
return normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId)
|| (typeof resolved.documentId === 'string'
@@ -4118,65 +4157,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
sendResponse({ status: 'ok' });
} else if (message.type === 'LEAVE_ROOM') {
- webJoinCoordinator.invalidate();
- completeForceSyncBeforeTargetChange(null);
- connectIntent = false;
- reconnectFailed = false;
- reconnectAttempts = 0;
- chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
- emit(EVENTS.LEAVE_ROOM, { peerId });
- currentRoom = null;
- clearChatActivity();
- controlMode = CONTROL_MODES.EVERYONE;
- hostPeerId = null;
- controllers = [];
- serverCapabilities = [];
- hcmDesynced = false;
- // Notify content.js/popup BEFORE currentTabId is cleared so they drop any
- // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
- broadcastControlMode();
- if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
- invalidateTargetActivations();
- currentTabId = null;
- currentTabTitle = null;
- clearCurrentContentTarget();
- roomIdleSince = null;
- lastContentHeartbeatAt = null;
-
- updateBadgeStatus();
-
- isForceSyncInitiator = false;
- forceSyncAcks.clear();
- expectedAcksCount = 0;
- if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
-
- // Cancel any active episode lobby
- clearEpisodeLobbyState();
- await clearPendingTarget();
-
- chrome.storage.session.set({
- currentRoom: null,
- chatActivityTimeline: [],
- currentTabId: null,
- currentTabTitle: null,
- currentTargetFrameId: 0,
- currentTargetDocumentId: null,
- currentTargetHasVideo: false,
- roomIdleSince: null,
- lastContentHeartbeatAt: null,
- isForceSyncInitiator: false,
- forceSyncAcks: [],
- forceSyncDeadline: null,
- episodeLobby: null,
- expectedAcksCount: 0,
- hcmDesynced: false
- });
- chatSecretGuard = '';
- invalidateChatSession();
- chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
- addLog('Left Room', 'info');
- chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
- forceDisconnect();
+ await endRoomSession({ notifyServer: true, reason: 'Left Room' });
sendResponse({ status: 'ok' });
} else if (message.type === 'CLEAR_LOGS') {
logs = [];
diff --git a/extension/chat-crypto.test.mjs b/extension/chat-crypto.test.mjs
index a2e205d..46caf3b 100644
--- a/extension/chat-crypto.test.mjs
+++ b/extension/chat-crypto.test.mjs
@@ -39,12 +39,15 @@ describe('chat crypto', () => {
const secret = generateChatSecret(webcrypto);
let deriveCalls = 0;
let releaseDerive;
+ let markDeriveStarted;
+ const deriveStarted = new Promise(resolve => { markDeriveStarted = resolve; });
const delayedCrypto = {
...webcrypto,
subtle: {
importKey: (...args) => webcrypto.subtle.importKey(...args),
deriveKey: async (...args) => {
deriveCalls++;
+ markDeriveStarted();
await new Promise(resolve => { releaseDerive = resolve; });
return webcrypto.subtle.deriveKey(...args);
}
@@ -52,7 +55,7 @@ describe('chat crypto', () => {
};
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
- await Promise.resolve();
+ await deriveStarted;
expect(deriveCalls).toBe(1);
clearChatKeyCache();
releaseDerive();
diff --git a/extension/manifest.base.json b/extension/manifest.base.json
index 960a184..126c82f 100644
--- a/extension/manifest.base.json
+++ b/extension/manifest.base.json
@@ -3,7 +3,7 @@
"default_locale": "en",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
- "version": "3.1.4",
+ "version": "3.1.5",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js
index bab8780..cf403c8 100644
--- a/extension/media-frame-target.js
+++ b/extension/media-frame-target.js
@@ -467,6 +467,7 @@ function contentTarget(tabId, selected, discoveredFrameIds = null) {
documentId,
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
hasVideo: !!selected?.result?.bestVideo,
+ visibilityConfirmed: selected?.result?.parentFrameVisible === true,
scriptTarget: documentId
? { tabId, documentIds: [documentId] }
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs
index 3695a5d..03a0570 100644
--- a/extension/media-frame-target.test.mjs
+++ b/extension/media-frame-target.test.mjs
@@ -184,6 +184,7 @@ describe('cross-origin media-frame targeting', () => {
documentId: 'document-8',
frameUrl: 'https://player-8.example/embed',
hasVideo: true,
+ visibilityConfirmed: true,
// Reported back so the caller can address these frames directly when
// a later all-frames sweep is rejected wholesale.
discoveredFrameIds: [0, 8],
@@ -242,6 +243,7 @@ describe('cross-origin media-frame targeting', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
+ visibilityConfirmed: false,
discoveredFrameIds: [0, 6],
scriptTarget: { tabId: 42 }
});
@@ -582,6 +584,7 @@ describe('embedded player access diagnosis', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
+ visibilityConfirmed: false,
discoveredFrameIds: [0],
scriptTarget: { tabId: 42 }
});
diff --git a/extension/popup-layout.test.mjs b/extension/popup-layout.test.mjs
new file mode 100644
index 0000000..aad9f1a
--- /dev/null
+++ b/extension/popup-layout.test.mjs
@@ -0,0 +1,20 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+const extensionDir = path.dirname(fileURLToPath(import.meta.url));
+const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.html'), 'utf8');
+
+describe('popup layout containment', () => {
+ it('prevents dynamic descendants from changing the 360px popup width', () => {
+ expect(popupSource).toMatch(/html\s*\{[^}]*width:\s*360px;/s);
+ expect(popupSource).toMatch(/html\s*\{[^}]*min-width:\s*360px;/s);
+ expect(popupSource).toMatch(/html\s*\{[^}]*max-width:\s*360px;/s);
+ expect(popupSource).toMatch(/html\s*\{[^}]*overflow-x:\s*hidden;/s);
+ expect(popupSource).toMatch(/body\s*\{[^}]*width:\s*360px;/s);
+ expect(popupSource).toMatch(/body\s*\{[^}]*max-width:\s*360px;/s);
+ expect(popupSource).toMatch(/body\s*\{[^}]*contain:\s*inline-size;/s);
+ expect(popupSource).toMatch(/body\s*\{[^}]*overflow-x:\s*hidden;/s);
+ });
+});
diff --git a/extension/popup.html b/extension/popup.html
index 05af8db..a35fe54 100644
--- a/extension/popup.html
+++ b/extension/popup.html
@@ -180,11 +180,25 @@
color-scheme: light;
}
+ /* Defensive popup boundary: descendants may be populated dynamically
+ after Chrome has measured the action popup. Keep their intrinsic
+ width from resizing the popup window while preserving normal vertical
+ layout and scrolling inside the established 360px surface. */
+ html {
+ width: 360px;
+ min-width: 360px;
+ max-width: 360px;
+ overflow-x: hidden;
+ }
+
body {
width: 360px;
+ max-width: 360px;
margin: 0;
padding: 18px;
box-sizing: border-box;
+ contain: inline-size;
+ overflow-x: hidden;
background: var(--bg);
color: var(--text);
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs
index 1611219..9ff87a0 100644
--- a/extension/target-tab-lifecycle.test.mjs
+++ b/extension/target-tab-lifecycle.test.mjs
@@ -9,6 +9,8 @@ const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'ut
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
+const sharedConstantsSource = fs.readFileSync(path.join(extensionDir, '..', 'shared', 'constants.js'), 'utf8');
+const serverSource = fs.readFileSync(path.join(extensionDir, '..', 'server', 'index.js'), 'utf8');
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
@@ -96,6 +98,37 @@ describe('target tab lifecycle', () => {
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
+ it('routes every terminal room exit through the full target unhook', () => {
+ const teardownStart = backgroundSource.indexOf('async function endRoomSession');
+ const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
+ const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
+ expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
+ expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
+ .toBeLessThan(teardownSource.indexOf('currentTabId = null'));
+ expect(teardownSource).toContain('await clearPendingTarget()');
+ expect(teardownSource).toContain('forceDisconnect()');
+
+ expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
+ expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");
+ expect(backgroundSource).toContain('data.code === ERROR_CODES.ROOM_CLOSED');
+ expect(backgroundSource).toContain('data.code === ERROR_CODES.PEER_TIMED_OUT');
+ expect(backgroundSource).toContain("data.message === 'Room closed'");
+ expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
+ expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
+
+ expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
+ expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
+ expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
+ expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
+ expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
+ });
+
+ it('does not promote a nested media target without confirmed parent visibility', () => {
+ expect(backgroundSource).toContain(
+ 'normalizeFrameId(resolved.frameId) !== 0 && resolved.visibilityConfirmed !== true'
+ );
+ });
+
it('removes monitors injected by a superseded cross-tab activation', () => {
expect(backgroundSource).toContain('function isTargetActivationSuperseded(tabId, activationGeneration)');
expect(backgroundSource).toMatch(/navigationRetries: navigationRetries - 1,\s*activationGeneration\s*\}\)/);
diff --git a/package-lock.json b/package-lock.json
index 63b6f7a..a5f9d4f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "koalasync",
- "version": "3.1.4",
+ "version": "3.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "koalasync",
- "version": "3.1.4",
+ "version": "3.1.5",
"devDependencies": {
"@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.10",
diff --git a/package.json b/package.json
index 1f2a46e..5370a5e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "koalasync",
- "version": "3.1.4",
+ "version": "3.1.5",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs
index 3b7a509..7881b6f 100644
--- a/scripts/test-server-ws.mjs
+++ b/scripts/test-server-ws.mjs
@@ -67,6 +67,25 @@ try {
close();
resetConnectionRate();
+ // --- Stale peer reaper: terminal timeout + clean rejoin ---
+ const staleClient = await c();
+ const staleRoomId = 'stale-'+Date.now();
+ await j(staleClient, staleRoomId, 'stale-peer');
+ staleClient._m.length = 0;
+ const staleRoom = mod.rooms.get(staleRoomId);
+ staleRoom.peerData.values().next().value.lastSeen = 1;
+ mod.cleanupInactiveRooms(Date.now());
+ const [staleEvent, staleData] = await a(staleClient);
+ assert.equal(staleEvent, 'error');
+ assert.equal(staleData.code, 'peer_timed_out');
+ assert.equal(staleData.message, 'Removed from room after inactivity');
+ assert.equal(mod.rooms.has(staleRoomId), false, 'stale peer room is deleted');
+ staleClient._m.length = 0;
+ await j(staleClient, staleRoomId, 'stale-peer');
+ assert.equal(mod.rooms.has(staleRoomId), true, 'stale peer can rejoin cleanly');
+ close();
+ resetConnectionRate();
+
// --- Capabilities: ROOM_DATA advertises server features for client detection ---
const capClient = await c();
s(capClient, 'join_room', { roomId: 'cap-'+Date.now(), peerId: 'capp', protocolVersion: '1.0.0' });
@@ -80,6 +99,27 @@ try {
close();
resetConnectionRate();
+ // --- Terminal room timeout: coded error + complete membership cleanup ---
+ const timeoutClient = await c();
+ const timeoutRoomId = 'timeout-'+Date.now();
+ await j(timeoutClient, timeoutRoomId, 'timeout-peer');
+ timeoutClient._m.length = 0;
+ mod.rooms.get(timeoutRoomId).lastActivity = 0;
+ mod.cleanupInactiveRooms(Date.now());
+ const [timeoutEvent, timeoutData] = await a(timeoutClient);
+ assert.equal(timeoutEvent, 'error');
+ assert.equal(timeoutData.code, 'room_closed');
+ assert.equal(timeoutData.message, 'Room closed');
+ assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
+ timeoutClient._m.length = 0;
+
+ // The same connected socket must be able to join that room again. This
+ // proves timeout cleanup removed its stale socketToRoom membership.
+ await j(timeoutClient, timeoutRoomId, 'timeout-peer');
+ assert.equal(mod.rooms.has(timeoutRoomId), true, 'timed-out peer can rejoin cleanly');
+ close();
+ resetConnectionRate();
+
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c();
diff --git a/server/index.js b/server/index.js
index e98da03..fc5bd88 100644
--- a/server/index.js
+++ b/server/index.js
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
-import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
+import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
buildHealthPayload,
@@ -922,8 +922,7 @@ io.on('connection', (socket) => {
});
// Active Room & Dead Peer Cleanup (Every 2m)
-const roomCleanupInterval = setInterval(() => {
- const now = Date.now();
+export function cleanupInactiveRooms(now = Date.now()) {
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
@@ -942,7 +941,13 @@ const roomCleanupInterval = setInterval(() => {
}
for (const sid of staleSids) {
const deadSocket = io.sockets?.sockets?.get(sid);
- if (deadSocket) deadSocket.leave(roomId);
+ if (deadSocket) {
+ deadSocket.emit(EVENTS.ERROR, {
+ code: ERROR_CODES.PEER_TIMED_OUT,
+ message: 'Removed from room after inactivity'
+ });
+ deadSocket.leave(roomId);
+ }
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
try {
removePeerFromRoom(sid, roomId, 'reaper');
@@ -954,12 +959,25 @@ const roomCleanupInterval = setInterval(() => {
// 2. Prune empty or inactive rooms
const currentRoom = rooms.get(roomId);
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
- io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
+ io.to(roomId).emit(EVENTS.ERROR, {
+ code: ERROR_CODES.ROOM_CLOSED,
+ message: 'Room closed'
+ });
+ // A terminal room timeout is a real leave for every member. Clear
+ // the same socket/peer indexes as an explicit leave so a later join
+ // cannot be mistaken for the stale membership.
+ for (const sid of Array.from(currentRoom.peers)) {
+ const memberSocket = io.sockets?.sockets?.get(sid);
+ if (memberSocket) memberSocket.leave(roomId);
+ removePeerFromRoom(sid, roomId, 'room-timeout');
+ }
rooms.delete(roomId);
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
}
}
-}, 2 * 60 * 1000);
+}
+
+const roomCleanupInterval = setInterval(cleanupInactiveRooms, 2 * 60 * 1000);
export function startServer(port = PORT, host) {
if (httpServer.listening) return Promise.resolve(httpServer);
diff --git a/shared/constants.js b/shared/constants.js
index 7cf8361..ee43007 100644
--- a/shared/constants.js
+++ b/shared/constants.js
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
-export const APP_VERSION = "3.1.4";
+export const APP_VERSION = "3.1.5";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
@@ -65,6 +65,14 @@ export const EVENTS = {
PONG: "pong" // server responds with same { t } for client RTT calculation
};
+// Stable server error identifiers. Clients must branch on these codes instead
+// of localized or user-facing message text whenever the error changes session
+// state.
+export const ERROR_CODES = {
+ ROOM_CLOSED: 'room_closed',
+ PEER_TIMED_OUT: 'peer_timed_out'
+};
+
// Room control modes (Host Control Mode feature).
// NOTE: content.js does not import this module — it uses the string literals
// 'everyone' / 'host-only' directly. Keep these values in sync there.
diff --git a/tests/e2e/popup-accessibility.spec.mjs b/tests/e2e/popup-accessibility.spec.mjs
index c86e3e6..b2e6c52 100644
--- a/tests/e2e/popup-accessibility.spec.mjs
+++ b/tests/e2e/popup-accessibility.spec.mjs
@@ -27,3 +27,34 @@ test('popup exposes names and keyboard access for every visible control', async
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('#tab-settings')).toBeVisible();
});
+
+test('popup root remains 360px when a dynamic child overflows', async ({ context, extensionId }) => {
+ const page = await openPopup(context, extensionId, { openEditor: false });
+ const geometry = await page.evaluate(() => {
+ const probe = document.createElement('div');
+ probe.id = 'popup-overflow-probe';
+ probe.style.width = '1200px';
+ probe.style.height = '1px';
+ document.body.appendChild(probe);
+
+ const htmlStyle = window.getComputedStyle(document.documentElement);
+ const bodyStyle = window.getComputedStyle(document.body);
+ return {
+ htmlWidth: document.documentElement.getBoundingClientRect().width,
+ bodyWidth: document.body.getBoundingClientRect().width,
+ htmlOverflowX: htmlStyle.overflowX,
+ bodyOverflowX: bodyStyle.overflowX,
+ bodyContain: bodyStyle.contain,
+ probeWidth: probe.getBoundingClientRect().width
+ };
+ });
+
+ expect(geometry).toEqual({
+ htmlWidth: 360,
+ bodyWidth: 360,
+ htmlOverflowX: 'hidden',
+ bodyOverflowX: 'hidden',
+ bodyContain: 'inline-size',
+ probeWidth: 1200
+ });
+});
diff --git a/website/llms.txt b/website/llms.txt
index 6401891..5ec8ba4 100644
--- a/website/llms.txt
+++ b/website/llms.txt
@@ -87,7 +87,7 @@ Compatibility depends on each website's player implementation and can change whe
## Technical information
-- Current website release: 3.1.4
+- Current website release: 3.1.5
- License: MIT
- Extension runtime: dependency-free browser extension code
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
diff --git a/website/template.html b/website/template.html
index 41a316a..b204377 100644
--- a/website/template.html
+++ b/website/template.html
@@ -116,7 +116,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
- "softwareVersion": "3.1.4",
+ "softwareVersion": "3.1.5",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
diff --git a/website/version.json b/website/version.json
index fed662e..1a06b0e 100644
--- a/website/version.json
+++ b/website/version.json
@@ -1,4 +1,4 @@
{
- "version": "3.1.4",
- "date": "2026-08-21T10:33:30Z"
+ "version": "3.1.5",
+ "date": "2026-08-24T21:56:28Z"
}