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