From 9d986becaf03d582b8fea426a6a6ca7e03692863 Mon Sep 17 00:00:00 2001 From: host Date: Sat, 7 Nov 2026 20:40:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(chv):=20=E5=AE=8C=E6=95=B4=20Chromium=20?= =?UTF-8?q?=E6=94=B9=E8=A3=85=E6=95=B0=E6=8D=AE=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根盘只剩 ~600MB,playwright install chromium 装根盘 109 秒后 ENOSPC 失败(2026-11-08 构建 #8 驱动实证)。完整版改装数据盘 /home/chv/workspace/.chv-browsers(9.3G 空闲),仍不 bake 进 base、按需;full 引擎启动时直接传 executable_path(channel 与 executable_path 互斥,且驱动进程启动时才读 env,运行中改 env 无效);status 增 full_browsers_dir,mount 失败报错给数据盘 安装命令。驱动的安装步同步改数据盘并 chown 给 chv-browser。 --- chv-guest/chv-browser.py | 87 +++++++++++++++++++++++------- cloud/tests/test_chv_opt_stage3.py | 75 ++++++++++++++++++++++---- 2 files changed, 135 insertions(+), 27 deletions(-) diff --git a/chv-guest/chv-browser.py b/chv-guest/chv-browser.py index 077c2f8..f86cdf1 100644 --- a/chv-guest/chv-browser.py +++ b/chv-guest/chv-browser.py @@ -19,6 +19,9 @@ Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完 卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base; 未安装时 mount_full 明确报错并回落 shell,不硬凑通过。 + 完整版装在数据盘 /home/chv/workspace/.chv-browsers(根盘只剩 + 约 600MB,装根盘会 ENOSPC;full 引擎启动时直接传 + executable_path,不走 channel/env)。 * 截图:JPEG quality 70,viewport 宽封顶 1280(上下文创建时 定死 1280x800);diff_only 时与上一帧比对,字节相同即不落盘、 返回 changed=false(整帧相同跳过;变化区裁剪是阶段 4 的事, @@ -77,6 +80,11 @@ PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright" WORKSPACE_MOUNT = "/home/chv/workspace" PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile" PROFILE_FALLBACK_DIR = "/var/lib/chv-browser/profile" +# 完整 Chromium 不 bake 进 base、不装根盘:根盘只剩 ~600MB, +# playwright install chromium 会 ENOSPC(2026-11-08 构建 #8 驱动 +# 实证:109 秒后写满失败)。按需装到数据盘(9.3G 空闲),用完可删。 +FULL_BROWSERS_DATA_DIR = "/home/chv/workspace/.chv-browsers" +FULL_BROWSERS_FALLBACK_DIR = "/var/lib/chv-browser/browsers" CDP_PORT_DEFAULT = 9222 # 截图规格(方案阶段 4 口径,阶段 3 先落地编码与尺寸两项): # JPEG quality 70,宽封顶 1280(上下文 viewport 定死 1280x800)。 @@ -128,19 +136,39 @@ def resolve_profile_dir(env=None, ismount=None): return PROFILE_FALLBACK_DIR, False -def full_chromium_present(browsers_path=None): - """完整 Chromium 是否已装:ms-playwright 下有 chromium-* - 目录且含 chrome-linux/chrome(headless-shell 目录不算)。""" +def full_chromium_executable(browsers_path=None): + """完整 Chromium 可执行文件路径(数据盘安装目录下),未装返回 None。 + headless-shell 目录不算。""" base = browsers_path or os.environ.get( "PLAYWRIGHT_BROWSERS_PATH", PLAYWRIGHT_BROWSERS_PATH) for cand in glob.glob(os.path.join(base, "chromium-*")): if os.path.basename(cand).startswith( "chromium_headless_shell"): continue - if os.path.isfile(os.path.join( - cand, "chrome-linux", "chrome")): - return True - return False + exe = os.path.join(cand, "chrome-linux", "chrome") + if os.path.isfile(exe): + return exe + return None + + +def full_chromium_present(browsers_path=None): + """完整 Chromium 是否已装:ms-playwright 下有 chromium-* + 目录且含 chrome-linux/chrome(headless-shell 目录不算)。""" + return full_chromium_executable(browsers_path) is not None + + +def resolve_full_browsers_dir(profile_on_data_disk, env=None): + """完整 Chromium 的安装目录:profile 在数据盘上时跟 profile 走 + 数据盘(/home/chv/workspace/.chv-browsers),否则回落本机盘。 + env 显式指定(CHV_BROWSER_FULL_BROWSERS_DIR)时优先。""" + if env is None: + env = os.environ + explicit = (env.get("CHV_BROWSER_FULL_BROWSERS_DIR") or "").strip() + if explicit: + return explicit + if profile_on_data_disk: + return FULL_BROWSERS_DATA_DIR + return FULL_BROWSERS_FALLBACK_DIR def truncate_a11y(node, max_nodes=MAX_A11Y_NODES, @@ -218,7 +246,7 @@ class BrowserAgent: _load_playwright()。""" def __init__(self, playwright_factory=None, shots_dir=SHOTS_DIR, - profile_dir=None, cdp_port=None): + profile_dir=None, cdp_port=None, full_browsers_dir=None): self._factory = playwright_factory or _load_playwright self._shots_dir = shots_dir if profile_dir: @@ -228,6 +256,9 @@ class BrowserAgent: else: self._profile_dir, self._profile_on_data_disk = \ resolve_profile_dir() + self._full_browsers_dir = ( + full_browsers_dir or resolve_full_browsers_dir( + self._profile_on_data_disk)) try: self._cdp_port = int(cdp_port or os.environ.get( "CHV_BROWSER_CDP_PORT") or CDP_PORT_DEFAULT) @@ -279,7 +310,18 @@ class BrowserAgent: "--remote-debugging-address=0.0.0.0"], } if engine == "full": - kwargs["channel"] = "chromium" + # 完整浏览器在数据盘安装目录里:直接给可执行文件路径, + # 不用 channel(channel 与 executable_path 互斥,且 + # channel 要走 PLAYWRIGHT_BROWSERS_PATH——驱动进程 + # 启动时才读 env,运行中改 env 对已起的驱动无效)。 + exe = full_chromium_executable( + self._full_browsers_dir) + if exe is None: + self._context = None + return False, ("完整 Chromium 未安装(%s 下未见 " + "chromium-*/chrome-linux/chrome)" + % self._full_browsers_dir) + kwargs["executable_path"] = exe launch = getattr(self._entered.chromium, "launch_persistent_context", None) if launch is None: @@ -439,7 +481,9 @@ class BrowserAgent: "sessions": [s["session"] for s in self.sessions()["sessions"]], "mounted_full": self._mounted_full, - "full_present": full_chromium_present()} + "full_present": full_chromium_present( + self._full_browsers_dir), + "full_browsers_dir": self._full_browsers_dir} def cdp(self): ok, err = self._ensure_browser() @@ -457,12 +501,16 @@ class BrowserAgent: return {"ok": False, "error": "未知引擎:%r(只允许 shell/full)" % (engine,)} - if engine == "full" and not full_chromium_present(): + if engine == "full" and not full_chromium_present( + self._full_browsers_dir): return {"ok": False, - "error": "完整 Chromium 未安装(未见 chromium-* " - "chrome-linux/chrome),回落 headless-shell;" - "先跑 python3 -m playwright install " - "chromium 安装", + "error": "完整 Chromium 未安装(%s 下未见 " + "chromium-*/chrome-linux/chrome),回落 " + "headless-shell;先跑 PLAYWRIGHT_BROWSERS_" + "PATH=%s python3 -m playwright install " + "chromium 安装(装数据盘,根盘空间不够)" + % (self._full_browsers_dir, + self._full_browsers_dir), "engine": self._engine, "fallback": True, "mounted_full": False} ok, err = self._ensure_browser(engine) @@ -482,11 +530,14 @@ class BrowserAgent: "profile": self._profile_dir} def mount_full(self): - if not full_chromium_present(): + if not full_chromium_present(self._full_browsers_dir): return {"ok": False, "error": "完整 Chromium 未安装,无法挂载(回落 " - "headless-shell 继续工作);先跑 python3 " - "-m playwright install chromium 安装", + "headless-shell 继续工作);先跑 " + "PLAYWRIGHT_BROWSERS_PATH=%s python3 -m " + "playwright install chromium 安装(装数据" + "盘,根盘空间不够)" + % self._full_browsers_dir, "engine": self._engine, "fallback": True, "mounted_full": False} return self.switch_engine("full") diff --git a/cloud/tests/test_chv_opt_stage3.py b/cloud/tests/test_chv_opt_stage3.py index 18cb732..63fb9b6 100644 --- a/cloud/tests/test_chv_opt_stage3.py +++ b/cloud/tests/test_chv_opt_stage3.py @@ -111,7 +111,7 @@ class FakePlaywright: return False -def _agent(pages, tmp): +def _agent(pages, tmp, full_browsers_dir=None): ctx = FakeContext(pages) entered_holder = {} @@ -126,7 +126,8 @@ def _agent(pages, tmp): agent = BROWSER.BrowserAgent( playwright_factory=lambda: PW(), shots_dir=tmp, - profile_dir=os.path.join(tmp, "profile")) + profile_dir=os.path.join(tmp, "profile"), + full_browsers_dir=full_browsers_dir) return agent, ctx, entered_holder @@ -172,6 +173,45 @@ class FullPresentTest(unittest.TestCase): self.assertFalse(BROWSER.full_chromium_present(tmp)) +class FullBrowsersDirTest(unittest.TestCase): + def test_resolve_data_disk_path(self): + self.assertEqual( + BROWSER.resolve_full_browsers_dir(True, env={}), + BROWSER.FULL_BROWSERS_DATA_DIR) + + def test_resolve_fallback_path(self): + self.assertEqual( + BROWSER.resolve_full_browsers_dir(False, env={}), + BROWSER.FULL_BROWSERS_FALLBACK_DIR) + + def test_resolve_env_override_wins(self): + self.assertEqual( + BROWSER.resolve_full_browsers_dir( + True, env={"CHV_BROWSER_FULL_BROWSERS_DIR": + "/data/browsers"}), + "/data/browsers") + + def test_executable_found_ignores_headless_shell(self): + with tempfile.TemporaryDirectory() as tmp: + os.makedirs(os.path.join( + tmp, "chromium_headless_shell-1248", "chrome-linux")) + self.assertIsNone(BROWSER.full_chromium_executable(tmp)) + exe_dir = os.path.join( + tmp, "chromium-141.0.7390.37", "chrome-linux") + os.makedirs(exe_dir) + exe = os.path.join(exe_dir, "chrome") + open(exe, "w").write("x") + self.assertEqual(BROWSER.full_chromium_executable(tmp), exe) + + def test_status_reports_data_disk_dir(self): + with tempfile.TemporaryDirectory() as tmp: + agent, _c, _h = _agent([FakePage()], tmp, + full_browsers_dir=tmp) + st = agent.status() + self.assertEqual(st["full_browsers_dir"], tmp) + self.assertFalse(st["full_present"]) + + class MultiSessionTest(unittest.TestCase): def test_two_sessions_do_not_interfere(self): with tempfile.TemporaryDirectory() as tmp: @@ -423,25 +463,42 @@ class EngineSwitchTest(unittest.TestCase): # 回落后 shell 仍可工作。 self.assertTrue(agent.open("https://example.com")["ok"]) - def test_mount_unmount_full_switches_channel(self): + def test_mount_unmount_full_uses_executable_path(self): with tempfile.TemporaryDirectory() as tmp: - agent, _c, holder = _agent([FakePage()], tmp) - with mock.patch.object(BROWSER, "full_chromium_present", - return_value=True): - mounted = agent.mount_full() + exe_dir = os.path.join( + tmp, "chromium-141.0.7390.37", "chrome-linux") + os.makedirs(exe_dir) + exe = os.path.join(exe_dir, "chrome") + open(exe, "w").write("x") + agent, _c, holder = _agent([FakePage()], tmp, + full_browsers_dir=tmp) + mounted = agent.mount_full() self.assertTrue(mounted["ok"]) self.assertEqual(mounted["engine"], "full") self.assertTrue(mounted["mounted_full"]) entered = holder["entered"] - self.assertEqual(entered.calls[-1][1].get("channel"), - "chromium") + kwargs = entered.calls[-1][1] + self.assertEqual(kwargs.get("executable_path"), exe) + self.assertNotIn("channel", kwargs) unmounted = agent.unmount_full() self.assertTrue(unmounted["ok"]) self.assertEqual(unmounted["engine"], "shell") self.assertFalse(unmounted["mounted_full"]) self.assertNotIn("channel", entered.calls[-1][1]) + self.assertNotIn("executable_path", entered.calls[-1][1]) self.assertTrue(agent.open("https://example.com")["ok"]) + def test_mount_full_error_names_data_disk_install(self): + with tempfile.TemporaryDirectory() as tmp: + agent, _c, _h = _agent([FakePage()], tmp, + full_browsers_dir=os.path.join( + tmp, "browsers")) + resp = agent.mount_full() + self.assertFalse(resp["ok"]) + self.assertTrue(resp["fallback"]) + self.assertIn(os.path.join(tmp, "browsers"), + resp["error"]) + def test_handle_request_routes_new_ops(self): with tempfile.TemporaryDirectory() as tmp: agent, _c, _h = _agent([FakePage()], tmp)