fix: 浏览器远控传输层改文件桥(Unix socket 在 runsc 下宿主不可见)

根因(2026-10-30 活体实证):#181 的 .browser-remote.sock 假设
宿主能连容器内 socket,但生产 Docker 任务走 runsc/gVisor,
其 gofer 挂载下容器内建的 socket 只在容器内可见,宿主目录
里只有普通文件(browser-live.png 一直能透传)。后果:server
代理对真实任务一律 503,会话根本没活过。

修法:传输层改四个普通文件(worktree 内,原子写 + 追加日志):
- .browser-remote-state.json:MCP 每轮重写,mtime 即存活信号,
  server 判 30 秒无心跳为会话死亡(503);
- .browser-remote-frame.jpg:最新 screencast 帧(base64 文本);
- .browser-remote-input.jsonl:server 追加,MCP 按偏移量消费
  完整行,执行时再判 holder,半行等下一轮;
- .browser-remote-control.json:{holder, seq},MCP 首轮即应用,
  接管可先于浏览器启动。
- holder 竞态:control 写下后 state 文件约 50ms 才刷新,server
  的 input/stream 改判 effective_holder(控制文件 mtime 新者胜),
  否则接管后立刻点会误 409。
- 线程契约与控制权模型不变;MCP 主循环仍是唯一触页线程。
- 播种刷新(#181)让老租户自动拿到新版脚本。

测试:socket 测试改文件桥测试(桥轮询/偏移量/半行/陈旧态/
effective_holder + server 代理全链),全量 1357 项通过(skipped 4)。
This commit is contained in:
host committed 2026-10-30 07:28:32 +08:00
1 parent 3a5c5db9fb
commit 6bcdeb8da1
7 files changed
+628 -510

No files matched your search

+25 -58
View File
@@ -230,7 +230,7 @@ def _err(code, message):
_BROWSER = None
_REMOTE = None
_BRIDGE = None
def _get_browser(browser=None):
@@ -242,23 +242,23 @@ def _get_browser(browser=None):
return _BROWSER
def _get_remote(browser=None):
"""Remote control state, lazily created. Keyed by browser
def _get_bridge(browser=None):
"""File-bridge remote control, lazily created. Keyed by browser
identity so tests passing different fake browsers don't share
one holder flag."""
global _REMOTE
global _BRIDGE
target = _get_browser(browser)
if _REMOTE is not None and _REMOTE.browser is target:
return _REMOTE
if _BRIDGE is not None and _BRIDGE.browser is target:
return _BRIDGE
try:
from playwright_remote import RemoteState
from playwright_remote import RemoteBridge
except ImportError:
try:
from cloud.playwright_remote import RemoteState
from cloud.playwright_remote import RemoteBridge
except ImportError:
return None
_REMOTE = RemoteState(target)
return _REMOTE
_BRIDGE = RemoteBridge(target, getattr(target, "cwd", None))
return _BRIDGE
def handle_request(request, browser=None):
@@ -299,7 +299,7 @@ def handle_request(request, browser=None):
name = params.get("name")
args = params.get("arguments") or {}
target = _get_browser(browser)
remote = _get_remote(browser)
remote = _get_bridge(browser)
if remote is not None and remote.get_holder() == "user":
return {"jsonrpc": "2.0", "id": req_id,
"result": {"content": [{"type": "text",
@@ -359,61 +359,28 @@ def handle_request(request, browser=None):
def _drain_remote(browser):
"""Main-thread only: run queued user inputs and pump screencast
frames (acks). Called on every loop iteration so remote input
"""Main-thread only: poll the file bridge (control, inputs,
frame, state). Called on every loop iteration so remote input
lands even while no JSON-RPC request is in flight."""
remote = _get_remote(browser)
if remote is None:
bridge = _get_bridge(browser)
if bridge is None:
return
try:
from playwright_remote import execute_input
except ImportError:
try:
from cloud.playwright_remote import execute_input
except ImportError:
return
while True:
try:
item = remote.inputs.get_nowait()
except Exception: # noqa: BLE001 - queue empty
break
if remote.get_holder() != "user":
continue
try:
execute_input(browser, item)
except Exception as exc: # noqa: BLE001 - input errors are data
print("playwright_mcp: remote input error: %s" % exc,
file=sys.stderr)
try:
frame = browser.pump_screencast()
except Exception: # noqa: BLE001 - screencast best-effort
frame = ""
if frame:
remote.set_frame(frame)
bridge.poll(browser)
except Exception as exc: # noqa: BLE001 - bridge best-effort
print("playwright_mcp: bridge poll error: %s" % exc,
file=sys.stderr)
def main():
import select
browser = _get_browser()
remote = _get_remote(browser)
if remote is not None:
try:
from playwright_remote import start_remote_server
except ImportError:
try:
from cloud.playwright_remote import start_remote_server
except ImportError:
start_remote_server = None
if start_remote_server is not None:
socket_path = os.environ.get(
"PLAYWRIGHT_REMOTE_SOCKET",
os.path.join(browser.cwd, ".browser-remote.sock"))
try:
browser.set_frame_sink(_frame_queue())
start_remote_server(remote, socket_path)
except Exception as exc: # noqa: BLE001 - remote best-effort
print("playwright_mcp: remote server failed: %s" % exc,
file=sys.stderr)
# Frame sink must be set before any navigate so pump_screencast
# has frames for the bridge to publish. No bridge (missing
# module) means no sink either, so frames are dropped instead
# of accumulating unboundedly in a queue nobody drains.
if _get_bridge(browser) is not None:
browser.set_frame_sink(_frame_queue())
buffer = ""
while True:
_drain_remote(browser)