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