feat(chv): 完整 Chromium 改装数据盘

根盘只剩 ~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。
This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent 435664925e
commit 9d986becaf
2 files changed
+134 -26

No files matched your search

+69 -18
View File
@@ -19,6 +19,9 @@
Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完 Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完
卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base; 卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base;
未安装时 mount_full 明确报错并回落 shell,不硬凑通过。 未安装时 mount_full 明确报错并回落 shell,不硬凑通过。
完整版装在数据盘 /home/chv/workspace/.chv-browsers(根盘只剩
约 600MB,装根盘会 ENOSPC;full 引擎启动时直接传
executable_path,不走 channel/env)。
* 截图:JPEG quality 70,viewport 宽封顶 1280(上下文创建时 * 截图:JPEG quality 70,viewport 宽封顶 1280(上下文创建时
定死 1280x800);diff_only 时与上一帧比对,字节相同即不落盘、 定死 1280x800);diff_only 时与上一帧比对,字节相同即不落盘、
返回 changed=false(整帧相同跳过;变化区裁剪是阶段 4 的事, 返回 changed=false(整帧相同跳过;变化区裁剪是阶段 4 的事,
@@ -77,6 +80,11 @@ PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright"
WORKSPACE_MOUNT = "/home/chv/workspace" WORKSPACE_MOUNT = "/home/chv/workspace"
PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile" PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile"
PROFILE_FALLBACK_DIR = "/var/lib/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 CDP_PORT_DEFAULT = 9222
# 截图规格(方案阶段 4 口径,阶段 3 先落地编码与尺寸两项): # 截图规格(方案阶段 4 口径,阶段 3 先落地编码与尺寸两项):
# JPEG quality 70,宽封顶 1280(上下文 viewport 定死 1280x800)。 # JPEG quality 70,宽封顶 1280(上下文 viewport 定死 1280x800)。
@@ -128,19 +136,39 @@ def resolve_profile_dir(env=None, ismount=None):
return PROFILE_FALLBACK_DIR, False return PROFILE_FALLBACK_DIR, False
def full_chromium_present(browsers_path=None): def full_chromium_executable(browsers_path=None):
"""完整 Chromium 是否已装:ms-playwright 下有 chromium-* """完整 Chromium 可执行文件路径(数据盘安装目录下),未装返回 None。
目录且含 chrome-linux/chrome(headless-shell 目录不算)。""" headless-shell 目录不算。"""
base = browsers_path or os.environ.get( base = browsers_path or os.environ.get(
"PLAYWRIGHT_BROWSERS_PATH", PLAYWRIGHT_BROWSERS_PATH) "PLAYWRIGHT_BROWSERS_PATH", PLAYWRIGHT_BROWSERS_PATH)
for cand in glob.glob(os.path.join(base, "chromium-*")): for cand in glob.glob(os.path.join(base, "chromium-*")):
if os.path.basename(cand).startswith( if os.path.basename(cand).startswith(
"chromium_headless_shell"): "chromium_headless_shell"):
continue continue
if os.path.isfile(os.path.join( exe = os.path.join(cand, "chrome-linux", "chrome")
cand, "chrome-linux", "chrome")): if os.path.isfile(exe):
return True return exe
return False 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, def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
@@ -218,7 +246,7 @@ class BrowserAgent:
_load_playwright()。""" _load_playwright()。"""
def __init__(self, playwright_factory=None, shots_dir=SHOTS_DIR, 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._factory = playwright_factory or _load_playwright
self._shots_dir = shots_dir self._shots_dir = shots_dir
if profile_dir: if profile_dir:
@@ -228,6 +256,9 @@ class BrowserAgent:
else: else:
self._profile_dir, self._profile_on_data_disk = \ self._profile_dir, self._profile_on_data_disk = \
resolve_profile_dir() resolve_profile_dir()
self._full_browsers_dir = (
full_browsers_dir or resolve_full_browsers_dir(
self._profile_on_data_disk))
try: try:
self._cdp_port = int(cdp_port or os.environ.get( self._cdp_port = int(cdp_port or os.environ.get(
"CHV_BROWSER_CDP_PORT") or CDP_PORT_DEFAULT) "CHV_BROWSER_CDP_PORT") or CDP_PORT_DEFAULT)
@@ -279,7 +310,18 @@ class BrowserAgent:
"--remote-debugging-address=0.0.0.0"], "--remote-debugging-address=0.0.0.0"],
} }
if engine == "full": 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 = getattr(self._entered.chromium,
"launch_persistent_context", None) "launch_persistent_context", None)
if launch is None: if launch is None:
@@ -439,7 +481,9 @@ class BrowserAgent:
"sessions": [s["session"] for s in "sessions": [s["session"] for s in
self.sessions()["sessions"]], self.sessions()["sessions"]],
"mounted_full": self._mounted_full, "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): def cdp(self):
ok, err = self._ensure_browser() ok, err = self._ensure_browser()
@@ -457,12 +501,16 @@ class BrowserAgent:
return {"ok": False, return {"ok": False,
"error": "未知引擎:%r(只允许 shell/full)" "error": "未知引擎:%r(只允许 shell/full)"
% (engine,)} % (engine,)}
if engine == "full" and not full_chromium_present(): if engine == "full" and not full_chromium_present(
self._full_browsers_dir):
return {"ok": False, return {"ok": False,
"error": "完整 Chromium 未安装(未见 chromium-* " "error": "完整 Chromium 未安装(%s 下未见 "
"chrome-linux/chrome),回落 headless-shell;" "chromium-*/chrome-linux/chrome),回落 "
"先跑 python3 -m playwright install " "headless-shell;先跑 PLAYWRIGHT_BROWSERS_"
"chromium 安装", "PATH=%s python3 -m playwright install "
"chromium 安装(装数据盘,根盘空间不够)"
% (self._full_browsers_dir,
self._full_browsers_dir),
"engine": self._engine, "fallback": True, "engine": self._engine, "fallback": True,
"mounted_full": False} "mounted_full": False}
ok, err = self._ensure_browser(engine) ok, err = self._ensure_browser(engine)
@@ -482,11 +530,14 @@ class BrowserAgent:
"profile": self._profile_dir} "profile": self._profile_dir}
def mount_full(self): def mount_full(self):
if not full_chromium_present(): if not full_chromium_present(self._full_browsers_dir):
return {"ok": False, return {"ok": False,
"error": "完整 Chromium 未安装,无法挂载(回落 " "error": "完整 Chromium 未安装,无法挂载(回落 "
"headless-shell 继续工作);先跑 python3 " "headless-shell 继续工作);先跑 "
"-m playwright install chromium 安装", "PLAYWRIGHT_BROWSERS_PATH=%s python3 -m "
"playwright install chromium 安装(装数据"
"盘,根盘空间不够)"
% self._full_browsers_dir,
"engine": self._engine, "fallback": True, "engine": self._engine, "fallback": True,
"mounted_full": False} "mounted_full": False}
return self.switch_engine("full") return self.switch_engine("full")
+65 -8
View File
@@ -111,7 +111,7 @@ class FakePlaywright:
return False return False
def _agent(pages, tmp): def _agent(pages, tmp, full_browsers_dir=None):
ctx = FakeContext(pages) ctx = FakeContext(pages)
entered_holder = {} entered_holder = {}
@@ -126,7 +126,8 @@ def _agent(pages, tmp):
agent = BROWSER.BrowserAgent( agent = BROWSER.BrowserAgent(
playwright_factory=lambda: PW(), shots_dir=tmp, 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 return agent, ctx, entered_holder
@@ -172,6 +173,45 @@ class FullPresentTest(unittest.TestCase):
self.assertFalse(BROWSER.full_chromium_present(tmp)) 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): class MultiSessionTest(unittest.TestCase):
def test_two_sessions_do_not_interfere(self): def test_two_sessions_do_not_interfere(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -423,25 +463,42 @@ class EngineSwitchTest(unittest.TestCase):
# 回落后 shell 仍可工作。 # 回落后 shell 仍可工作。
self.assertTrue(agent.open("https://example.com")["ok"]) 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: with tempfile.TemporaryDirectory() as tmp:
agent, _c, holder = _agent([FakePage()], tmp) exe_dir = os.path.join(
with mock.patch.object(BROWSER, "full_chromium_present", tmp, "chromium-141.0.7390.37", "chrome-linux")
return_value=True): 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() mounted = agent.mount_full()
self.assertTrue(mounted["ok"]) self.assertTrue(mounted["ok"])
self.assertEqual(mounted["engine"], "full") self.assertEqual(mounted["engine"], "full")
self.assertTrue(mounted["mounted_full"]) self.assertTrue(mounted["mounted_full"])
entered = holder["entered"] entered = holder["entered"]
self.assertEqual(entered.calls[-1][1].get("channel"), kwargs = entered.calls[-1][1]
"chromium") self.assertEqual(kwargs.get("executable_path"), exe)
self.assertNotIn("channel", kwargs)
unmounted = agent.unmount_full() unmounted = agent.unmount_full()
self.assertTrue(unmounted["ok"]) self.assertTrue(unmounted["ok"])
self.assertEqual(unmounted["engine"], "shell") self.assertEqual(unmounted["engine"], "shell")
self.assertFalse(unmounted["mounted_full"]) self.assertFalse(unmounted["mounted_full"])
self.assertNotIn("channel", entered.calls[-1][1]) self.assertNotIn("channel", entered.calls[-1][1])
self.assertNotIn("executable_path", entered.calls[-1][1])
self.assertTrue(agent.open("https://example.com")["ok"]) 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): def test_handle_request_routes_new_ops(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
agent, _c, _h = _agent([FakePage()], tmp) agent, _c, _h = _agent([FakePage()], tmp)