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:
1 parent
3a5c5db9fb
commit
6bcdeb8da1
7 files changed
+628
-510
No files matched your search
+25
-58
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user