fix(extension): clear stale target after room exit

This commit is contained in:
Timo
2026-08-31 23:43:41 +02:00
parent 1bd9da6bc5
commit 0dd6f5bba5
6 changed files with 198 additions and 56 deletions
+2 -1
View File
@@ -81,7 +81,8 @@ describe('async room-session guards', () => {
for (const [start, end] of [
["message.type === 'CONNECT'", "message.type === 'RETRY_CONNECT'"],
["message.type === 'RETRY_CONNECT'", "message.type === 'GET_STATUS'"],
["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"]
["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"],
["message.type === 'SET_TARGET_TAB'", "message.type === 'LOG'"]
]) {
expect(sourceBetween(start, end)).toContain('await waitForRoomTeardown()');
}
+46 -44
View File
@@ -1142,7 +1142,11 @@ function releaseUnreachableFrameTarget(tabId) {
return true;
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
async function clearTargetSelectionForLifecycle({
expectedTabId = null,
expectedGeneration = null,
markRoomIdle = false
} = {}) {
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
return false;
}
@@ -1150,30 +1154,52 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
return false;
}
const previousTabId = normalizeTabId(currentTabId);
const previousContentTarget = currentContentTarget();
const clearedTabId = normalizeTabId(userSelectedTabId) ?? previousTabId;
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {});
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) {
if (markRoomIdle && currentRoom) {
roomIdleSince = Date.now();
}
chrome.storage.session.set({
resetUserSelectionState();
const cleanupTasks = [clearPendingTarget()];
if (previousTabId !== null) {
cleanupTasks.push(deactivateTargetTab(previousTabId, previousContentTarget));
}
await Promise.all(cleanupTasks);
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
currentTargetFrameId,
currentTargetDocumentId,
currentTargetHasVideo,
roomIdleSince,
lastContentHeartbeatAt
}).catch(() => {});
lastContentHeartbeatAt,
selectedTabId: null,
selectedTabTitle: null,
selectionErrorTabId: null,
selectionErrorMessage: null
});
updateBadgeStatus();
chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId: clearedTabId }).catch(() => {});
return true;
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
return clearTargetSelectionForLifecycle({
expectedTabId,
expectedGeneration,
markRoomIdle: true
});
}
async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left Room' } = {}) {
webJoinCoordinator.invalidate();
connectIntent = false;
@@ -1198,14 +1224,8 @@ async function performRoomSessionTeardown({ notifyServer = false, reason = 'Left
// 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, currentContentTarget());
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
roomIdleSince = null;
lastContentHeartbeatAt = null;
await clearPendingTarget();
await clearTargetSelectionForLifecycle();
isForceSyncInitiator = false;
forceSyncAcks.clear();
@@ -3829,10 +3849,7 @@ async function clearUserSelection(expectedTabId = null) {
&& normalizeTabId(userSelectedTabId) !== normalizeTabId(expectedTabId)) {
return false;
}
userSelectedTabId = null;
userSelectedTabTitle = null;
userSelectionErrorTabId = null;
userSelectionErrorMessage = null;
resetUserSelectionState();
await chrome.storage.session.set({
selectedTabId: null,
selectedTabTitle: null,
@@ -3842,6 +3859,13 @@ async function clearUserSelection(expectedTabId = null) {
return true;
}
function resetUserSelectionState() {
userSelectedTabId = null;
userSelectedTabTitle = null;
userSelectionErrorTabId = null;
userSelectionErrorMessage = null;
}
async function activateTargetTab(tabId, tabTitle, {
requestHostAccess = true,
expectedGeneration = null,
@@ -4359,7 +4383,7 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
}
if (retries >= 3) {
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
clearTargetTabForIdle(tabId, targetGeneration);
await clearTargetTabForIdle(tabId, targetGeneration);
return;
}
@@ -4386,7 +4410,7 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
}
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
clearTargetTabForIdle(tabId, targetGeneration);
await clearTargetTabForIdle(tabId, targetGeneration);
}
}
@@ -5332,31 +5356,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
});
return true;
} else if (message.type === 'SET_TARGET_TAB') {
await waitForRoomTeardown();
if (message.tabId === null || message.tabId === undefined || message.tabId === '') {
const previousTabId = currentTabId;
const previousContentTarget = currentContentTarget();
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
await deactivateTargetTab(previousTabId, previousContentTarget);
}
await clearUserSelection();
await clearPendingTarget();
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
currentTargetHasVideo: false,
roomIdleSince,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
await clearTargetSelectionForLifecycle({ markRoomIdle: true });
sendResponse({ status: 'ok', tabId: null });
return;
}
+2 -1
View File
@@ -5,7 +5,8 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8')
.replace(/\r\n/g, '\n');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const providerSource = fs.readFileSync(path.join(extensionDir, 'page-api-seek-overrides.js'), 'utf8');
+6 -5
View File
@@ -660,7 +660,7 @@ function selectTargetTab(tabId, tabTitle) {
});
}
async function refreshTargetAccessState() {
async function refreshTargetAccessState({ autoSelectMatch = true } = {}) {
const status = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => {
if (chrome.runtime.lastError) {
@@ -682,7 +682,8 @@ async function refreshTargetAccessState() {
}
await populateTabs(
status.peers,
status.targetTabId ?? status.pendingTargetTabId ?? null
status.targetTabId ?? status.pendingTargetTabId ?? null,
autoSelectMatch
);
}
@@ -1214,7 +1215,7 @@ async function resetBlacklistDomains() {
await populateTabs();
}
async function populateTabs(providedPeers = null, providedTargetTabId = null) {
async function populateTabs(providedPeers = null, providedTargetTabId = null, autoSelectMatch = true) {
const token = {};
populateTabsToken = token;
@@ -1335,7 +1336,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
elements.targetTab.value = currentTargetTabId;
} else {
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
if (matchOpt && elements.targetTab.options.length > 1) {
if (autoSelectMatch && matchOpt && elements.targetTab.options.length > 1) {
elements.targetTab.value = matchOpt.value;
const tabTitle = matchOpt.dataset.originalTitle || null;
selectTargetTab(parseInt(matchOpt.value), tabTitle);
@@ -2545,7 +2546,7 @@ chrome.runtime.onMessage.addListener((msg) => {
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'TARGET_TAB_CLEARED') {
refreshTargetAccessState().catch(() => {});
refreshTargetAccessState({ autoSelectMatch: false }).catch(() => {});
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {
+18 -4
View File
@@ -7,6 +7,7 @@ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.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');
@@ -102,11 +103,24 @@ describe('target tab lifecycle', () => {
const teardownStart = backgroundSource.indexOf('async function performRoomSessionTeardown');
const teardownEnd = backgroundSource.indexOf('async function endRoomSession', 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('await clearTargetSelectionForLifecycle()');
expect(teardownSource).toContain('forceDisconnect()');
expect(teardownSource.indexOf('completeForceSyncBeforeTargetChange(null)'))
.toBeLessThan(teardownSource.indexOf('currentRoom = null'));
const clearStart = backgroundSource.indexOf('async function clearTargetSelectionForLifecycle');
const clearEnd = backgroundSource.indexOf('function clearTargetTabForIdle', clearStart);
const clearSource = backgroundSource.slice(clearStart, clearEnd);
expect(clearSource).toContain('const previousTabId = normalizeTabId(currentTabId)');
expect(clearSource).toContain('const previousContentTarget = currentContentTarget()');
expect(clearSource.indexOf('const previousContentTarget = currentContentTarget()'))
.toBeLessThan(clearSource.indexOf('currentTabId = null'));
expect(clearSource).toContain('resetUserSelectionState()');
expect(clearSource).toContain('deactivateTargetTab(previousTabId, previousContentTarget)');
expect(clearSource).toContain('selectedTabId: null');
expect(clearSource).toContain("type: 'TARGET_TAB_CLEARED'");
expect(popupSource).toContain("refreshTargetAccessState({ autoSelectMatch: false })");
expect(popupSource).toContain('if (autoSelectMatch && matchOpt && elements.targetTab.options.length > 1)');
expect(backgroundSource).toContain('await endRoomSession({ notifyServer: true, reason });');
expect(backgroundSource).toContain("await endRoomSession({ notifyServer: true, reason: 'Left Room' });");