fix: share Xvfb display across browser instances

This commit is contained in:
2026-10-06 12:59:55 +08:00
parent 4180ec104c
commit b72727451d
3 changed files with 408 additions and 12 deletions
+33 -12
View File
@@ -782,6 +782,7 @@ class NativeRuntimeManager:
record = self._require_record(alias, generation)
self._refresh(record)
if record.state != "running" or not self._ready(record):
LOG.warning("runtime_not_ready alias=%s runtime_id=%s network_id=%s state=%s cleanup_state=%s cdp_port=%s browser_unit=%s xvfb_unit=%s", record.alias, record.runtime_id, record.network_id, record.state, record.cleanup_state, record.cdp_port, record.browser_unit, record.xvfb_unit)
raise BrowserRuntimeError("browser runtime is not ready", 503, record.network_id)
return record
@@ -790,9 +791,21 @@ class NativeRuntimeManager:
records = self._records()
result = []
for record in records:
if record.state not in {"released"}:
self._refresh(record)
result.append(self._public(record, self._ready(record) if record.state in {"running", "degraded"} else False))
lock = FileLock(self.state_dir / "locks" / f"alias-{record.alias}.lock")
if not lock.acquire():
LOG.debug("runtime_observation_busy alias=%s runtime_id=%s state=%s", record.alias, record.runtime_id, record.state)
result.append(self._public(record, False))
continue
try:
# A lifecycle operation may have finished after the initial snapshot.
# Refresh only a fresh record protected by the same cross-process lock.
record = self._read_record(self._record_path(record))
if record.state != "released":
self._refresh(record)
ready = self._ready(record) if record.state in {"running", "degraded"} else False
result.append(self._public(record, ready))
finally:
lock.release()
return result
def retry_cleanup(self, alias: str, generation: Mapping[str, Any]) -> None:
@@ -1051,6 +1064,12 @@ class NativeRuntimeManager:
record.xvfb_pid = xvfb.pid
if record.state == "starting":
record.state = "degraded"
elif record.state == "degraded" and record.cleanup_state != "pending" and self._ready(record):
record.state = "running"
record.cleanup_state = "none"
record.cleanup_error = ""
record.updated_at = self.clock()
LOG.info("runtime_readiness_recovered alias=%s runtime_id=%s network_id=%s cdp_port=%s", record.alias, record.runtime_id, record.network_id, record.cdp_port)
self._write(record)
def _ready(self, record: RuntimeRecord) -> bool:
@@ -1059,7 +1078,8 @@ class NativeRuntimeManager:
try:
version = self._get_json(record.cdp_port, "/json/version")
targets = self._get_json(record.cdp_port, "/json/list")
except (OSError, ValueError, BrowserRuntimeError):
except (OSError, ValueError, BrowserRuntimeError) as exc:
LOG.debug("runtime_cdp_not_ready alias=%s runtime_id=%s cdp_port=%s error=%s", record.alias, record.runtime_id, record.cdp_port, exc)
return False
return isinstance(version, dict) and isinstance(targets, list) and any(
isinstance(target, dict) and target.get("type") == "page" for target in targets
@@ -1218,15 +1238,16 @@ class NativeRuntimeManager:
with suppress(FileNotFoundError):
temporary.unlink()
def _read_record(self, path: Path) -> RuntimeRecord:
try:
return RuntimeRecord.from_dict(json.loads(path.read_text(encoding="utf-8")))
except OSError as exc:
raise BrowserRuntimeError("runtime metadata cannot be read", 500) from exc
except json.JSONDecodeError as exc:
raise BrowserRuntimeError("runtime metadata is invalid", 500) from exc
def _records(self) -> list[RuntimeRecord]:
result = []
for path in sorted((self.state_dir / "runtimes").glob("*/runtime.json")):
try:
result.append(RuntimeRecord.from_dict(json.loads(path.read_text(encoding="utf-8"))))
except OSError as exc:
raise BrowserRuntimeError("runtime metadata cannot be read", 500) from exc
except json.JSONDecodeError as exc:
raise BrowserRuntimeError("runtime metadata is invalid", 500) from exc
result = [self._read_record(path) for path in sorted((self.state_dir / "runtimes").glob("*/runtime.json"))]
return sorted(result, key=lambda item: (item.created_at, item.alias))
def _find_alias(self, alias: str, include_released: bool) -> RuntimeRecord | None:
+97
View File
@@ -448,6 +448,103 @@ class NativeRuntimeManagerTests(unittest.TestCase):
self.assertEqual(result["state"], "stopped")
self.assertEqual(self.units.commands, [])
def test_listing_during_start_cannot_overwrite_runtime_readiness(self) -> None:
from threading import Event, Thread
waiting, finish = Event(), Event()
outcomes: list[object] = []
def wait(record: object) -> None:
waiting.set()
if not finish.wait(2):
raise AssertionError("startup test did not release CDP wait")
def create() -> None:
try:
outcomes.append(self.manager.create(self.payload()))
except Exception as exc:
outcomes.append(exc)
self.manager._wait_for_cdp = wait
thread = Thread(target=create)
thread.start()
try:
self.assertTrue(waiting.wait(1))
runtime_file = next(self.state.glob("runtimes/*/runtime.json"))
before = runtime_file.read_text()
stale_starting = self.manager._records()
public = self.manager.list_public()[0]
self.assertEqual(public["state"], "starting")
self.assertFalse(public["ready"])
self.assertEqual(runtime_file.read_text(), before)
finally:
finish.set()
thread.join(2)
self.assertFalse(thread.is_alive())
self.assertEqual(len(outcomes), 1)
self.assertIsInstance(outcomes[0], dict)
result = outcomes[0]
self.manager._ready = lambda record: True
with patch.object(self.manager, "_records", return_value=stale_starting):
public = self.manager.list_public()[0]
self.assertEqual(public["state"], "running")
self.assertTrue(public["ready"])
verified = self.manager.require_generation("account-a", result)
self.assertEqual(verified.state, "running")
self.assertEqual(verified.runtime_id, result["runtime_id"])
def test_listing_reloads_metadata_after_lifecycle_transition(self) -> None:
result = self.manager.create(self.payload())
stale = self.manager._records()
self.manager.change_state("account-a", "stop", result)
with patch.object(self.manager, "_records", return_value=stale):
public = self.manager.list_public()[0]
self.assertEqual(public["state"], "stopped")
self.assertEqual(public["cleanup_state"], "cleaned")
self.assertEqual(self.manager._records()[0].state, "stopped")
def test_verified_live_degraded_runtime_recovers_without_restart(self) -> None:
result = self.manager.create(self.payload())
record = self.manager._records()[0]
record.state = "degraded"
self.manager._write(record)
self.manager._ready = lambda record: True
commands_before = list(self.units.commands)
verified = self.manager.require_generation("account-a", result)
self.assertEqual(verified.state, "running")
self.assertEqual(verified.runtime_id, result["runtime_id"])
self.assertEqual(self.units.commands, commands_before)
self.assertTrue(all(status.active for status in self.units.units.values()))
def test_degraded_runtime_with_pending_cleanup_is_not_recovered(self) -> None:
result = self.manager.create(self.payload())
record = self.manager._records()[0]
record.state = "degraded"
record.cleanup_state = "pending"
self.manager._write(record)
self.manager._ready = lambda record: True
with self.assertRaises(BrowserRuntimeError) as caught:
self.manager.require_generation("account-a", result)
self.assertEqual(caught.exception.status, 503)
self.assertEqual(self.manager._records()[0].state, "degraded")
self.assertTrue(all(status.active for status in self.units.units.values()))
def test_listing_respects_cross_process_alias_lock(self) -> None:
from .runtime import FileLock
self.manager.create(self.payload())
runtime_file = next(self.state.glob("runtimes/*/runtime.json"))
before = runtime_file.read_text()
external = FileLock(self.state / "locks" / "alias-account-a.lock")
self.assertTrue(external.acquire())
try:
with patch.object(self.manager, "_refresh", side_effect=AssertionError("busy alias refreshed")):
public = self.manager.list_public()[0]
self.assertFalse(public["ready"])
self.assertEqual(runtime_file.read_text(), before)
finally:
external.release()
def test_timeout_marks_runtime_cleanup_pending(self) -> None:
self.manager.ready_timeout = 0.001
self.manager._wait_for_display = NativeRuntimeManager._wait_for_display.__get__(self.manager)
+278
View File
@@ -0,0 +1,278 @@
# 共享 Xvfb 与指定 profile 窗口操作:讨论结论
## 1. 文档定位与完成标准
本文汇总 gateway 的 DISPLAY 配置、共享 Xvfb 的资源收益、窗口定位和 Openbox 激活机制,供后续需求迭代使用。
- **当前事实**:依据现有代码、配置样例及本次讨论期间的只读查询。
- **建议方案**:尚未实现,不能视为当前产品能力或已经批准的开发任务。
- 本次仅新增文档,不修改代码、配置,不启动或激活浏览器,不发送鼠标事件。
- 文档完成标准:覆盖讨论中的配置、限制、资源、两种输入方式、profile/窗口映射、风险、待办与验收条件,并明确证据边界。
相关资料:
- [现有验证记录](native-browser-verification.md)
- [现有实施计划](native-browser-implementation-plan.md)
- [现有浏览器变更评审](native-browser-change-review.md)
本文讨论的新建议不改变上述文档已有的完成状态,也不表示共享 DISPLAY 或窗口激活功能已经通过验收。
## 2. 当前 gateway 的 DISPLAY 行为
### 2.1 默认模式
- 每个 runtime 启动独立 Xvfb。
- 默认从 `:100` 到 `:199` 按顺序分配可用 DISPLAY。
- 当前默认屏幕配置为 `1280x720x24`。
- Chrome 启动时显式设置对应 `DISPLAY`,不是直接继承 gateway 进程的 `DISPLAY`。
- 默认分配范围目前没有对应环境变量,只有 runtime manager 的构造参数。
### 2.2 外部 DISPLAY 模式
在 gateway 环境配置中设置:
```ini
RUNTIME_EXTERNAL_DISPLAY=99
```
含义:复用已经存在的 `DISPLAY=:99`。
- 配置值是正整数,不带冒号。
- 外部 Xvfb 必须事先运行;gateway 不负责启动或停止它。
- systemd 部署的环境文件通常是 `~/.config/creatorhub/browser-gateway.env`。
- 修改配置后需重启 gateway;已经运行的 Chrome 不会自动切换 DISPLAY。
- 当前可用性检查涉及 X11 socket 是否存在,不能将其等同于完整的显示服务健康检查。
配置和实现依据:
- `browser_gateway/server/http.py`:环境配置解析。
- `browser_gateway/runtime.py`:DISPLAY 分配、启动、恢复及释放。
- `deploy/browser-gateway.env.example`:配置样例。
- `deploy/creatorhub-browser-gateway.service.in`:环境文件加载。
### 2.3 当前共享限制
**Xvfb 本身支持多个 Chrome 共用同一个 DISPLAY;禁止共享是 gateway 当前的应用限制。**
现有外部模式包含两层占用处理:
1. `_reserve_display` 检查运行记录:如果其他未 `released` 的 runtime 已记录同一 DISPLAY,则拒绝启动。检查不只限于正在运行的浏览器,`stopped` 记录也可能触发。
2. `DisplayAllocator.reserve_existing` 对 DISPLAY 使用独占文件锁;当前实现会在浏览器启动后释放 display lease,不能把它描述为贯穿运行期的独占锁。
若允许共享,应同时审查记录检查和外部模式的锁流程,不能只删除报错分支。默认模式自行启动 Xvfb 的分配锁仍有必要保留,避免两个启动流程竞争同一 DISPLAY。
## 3. 每增加一个 Xvfb 的资源开销
### 3.1 本次讨论期间的运行快照
| 屏幕配置 | 进程显示内存 RSS | 共享内存按比例分摊后的 PSS | 短时 CPU 均值 |
| --- | ---: | ---: | ---: |
| `1280x720x24` | 约 64.8 MiB | 约 32.0 MiB | 0.00% |
| `1920x1080x24` | 约 53.3 MiB | 约 35.5 MiB | 0.33% |
测量来自现有 Xvfb 的 `/proc/<pid>/smaps_rollup` 和短时间 `pidstat` 采样,未为测量启动新进程。
解释与边界:
- RSS 含共享页,不能简单相加得出额外物理内存;PSS 是当前分摊值,也不是新增一个进程的严格边际成本。
- 两个进程运行历史和负载不同,不能根据表格推导较高分辨率反而更省内存。
- CPU 百分比按单核口径理解;本次近乎空闲的采样不代表视频、动画或频繁重绘时的消耗。
- Chrome、VNC、Openbox 的资源消耗均不包含在上述 Xvfb 数字中。
- 未保留完整的负载条件和连续曲线,因此这些数据是运行快照,不是性能基准。
### 3.2 容量规划建议
可暂按 **每个 Xvfb 预留 50~70 MiB** 估算,10 个约 **0.5~0.7 GiB**。该数字是当前快照下的粗略预留值,不是上限或保证。
24 位色的像素通常按 32 位存储,`1280×720` 单帧约 `3.52 MiB`,但 Xvfb 还包含绘图资源、字体、连接和缓存,不能只用单帧大小估算进程内存。
共享 DISPLAY 能减少独立 Xvfb 的重复固定开销;不会消除各个 Chrome 的内存,也不会让所有窗口绘图资源的占用归零。是否值得共享,仍需结合目标并发数和实际页面负载判断。
## 4. 共享 DISPLAY 的收益与代价
| 项目 | 独立 DISPLAY | 共享 DISPLAY |
| --- | --- | --- |
| Xvfb 固定开销 | 每个 runtime 一份 | 多个 runtime 共用 |
| Chrome 数据目录、控制端口 | 各自独立 | 仍须各自独立 |
| 桌面鼠标、键盘焦点、剪贴板 | 各自独立 | 全部共享 |
| VNC 可见窗口 | 单个浏览器为主 | 多窗口可能遮挡 |
| Xvfb 故障影响范围 | 对应 runtime | 所有共享者 |
| 桌面输入协调 | 每个 DISPLAY 内协调 | 所有共享者统一协调 |
**建议方向,尚未实现:默认保留独立 Xvfb;外部 DISPLAY 模式允许共享。**
停止一个浏览器时,外部模式没有由该 runtime 持有的 Xvfb unit,现有清理流程不会主动关闭外部 Xvfb。共享后的启动、恢复、停止、状态判断仍需完整验证。
## 5. 指定 profile 的两种输入方式
### 5.1 直接控制网页:CDP
```text
profile_id → runtime → 独立 cdp_port → 页面 targetId
```
- `Page.bringToFront`:选择对应页面并请求置于前台。
- `Input.dispatchMouseEvent`:向指定页面发送鼠标事件。
- 事件坐标是主框架视口内的 CSS 像素,不是 Xvfb 桌面坐标。
- 请求通过独立控制连接定位到对应 Chrome,无需按桌面位置寻找账号。
- 这不等于控制系统鼠标,也不覆盖浏览器工具栏或原生系统弹窗。
- 依赖前台焦点或页面可见性的行为仍需协调,不能笼统承诺所有操作均可并发。
**推荐:只需网页内输入时优先采用 CDP。**
### 5.2 桌面输入:X11 + Openbox
```text
profile_id → runtime.browser_pid → X11 窗口 ID → 激活窗口 → 桌面输入
```
适用于需要真实桌面焦点、VNC 前台窗口或网页之外的操作。必须注意:同一个 DISPLAY 只有一套桌面鼠标和键盘焦点,不能把多个窗口理解为多个独立输入设备。
本文仅说明事件与目标窗口的对应机制,不涉及验证码识别、求解或自动绕过,也不保证网站接受合成输入。
## 6. Openbox 如何激活指定 profile 的窗口
### 6.1 职责和绑定关系
```text
Xvfb 提供虚拟屏幕,例如 :99
Openbox 管理该屏幕上的窗口、桌面、遮挡顺序和焦点
Chrome 创建窗口,并声明窗口所属进程
gateway 保存 profile 对应的 Chrome 运行信息
```
Openbox 不认识 `profile_id`,也不保存账号绑定。绑定关系由 gateway 查询或维护:
```text
profile_id
→ 当前 runtime 的 display、browser_pid、browser_start_time
→ 同一 DISPLAY 上 _NET_WM_PID 匹配的顶层窗口
→ X11 窗口 ID
→ 向 Openbox 发送激活请求
```
当前每个 profile 使用独立的 `--user-data-dir` 和 Chrome 进程,这是按 PID 对应 profile 的前提。若未来改为多个 profile 共用同一 Chrome 进程,这条映射将不再足够,不能沿用结论。
一个 DISPLAY 通常运行一个窗口管理器。Xvfb 自身不提供窗口管理;若该 DISPLAY 尚无窗口管理器,可由部署方在该 DISPLAY 上运行 Openbox。gateway 目前不会因此自动具备管理 Openbox 生命周期的能力。
### 6.2 命令示例
以下 PID、窗口 ID 是示意值;本次没有实际执行激活。
假设运行记录是 `display=99`、`browser_pid=23456`:
```bash
export DISPLAY=:99
# 查看窗口管理器。
wmctrl -m
# 查看顶层窗口:窗口 ID、桌面、PID、主机、标题。
wmctrl -lp
```
示意输出:
```text
0x01200003 0 23456 host 抖音 - Google Chrome
0x01400003 0 34567 host 抖音 - Google Chrome
```
第三列匹配目标 Chrome PID,因此候选窗口是 `0x01200003`。可进一步检查:
```bash
xprop -id 0x01200003 _NET_WM_PID WM_CLASS
```
确认窗口唯一且仍属于目标 Chrome 后,请求激活:
```bash
timeout 5s xdotool windowactivate --sync 0x01200003
```
`xdotool` 通过 EWMH 窗口管理协议发送请求,Openbox 执行桌面切换、窗口置前和焦点分配;`--sync` 等待目标窗口成为激活窗口,外层超时避免无限等待。超时或失败必须报告,不能继续发送输入。
检查激活结果:
```bash
xdotool getactivewindow
xdotool getactivewindow getwindowpid
```
不仅检查 PID,还应确认激活的是选定窗口;同一 PID 可能有多个窗口。
### 6.3 只读验证证据
本次讨论期间,在本机 `DISPLAY=:99` 上只读检查发现:
- `wmctrl -m` 报告窗口管理器为 Openbox。
- 一个 Chrome 窗口的 `_NET_WM_PID` 与实际 Chrome 进程 PID 一致。
- 该 Chrome 进程命令行包含其独立 `--user-data-dir`。
- 本地 `xdotool` 手册确认 `windowactivate --sync` 的激活等待行为。
这验证了环境与映射依据,**不代表 gateway 已经实现或验证按 profile 激活窗口**;本次未执行窗口切换、拖动或网页操作。
## 7. 窗口定位和输入的必要约束
1. **不按标题或窗口顺序绑定**:页面标题会变,不同 profile 可能打开相同页面。
2. **重启后重新定位**:PID 和 X11 窗口 ID 都可能改变;校验进程启动时间,防止 PID 被复用。
3. **多窗口必须明确目标**:一个 profile 可以有多个窗口。找不到窗口或找到多个候选时明确报错,不能自动取第一个。
4. **窗口与标签页分开处理**:激活 Chrome 窗口不代表选中了指定标签页。指定页面使用 CDP 的 `targetId`。
5. **两种窗口 ID 不能混用**:`Browser.getWindowForTarget` 返回的 CDP `windowId` 不等于 X11 窗口 ID。
6. **同屏桌面操作串行**:协调范围至少覆盖“定位、激活、确认、按下、移动、释放”,不能只锁激活步骤。若存在多个控制进程,必须明确单一控制者或跨进程协调方式。
7. **人工操作也会抢焦点**:VNC 使用者不受程序锁约束。操作期间应避免人工输入;检测到目标变化时中止并报告,不能声称程序锁能消除全部竞争。
8. **错误必须可见**:窗口管理器缺失、窗口不存在、映射歧义、激活超时、焦点丢失均应报告。中断输入时应清理本次按键/按钮状态,不能静默改投其他窗口。
## 8. 后续迭代事项(待确认后实施)
### A. 外部 DISPLAY 共享
- 调整外部模式的运行记录独占检查和 display lease 处理。
- 保留默认独立模式的 Xvfb 分配和生命周期隔离。
- 核查共享模式的启动、停止、释放、gateway 重启恢复、显示服务失效等路径。
- 更新配置说明、相关测试及与行为发生冲突的既有文档。
### B. 按 profile 激活窗口
- 从当前 runtime 获取 DISPLAY、浏览器 PID 和进程启动时间。
- 在对应 DISPLAY 上查询受管理的 Chrome 顶层窗口。
- 定义多窗口时的目标选择规则;未确定规则前,不新增猜测或兜底行为。
- 使用现有 X11 工具请求激活,并校验结果、设置超时、报告错误。
- 窗口 ID 优先即时查询,不为短生命周期标识新增数据库绑定表。
### C. 输入方式及协调
- 根据实际需求明确采用网页 CDP 输入还是桌面 X11 输入,不把两者混作同一能力。
- 桌面输入按 DISPLAY 协调整个操作过程,并明确人工 VNC 输入的处理约定。
- 是否由 gateway 管理 Openbox、是否需要专用操作接口,由后续需求单独确认;当前不新增部署层或接口。
### D. 日志与资源验证
- 关键记录至少包括:操作编号、profile、runtime、DISPLAY、浏览器 PID/启动时间、目标窗口、激活前后状态、耗时和失败原因。
- 区分“未找到窗口”“多个候选”“服务不可用”“激活失败”“焦点改变”,避免只记录通用失败。
- 若节省内存是迭代目的,应在相同页面、相同 profile 数量与相同负载下对比独立/共享模式;统计 Xvfb、Chrome、Openbox、VNC 整体资源,而不是只累加 RSS。
## 9. 后续开发验收条件
功能开发遵循 TDD,相关单元测试覆盖率至少 65%;不主动编写或运行 E2E 测试。
### 单元测试应覆盖
- 默认模式为不同 runtime 分配独立 DISPLAY。
- 共享外部模式允许多个 runtime 使用同一 DISPLAY,且保留独立数据目录和控制端口。
- 停止一个 runtime 不关闭外部 Xvfb,不影响另一个 runtime。
- gateway 恢复时正确识别共享 DISPLAY 与各自浏览器状态。
- 外部 DISPLAY 不可用时明确报错,不自动切换到其他屏幕。
- profile/PID/窗口定位成功、窗口缺失、多窗口歧义、PID 失效和窗口失效。
- 激活成功、超时、窗口管理器缺失与结果不匹配。
- 同一 DISPLAY 的桌面输入不会交叉执行;失败能释放协调资源并报告。
### 需要使用者明确授权的现场验证
- 两个独立 profile 共用一个 Xvfb,确认窗口和账号数据互不混淆。
- 按 profile 激活窗口,包含窗口被遮挡、位于其他桌面以及重启后的情形。
- 在普通可拖动页面验证目标窗口与输入坐标,避免以验证码求解作为验证标准。
- 检查停止其中一个浏览器后另一浏览器和外部 Xvfb 是否正常。
- 记录相同负载下的资源变化及日志证据。
**实施前仍需确认:是否启用共享外部 DISPLAY、是否需要桌面输入、多窗口选择规则、Openbox 由谁管理。本文不替代这些决策。**