From 5268f1119bf0cf8907966f8cf34fb4659ce4e0a6 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Sat, 30 May 2026 10:08:55 +0800 Subject: [PATCH] fix: multi-tab concurrency and metadata sync improvements --- .gitignore | 2 ++ .../app/services/browser_session_service.py | 34 +++++++++++++++---- backend/test_browser_tabs.py | 6 ++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index c0f2819..b352520 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ node_modules/ dist/ build/ .vite/ +frontend/auto-imports.d.ts +frontend/components.d.ts backend/static/ backend/data/ diff --git a/backend/app/services/browser_session_service.py b/backend/app/services/browser_session_service.py index 4a1f3e5..89ddfeb 100644 --- a/backend/app/services/browser_session_service.py +++ b/backend/app/services/browser_session_service.py @@ -141,6 +141,8 @@ class BrowserSessionService: self._sessions[session.id] = session self._profiles[profile_key] = session.id self._touch(session.id) + # Register listeners for the initial tab + self._setup_tab_listeners(session, page) # Register page capture for multi-tab support context.on("page", lambda p: self._handle_new_page(session, p)) # Evict again after adding the new session so cap is enforced immediately @@ -160,14 +162,31 @@ class BrowserSessionService: def _handle_new_page(self, session: BrowserSession, page: Any) -> None: """Capture a new page opened by the remote browser (e.g. target="_blank").""" + asyncio.create_task(self._register_new_page(session, page)) + + def _setup_tab_listeners(self, session: BrowserSession, page: Any) -> None: + """Register navigation and state listeners to bump tab_revision.""" + def bump_revision(_=None): + session.tab_revision += 1 + + page.on("domcontentloaded", bump_revision) + page.on("load", bump_revision) + page.on("framenavigated", bump_revision) + page.on("close", bump_revision) + + async def _register_new_page(self, session: BrowserSession, page: Any) -> None: tab_id = uuid4().hex tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time()) - session.tabs[tab_id] = tab - session.active_tab_id = tab_id - session.tab_revision += 1 - logger.info("session %s: captured new tab %s (total: %d)", session.id[:12], tab_id[:8], len(session.tabs)) + + async with session.lock: + session.tabs[tab_id] = tab + session.active_tab_id = tab_id + session.tab_revision += 1 + logger.info("session %s: captured new tab %s (total: %d)", session.id[:12], tab_id[:8], len(session.tabs)) + + self._setup_tab_listeners(session, page) # Best-effort: bring to front and reset zoom - asyncio.create_task(self._init_new_tab(session, tab)) + await self._init_new_tab(session, tab) async def _init_new_tab(self, session: BrowserSession, tab: BrowserTab) -> None: try: @@ -399,7 +418,8 @@ class BrowserSessionService: tabs = [] # We might need to prune closed pages during state generation too closed_ids = [] - for tid, tab in session.tabs.items(): + # Use list() to avoid RuntimeError if tabs dict changes during iteration + for tid, tab in list(session.tabs.items()): if tab.page.is_closed(): closed_ids.append(tid) continue @@ -734,6 +754,8 @@ class BrowserSessionService: ) self._sessions[session.id] = session self._touch(session.id) + # Register listeners for the initial tab + self._setup_tab_listeners(session, page) # Register page capture context.on("page", lambda p: self._handle_new_page(session, p)) # Start CDP network capture BEFORE the initial page load, diff --git a/backend/test_browser_tabs.py b/backend/test_browser_tabs.py index c162e74..78bd12f 100644 --- a/backend/test_browser_tabs.py +++ b/backend/test_browser_tabs.py @@ -12,6 +12,8 @@ def service(): def session(service): fake_context = AsyncMock() fake_page = AsyncMock() + # Playwright's page.on is synchronous + fake_page.on = MagicMock() fake_page.is_closed = MagicMock(return_value=False) fake_page.url = "https://initial.test" fake_page.title = AsyncMock(return_value="Initial Tab") @@ -34,6 +36,7 @@ def session(service): @pytest.mark.asyncio async def test_tab_capture(service, session): new_page = AsyncMock() + new_page.on = MagicMock() new_page.is_closed = MagicMock(return_value=False) new_page.url = "https://new.test" new_page.title = AsyncMock(return_value="New Tab") @@ -42,6 +45,9 @@ async def test_tab_capture(service, session): # Simulate page capture service._handle_new_page(session, new_page) + # Wait for the background registration task to finish + await asyncio.sleep(0.1) + assert len(session.tabs) == 2 assert session.active_tab_id != "tab1" new_tab_id = session.active_tab_id