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()