perf: 桥变化才写、stdin 优先、stream 50ms 轮询

1. 桥写放大:旧代码每 ~50ms 轮询都无条件重写 state 和帧
   文件(约 40 次原子 temp+replace/s 走 runsc gofer,
   泵持久返回同一帧、updated_at 每轮都变,字节其实不变)。
   现帧字节不变不写;state 只在 url/title/started/holder/
   streaming 变化时写,或 0.5s 心跳一次(判死阈值 30s,
   绰绰有余);静态页写量从 ~40次/s 降到 ~2次/s。
2. MCP 主循环 stdin 优先:旧循环先 drain 再读 stdin,
   每次工具调用都先付一轮文件 IO。现 pending 请求先处理,
   空闲轮询 drain,忙时 200ms 保底 drain 一次防饿死。
3. stream 轮询 200ms → 50ms,画面延迟下限随之下降。
附回归测试:无变化轮询不重写、帧变化立即写、holder
变化绕过节流立即写。
This commit is contained in:
host committed 2026-10-30 13:36:24 +08:00
1 parent e9370d7291
commit 910168126d
4 files changed
+93 -21

No files matched your search

+46 -18
View File
@@ -14,12 +14,12 @@ is four regular files in the worktree, all written atomically
(temp file + os.replace) except the append-only input log:
- ``.browser-remote-state.json``: {url, title, started, holder,
streaming, updated_at} -- rewritten by the MCP drain loop on
every iteration, so its mtime is the session-liveness signal.
The server treats a state file older than STATE_STALE_SECONDS
as a dead session (503).
streaming, updated_at} -- rewritten when a meaningful field
changes or at the heartbeat interval, so its updated_at is the
session-liveness signal. The server treats a state file older
than STATE_STALE_SECONDS as a dead session (503).
- ``.browser-remote-frame.jpg``: latest screencast frame, base64
text, rewritten only when a new frame arrives.
text, rewritten only when its bytes change.
- ``.browser-remote-input.jsonl``: the server appends one JSON
object per line. The MCP tracks a byte offset and executes
complete lines only (a trailing partial line waits for the
@@ -34,10 +34,10 @@ Threading contract (unchanged from the socket version, measured
2026-10-30: Playwright sync calls from any thread but the
creating one raise "Cannot switch to a different thread"):
only the MCP main thread touches the browser. It calls
RemoteBridge.poll() on every select() iteration (~50ms), so
control/input/frames flow even while the agent is idle, and CDP
frame acks keep flowing (no ack = CDP stops after the first
frame).
RemoteBridge.poll() on every idle main-loop iteration (~50ms),
so control/input/frames flow even while the agent is idle, and
CDP frame acks keep flowing (no ack = CDP stops after the
first frame).
Control model (unchanged): holder "agent" by default; holder
"user" while the console user has taken over, during which
@@ -55,6 +55,10 @@ FRAME_NAME = ".browser-remote-frame.jpg"
INPUT_NAME = ".browser-remote-input.jsonl"
CONTROL_NAME = ".browser-remote-control.json"
STATE_STALE_SECONDS = 30
# Heartbeat interval for state writes. The server treats state
# older than STATE_STALE_SECONDS as dead, so 0.5s is plenty;
# writing every ~50ms iteration was pure gofer round-trips.
STATE_HEARTBEAT_SECONDS = 0.5
class SimpleQueue:
@@ -114,6 +118,9 @@ class RemoteBridge:
self._input_offset = 0
self._control_seq = None
self._frame_written = False
self._last_frame = None
self._last_state_key = None
self._last_state_write = 0.0
def _path(self, name):
return os.path.join(self.workdir, name)
@@ -180,7 +187,18 @@ class RemoteBridge:
print("playwright_remote: input error: %s" % exc)
def poll(self, browser=None):
"""One drain-loop iteration: control, inputs, frame, state."""
"""One drain-loop iteration: control, inputs, frame, state.
Writes are change-gated. The old code rewrote both files
on every ~50ms iteration: ~20 atomic temp+replace
round-trips per second through the runsc gofer mount for
identical bytes (pump returns the latest frame
persistently, and updated_at changes every call). Now the
frame file is rewritten only when its bytes change, and
the state file only when a meaningful field changes or
the heartbeat interval elapsed -- a static page costs
~2 writes per second instead of ~40.
"""
target = browser if browser is not None else self.browser
self._poll_control()
self._poll_inputs()
@@ -188,20 +206,30 @@ class RemoteBridge:
frame = target.pump_screencast()
except Exception: # noqa: BLE001 - screencast best-effort
frame = ""
if frame:
if frame and frame != self._last_frame:
try:
_atomic_write(self._path(FRAME_NAME),
frame.encode("utf-8"))
self._last_frame = frame
self._frame_written = True
except OSError:
pass
try:
_atomic_write(
self._path(STATE_NAME),
json.dumps(self.state_dict(),
ensure_ascii=False).encode("utf-8"))
except OSError:
pass
state = self.state_dict()
key = (state.get("url"), state.get("title"),
state.get("started"), state.get("holder"),
state.get("streaming"))
now = state["updated_at"]
if key != self._last_state_key or \
now - self._last_state_write >= STATE_HEARTBEAT_SECONDS:
try:
_atomic_write(
self._path(STATE_NAME),
json.dumps(state,
ensure_ascii=False).encode("utf-8"))
self._last_state_key = key
self._last_state_write = now
except OSError:
pass
def bridge_paths(workdir):