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:
1 parent
435664925e
commit
9d986becaf
2 files changed
+135
-27
No files matched your search
+69
-18
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
mounted = agent.mount_full()
|
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.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)
|
||||||
|
|||||||
Reference in new issue
Block a user