Harden browser frame push completion paths

Wake the WebSocket screencast supervisor when the runtime frame task stops before delivering a consumer frame, and keep fatal frame exits eligible for normal CDP teardown. Avoid resizing the Browser canvas backing store on every frame when bitmap dimensions are unchanged, preventing unnecessary clears and reallocations.

Alessandro committed Jul 3, 2026 at 11:22 UTC 153ffcc22161d0495447a4872fd7e20f3a392681
4 files changed +87 -7
plugins/_browser/api/ws_browser.py
+4 -1
@@ -477,7 +477,10 @@ class WsBrowser(WsHandler):
477 def frame_consumer(frame: dict[str, Any]):
478 return asyncio.run_coroutine_threadsafe(emit_frame(frame), server_loop)
479
480 - await runtime.call("attach_screencast_consumer", stream_id, frame_consumer)
480 + def stop_consumer() -> None:
481 + server_loop.call_soon_threadsafe(stop_event.set)
482 +
483 + await runtime.call("attach_screencast_consumer", stream_id, frame_consumer, stop_consumer)
484
485 while True:
486 if stop_event.is_set():
plugins/_browser/helpers/runtime.py
+23 -4
@@ -318,8 +318,10 @@ class _BrowserScreencast:
318 self.session = session
319 self.mime = mime
320 self.frame_consumer: Any | None = None
321 + self.stop_callback: Any | None = None
322 self.queue = asyncio.Queue(maxsize=1)
323 self.stopped = False
324 + self._closed = False
325 self._ack_tasks: set[asyncio.Task] = set()
326 self._expected_width = 0
327 self._expected_height = 0
@@ -399,15 +401,17 @@ class _BrowserScreencast:
401 raise RuntimeError("Browser screencast stopped.")
402 return frame
403
402 - async def attach_consumer(self, frame_consumer: Any) -> None:
404 + async def attach_consumer(self, frame_consumer: Any, stop_callback: Any | None = None) -> None:
405 self.frame_consumer = frame_consumer
406 + self.stop_callback = stop_callback
407 frame = await self.pop_frame()
408 if frame:
409 await self._deliver_frame(frame)
410
411 async def stop(self) -> None:
409 - if self.stopped:
412 + if self._closed:
413 return
414 + self._closed = True
415 self.stopped = True
416 self._drop_queued_frames()
417 with contextlib.suppress(asyncio.QueueFull):
@@ -431,6 +435,7 @@ class _BrowserScreencast:
435
436 async def _handle_frame(self, params: dict[str, Any]) -> None:
437 stop_after_ack = False
438 + notify_stop = False
439 try:
440 data = params.get("data") or ""
441 if data:
@@ -453,6 +458,7 @@ class _BrowserScreencast:
458 except Exception:
459 if self.frame_consumer:
460 stop_after_ack = True
461 + notify_stop = True
462 else:
463 raise
464 finally:
@@ -465,6 +471,14 @@ class _BrowserScreencast:
471 )
472 if stop_after_ack:
473 self.stopped = True
474 + if notify_stop:
475 + self._notify_stopped()
476 +
477 + def _notify_stopped(self) -> None:
478 + if not self.stop_callback:
479 + return
480 + with contextlib.suppress(Exception):
481 + self.stop_callback()
482
483 async def _deliver_frame(self, frame: dict[str, Any]) -> None:
484 if not self.frame_consumer:
@@ -1694,11 +1708,16 @@ class _BrowserRuntimeCore:
1708 raise KeyError("Browser screencast is not active.")
1709 return await screencast.pop_frame()
1710
1697 - async def attach_screencast_consumer(self, stream_id: str, frame_consumer: Any) -> None:
1711 + async def attach_screencast_consumer(
1712 + self,
1713 + stream_id: str,
1714 + frame_consumer: Any,
1715 + stop_callback: Any | None = None,
1716 + ) -> None:
1717 screencast = self.screencasts.get(str(stream_id or ""))
1718 if not screencast:
1719 raise KeyError("Browser screencast is not active.")
1701 - await screencast.attach_consumer(frame_consumer)
1720 + await screencast.attach_consumer(frame_consumer, stop_callback)
1721
1722 async def stop_screencast(self, stream_id: str) -> None:
1723 screencast = self.screencasts.pop(str(stream_id or ""), None)
plugins/_browser/webui/browser-store.js
+2 -2
@@ -1423,8 +1423,8 @@ const model = {
1423 paintFrameBitmap(bitmap) {
1424 const canvas = this._frameCanvas;
1425 if (!canvas || !bitmap?.width || !bitmap?.height) return false;
1426 - canvas.width = bitmap.width;
1427 - canvas.height = bitmap.height;
1426 + if (canvas.width !== bitmap.width) canvas.width = bitmap.width;
1427 + if (canvas.height !== bitmap.height) canvas.height = bitmap.height;
1428 const context = canvas.getContext("2d");
1429 if (!context) return false;
1430 context.drawImage(bitmap, 0, 0);
tests/test_browser_agent_regressions.py
+58
@@ -1180,6 +1180,9 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1180 assert '"Page.screencastFrameAck"' in runtime
1181 assert "async def attach_screencast_consumer" in runtime
1182 assert "async def attach_consumer" in runtime
1183 + assert "stop_callback: Any | None = None" in runtime
1184 + assert "def _notify_stopped(self) -> None:" in runtime
1185 + assert "server_loop.call_soon_threadsafe(stop_event.set)" in ws_browser
1186 assert "await self._deliver_frame(" in runtime
1187 assert "await asyncio.wrap_future(future)" in runtime
1188 assert '"Page.stopScreencast"' in runtime
@@ -1213,6 +1216,7 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1216 assert "let dimensions = options?.dimensions || null" in browser_store
1217 assert "dimensions ||= await loadFrameDimensions(frameSrc)" in browser_store
1218 assert "paintFrameBitmap(bitmap)" in browser_store
1219 + assert "if (canvas.width !== bitmap.width) canvas.width = bitmap.width;" in browser_store
1220 assert "freezeCanvasFrameToImage()" in browser_store
1221 assert 'this._frameCanvas.toDataURL("image/jpeg", 0.86)' in browser_store
1222 assert "this.frameSrc = frameSrc;" in browser_store
@@ -1697,6 +1701,60 @@ async def test_browser_screencast_acks_after_consumer_settles():
1701 await screencast.stop()
1702
1703
1704 +@pytest.mark.anyio
1705 +async def test_browser_screencast_notifies_consumer_when_frame_task_stops_before_delivery():
1706 + class FakeSession:
1707 + def __init__(self):
1708 + self.handlers = {}
1709 + self.sent = []
1710 + self.detached = False
1711 +
1712 + def on(self, event, handler):
1713 + self.handlers[event] = handler
1714 +
1715 + async def send(self, method, params=None):
1716 + self.sent.append((method, params or {}))
1717 +
1718 + async def detach(self):
1719 + self.detached = True
1720 +
1721 + session = FakeSession()
1722 + delivered = []
1723 + stopped = []
1724 + screencast = _BrowserScreencast(
1725 + stream_id="stream",
1726 + browser_id=7,
1727 + session=session,
1728 + mime="image/jpeg",
1729 + )
1730 +
1731 + await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 640, "height": 480})
1732 + await screencast.attach_consumer(
1733 + lambda frame: delivered.append(frame),
1734 + lambda: stopped.append(True),
1735 + )
1736 +
1737 + def fail_jpeg_probe(_data):
1738 + raise RuntimeError("jpeg probe failed")
1739 +
1740 + screencast._jpeg_size = fail_jpeg_probe
1741 + session.handlers["Page.screencastFrame"](
1742 + {"data": SMALL_JPEG_10X10, "metadata": {}, "sessionId": 29}
1743 + )
1744 + await asyncio.sleep(0)
1745 + await asyncio.sleep(0)
1746 +
1747 + assert delivered == []
1748 + assert stopped == [True]
1749 + assert screencast.stopped is True
1750 + assert ("Page.screencastFrameAck", {"sessionId": 29}) in session.sent
1751 +
1752 + await screencast.stop()
1753 +
1754 + assert ("Page.stopScreencast", {}) in session.sent
1755 + assert session.detached is True
1756 +
1757 +
1758 @pytest.mark.anyio
1759 async def test_browser_screencast_attach_consumer_flushes_queued_frame():
1760 class FakeSession: