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:
1 parent
e9370d7291
commit
910168126d
4 files changed
+93
-21
No files matched your search
+46
-18
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user