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: