Skip to content

feat(browser): switch screencast stream from poll-JPEG to CDP screencast frames - #505

Open
angri450 wants to merge 4 commits into
TencentCloud:developfrom
angri450:feature/browser-screencast-stream
Open

feat(browser): switch screencast stream from poll-JPEG to CDP screencast frames#505
angri450 wants to merge 4 commits into
TencentCloud:developfrom
angri450:feature/browser-screencast-stream

Conversation

@angri450

Copy link
Copy Markdown

Summary

The dashboard browser stream currently polls Page.captureScreenshot every 250ms (4fps ceiling, 1.5s per-capture timeout). On headless servers this is both laggy (fixed 4fps, captures can hang on slow pages) and wasteful (full-viewport JPEG every tick even when nothing changed).

This PR switches the stream to event-driven Page.startScreencast frames, the same mechanism DevTools itself uses:

  • Frames are pushed only when page content changes: static pages cost zero frames; interactive pages follow the real frame rate instead of a hard 4fps cap.
  • The frame size follows the viewport (no maxWidth/maxHeight), keeping canvas coordinates 1:1 with CDP input coordinates — fixes pointer drift that a shrunk frame causes.
  • Every screencastFrame is acked via its sessionId (required, otherwise Chrome stops sending frames).
  • The listener is re-registered when a tab switch replaces the CDP client (the session reconnects to a new page target).
  • The poll loop remains as a fallback for CDP endpoints without screencast support.

Wire protocol is unchanged: the dashboard still receives {"type":"frame","data":...}, so no frontend changes are required.

Motivation

Reported in #485: the harness-browser Chromium stays resident forever; and the poll-based stream makes the workbench browser feel unusable on small VPSes (4fps + 1.5s timeout hangs). This PR addresses the streaming half. An idle-timeout for the resident browser is a separate follow-up.

Test plan

Verified end-to-end against a live Octop 0.9.28 instance (headless Chromium 151):

  1. Initial frame arrives immediately after start (sub-second).
  2. resize (Emulation.setDeviceMetricsOverride) adapts frame size automatically — no stream restart needed.
  3. Static page: zero frames after the initial one (vs. 4 forced JPEGs/sec before).
  4. Pointer events: coordinates 1:1 with the frame (slider captcha drag works).

Unit tests for the stream module (test_browser_stream_listen.py, test_browser_stream_mouse.py) should remain green — the listen-only loop is untouched, and the mouse dispatch path is unchanged.

Copilot AI lite review requested due to automatic review settings August 31, 2026 09:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Tab 切换导致 CDP client 更换时,旧 screencast handler/会话的清理与 ack 连接绑定存在问题,可能引发帧停更与 handler 泄漏。

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

这个 PR 将 dashboard 的浏览器画面推流从“每 250ms 轮询 Page.captureScreenshot”切换为基于 CDP 的事件驱动 Page.startScreencast 帧推送,并保留轮询作为不支持 screencast 的 CDP 端点的 fallback。整体目标是在 headless/小 VPS 场景下显著降低无变化页面的 CPU/带宽浪费、避免截图超时带来的卡顿,并让帧率随页面变化而变化。

Changes:

  • 新增 Page.screencastFrame 监听器:推送 {"type":"frame"} 并对每帧 sessionIdPage.screencastFrameAck
  • stream loop 改为:screencast(主路径)+ 1s 一次 session/tabs snapshot + 轮询截图 fallback
  • 支持 tab switch 时检测 CDP client 变化并尝试重新启动 screencast
File summaries
File Description
src/octop/api/routers/browser/stream.py 将浏览器 WS 推流从轮询 JPEG 改为基于 CDP screencast 的事件驱动帧推送,并在不支持时回退到轮询截图
Review details

Suppressed comments (1)

src/octop/api/routers/browser/stream.py:239

  • On tab switch, _stream_loop starts a new screencast on the new CDP client but never detaches the old Page.screencastFrame handler or stops the old screencast. This can leak handlers and leave the previous target streaming until Chrome stalls, and it also makes the _screencast_handlers map grow over time. Detach/stop the previous client before switching to the new one.
            # A tab switch replaces the CDP client, so restart screencast on
            # the new client to keep frames flowing.
            current_client = sess._internal.client  # noqa: SLF001
            if not listen_only and current_client is not last_client:
                last_client = current_client
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +64 to +88
def _make_screencast_handler(sess: Any, ws: WebSocket) -> Any:
"""Construct a Page.screencastFrame listener: forward frame + ack.

The ack is sent through ``sess._internal.client`` (the *current* client)
rather than the one captured at registration, so frames keep being acked
after a tab switch replaces the CDP client.
"""
last_sent = 0.0

async def _on_frame(params: dict[str, Any]) -> None:
nonlocal last_sent
data = params.get("data")
sid = params.get("sessionId")
now = time.monotonic()
if data and (now - last_sent) >= _SCREENCAST_MIN_INTERVAL_S:
last_sent = now
await _send_json(ws, {"type": "frame", "data": data})
# Must ack regardless of forwarding, otherwise Chrome stops sending
# subsequent screencast frames.
if sid is not None:
with contextlib.suppress(Exception):
await sess._internal.client.send( # noqa: SLF001
"Page.screencastFrameAck", {"sessionId": sid}
)

…ast frames

Replace the 4fps poll loop (Page.captureScreenshot + 1.5s timeout) in
browser-stream with event-driven Page.startScreencast frames:

- Frames are pushed only when the page content changes: static pages
  cost zero frames, interactive pages follow the actual frame rate
  instead of a hard 4fps ceiling.
- Frame size follows the viewport (no maxWidth/maxHeight), keeping
  canvas coordinates 1:1 with CDP input coordinates - fixes pointer
  drift that a shrunk screencast frame causes.
- ACK every screencastFrame via its sessionId, otherwise Chrome stops
  sending frames.
- The screencast listener is re-registered when a tab switch replaces
  the CDP client; the poll loop remains as a fallback for CDP endpoints
  without screencast support.
- Wire protocol unchanged: dashboard still receives
  {"type":"frame","data":...}, no frontend changes required.

Verified end-to-end against a live Octop instance: initial frame
arrives immediately, resize adapts frame size without restarting the
stream, and static pages push no frames.
- Input events (mouse, wheel, resize) and screencast acks now use
  CDPClient.send_no_wait: no round-trip wait per event, so rapid drags
  stop piling up latency. Serial awaits in the stream handler keep
  socket write order stable.
- Screencast jpeg quality 80 -> 60 and throttle cap 30 -> 20fps: bounds
  bandwidth for remote clients (a full-page change frame at 80q/25fps
  needs ~47Mbps; now ~3.5Mbps at 20fps). 20fps is still smooth for
  dragging.

Note: requires harness-browser >= 0.7.7 providing CDPClient.send_no_wait;
falls back to send() when absent (attribute check).
Older harness-browser (<0.7.7) lacks send_no_wait; route through a helper
that falls back to the awaiting send() so the stream keeps working without
a hard dependency bump. Newer versions get the fire-and-forget fast path.
@angri450
angri450 force-pushed the feature/browser-screencast-stream branch from 00baf06 to 2dcc8da Compare September 2, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants