@samitouri / QOSami_Agents_Zero / commits / d3750096
Fork 0

Agent Zero AI framework

Restart browser runtime after stale context

Detect cached Playwright contexts that have already closed before reusing the browser runtime. Clear stale browser pages, popup waiters, screencasts, and interaction state; stop the old Playwright instance; and restart cleanly on next use. Add regression coverage for stale context recovery and unexpected context close events.

Alessandro committed May 3, 2026 at 01:55 UTC d37500967f8cd9a6b6d5cb7bc8ecd0adbac72af5
2 files changed +113 -2
plugins/_browser/helpers/runtime.py
+62 -2
@@ -658,17 +658,70 @@ class _BrowserRuntimeCore:
658 return Path(files.get_abs_path("usr/downloads/browser"))
659
660 async def ensure_started(self) -> None:
661 - if self.context:
661 + if self._context_is_alive():
662 return
663 + if self.context:
664 + await self._discard_stale_context("Browser context is stale; restarting.")
665
666 if self._start_lock is None:
667 self._start_lock = asyncio.Lock()
668
669 async with self._start_lock:
668 - if self.context:
670 + if self._context_is_alive():
671 return
672 + if self.context:
673 + await self._discard_stale_context("Browser context is stale; restarting.")
674 + elif self.playwright and not self._closing:
675 + await self._stop_playwright("Browser context closed; restarting Playwright.")
676 await self._start()
677
678 + def _context_is_alive(self) -> bool:
679 + if not self.context:
680 + return False
681 + try:
682 + pages = getattr(self.context, "pages")
683 + len(pages() if callable(pages) else pages)
684 + return True
685 + except AttributeError:
686 + # Lightweight test doubles may not model Playwright's pages property.
687 + return True
688 + except Exception:
689 + return False
690 +
691 + async def _discard_stale_context(self, message: str) -> None:
692 + PrintStyle.warning(message)
693 + self._discard_context_state()
694 + await self._stop_playwright("Playwright stop after Browser context loss failed")
695 +
696 + def _discard_context_state(self) -> None:
697 + for waiter in self._pending_popups:
698 + if not waiter.done():
699 + waiter.set_exception(RuntimeError("Browser context closed."))
700 + self._pending_popups.clear()
701 + self._background_popup_pages.clear()
702 + self.pages.clear()
703 + self.last_interacted_browser_id = None
704 + for screencast in self.screencasts.values():
705 + screencast.stopped = True
706 + screencast._drop_queued_frames()
707 + with contextlib.suppress(asyncio.QueueFull):
708 + screencast.queue.put_nowait(None)
709 + for task in list(screencast._ack_tasks):
710 + task.cancel()
711 + screencast._ack_tasks.clear()
712 + self.screencasts.clear()
713 + self.context = None
714 +
715 + async def _stop_playwright(self, warning: str) -> None:
716 + if not self.playwright:
717 + return
718 + try:
719 + await self.playwright.stop()
720 + except Exception as exc:
721 + PrintStyle.warning(f"{warning}: {exc}")
722 + finally:
723 + self.playwright = None
724 +
725 async def _start(self) -> None:
726 from playwright.async_api import async_playwright
727
@@ -711,6 +764,7 @@ class _BrowserRuntimeCore:
764 raise
765 self.context.set_default_timeout(30000)
766 self.context.set_default_navigation_timeout(30000)
767 + self.context.on("close", self._on_context_closed)
768 self.context.on("page", self._on_new_page_sync)
769 await self.context.add_init_script(self._shadow_dom_script())
770 await self.context.add_init_script(path=str(CONTENT_HELPER_PATH))
@@ -1507,6 +1561,12 @@ class _BrowserRuntimeCore:
1561 if delete_profile:
1562 shutil.rmtree(self.profile_dir, ignore_errors=True)
1563
1564 + def _on_context_closed(self) -> None:
1565 + if self._closing or self.context is None:
1566 + return
1567 + PrintStyle.warning("Browser context closed unexpectedly; will restart on next use.")
1568 + self._discard_context_state()
1569 +
1570 async def _reference_action(
1571 self,
1572 helper_method: str,
tests/test_browser_agent_regressions.py
+51
@@ -1174,6 +1174,57 @@ def test_browser_runtime_removes_stale_profile_singletons(monkeypatch, tmp_path)
1174 )
1175
1176
1177 +@pytest.mark.anyio
1178 +async def test_browser_runtime_restarts_when_cached_context_is_stale():
1179 + starts = []
1180 + stopped = []
1181 +
1182 + class StaleContext:
1183 + @property
1184 + def pages(self):
1185 + raise RuntimeError("Target page, context or browser has been closed")
1186 +
1187 + class LiveContext:
1188 + pages = []
1189 +
1190 + class FakePlaywright:
1191 + async def stop(self):
1192 + stopped.append(True)
1193 +
1194 + core = _BrowserRuntimeCore("ctx")
1195 + core.context = StaleContext()
1196 + core.playwright = FakePlaywright()
1197 + core.pages[4] = browser_runtime_module.BrowserPage(id=4, page=object())
1198 + core.last_interacted_browser_id = 4
1199 +
1200 + async def fake_start():
1201 + starts.append(True)
1202 + core.context = LiveContext()
1203 +
1204 + core._start = fake_start
1205 +
1206 + await core.ensure_started()
1207 +
1208 + assert starts == [True]
1209 + assert stopped == [True]
1210 + assert isinstance(core.context, LiveContext)
1211 + assert core.pages == {}
1212 + assert core.last_interacted_browser_id is None
1213 +
1214 +
1215 +def test_browser_runtime_context_close_event_clears_cached_state():
1216 + core = _BrowserRuntimeCore("ctx")
1217 + core.context = object()
1218 + core.pages[4] = browser_runtime_module.BrowserPage(id=4, page=object())
1219 + core.last_interacted_browser_id = 4
1220 +
1221 + core._on_context_closed()
1222 +
1223 + assert core.context is None
1224 + assert core.pages == {}
1225 + assert core.last_interacted_browser_id is None
1226 +
1227 +
1228 def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch, tmp_path):
1229 extension_dir = tmp_path / "extension"
1230 extension_dir.mkdir()