fix: multi-tab concurrency and metadata sync improvements

This commit is contained in:
liumangmang
2026-05-30 10:08:55 +08:00
parent 3ab3a5e26f
commit 5268f1119b
3 changed files with 36 additions and 6 deletions
+2
View File
@@ -14,6 +14,8 @@ node_modules/
dist/ dist/
build/ build/
.vite/ .vite/
frontend/auto-imports.d.ts
frontend/components.d.ts
backend/static/ backend/static/
backend/data/ backend/data/
@@ -141,6 +141,8 @@ class BrowserSessionService:
self._sessions[session.id] = session self._sessions[session.id] = session
self._profiles[profile_key] = session.id self._profiles[profile_key] = session.id
self._touch(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 # Register page capture for multi-tab support
context.on("page", lambda p: self._handle_new_page(session, p)) context.on("page", lambda p: self._handle_new_page(session, p))
# Evict again after adding the new session so cap is enforced immediately # 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: def _handle_new_page(self, session: BrowserSession, page: Any) -> None:
"""Capture a new page opened by the remote browser (e.g. target="_blank").""" """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_id = uuid4().hex
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time()) tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
async with session.lock:
session.tabs[tab_id] = tab session.tabs[tab_id] = tab
session.active_tab_id = tab_id session.active_tab_id = tab_id
session.tab_revision += 1 session.tab_revision += 1
logger.info("session %s: captured new tab %s (total: %d)", session.id[:12], tab_id[:8], len(session.tabs)) 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 # 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: async def _init_new_tab(self, session: BrowserSession, tab: BrowserTab) -> None:
try: try:
@@ -399,7 +418,8 @@ class BrowserSessionService:
tabs = [] tabs = []
# We might need to prune closed pages during state generation too # We might need to prune closed pages during state generation too
closed_ids = [] 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(): if tab.page.is_closed():
closed_ids.append(tid) closed_ids.append(tid)
continue continue
@@ -734,6 +754,8 @@ class BrowserSessionService:
) )
self._sessions[session.id] = session self._sessions[session.id] = session
self._touch(session.id) self._touch(session.id)
# Register listeners for the initial tab
self._setup_tab_listeners(session, page)
# Register page capture # Register page capture
context.on("page", lambda p: self._handle_new_page(session, p)) context.on("page", lambda p: self._handle_new_page(session, p))
# Start CDP network capture BEFORE the initial page load, # Start CDP network capture BEFORE the initial page load,
+6
View File
@@ -12,6 +12,8 @@ def service():
def session(service): def session(service):
fake_context = AsyncMock() fake_context = AsyncMock()
fake_page = AsyncMock() fake_page = AsyncMock()
# Playwright's page.on is synchronous
fake_page.on = MagicMock()
fake_page.is_closed = MagicMock(return_value=False) fake_page.is_closed = MagicMock(return_value=False)
fake_page.url = "https://initial.test" fake_page.url = "https://initial.test"
fake_page.title = AsyncMock(return_value="Initial Tab") fake_page.title = AsyncMock(return_value="Initial Tab")
@@ -34,6 +36,7 @@ def session(service):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tab_capture(service, session): async def test_tab_capture(service, session):
new_page = AsyncMock() new_page = AsyncMock()
new_page.on = MagicMock()
new_page.is_closed = MagicMock(return_value=False) new_page.is_closed = MagicMock(return_value=False)
new_page.url = "https://new.test" new_page.url = "https://new.test"
new_page.title = AsyncMock(return_value="New Tab") new_page.title = AsyncMock(return_value="New Tab")
@@ -42,6 +45,9 @@ async def test_tab_capture(service, session):
# Simulate page capture # Simulate page capture
service._handle_new_page(session, new_page) 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 len(session.tabs) == 2
assert session.active_tab_id != "tab1" assert session.active_tab_id != "tab1"
new_tab_id = session.active_tab_id new_tab_id = session.active_tab_id