diff --git a/README.md b/README.md index d9be43f..ace205a 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ print(runtime_diagnostics_dict(diag)) # 可 JSON 序列化 自 **1.2.11** 起,`runtime_diagnostics_dict` 额外包含只读字段 `machine_power`(AC 供电 / 可选 nvidia-smi 限频信息)。探不到则 `probed=false`,**不会**因此把 health 标为 fatal。 +自 **1.2.13** 起,提供按 Profile 精准关闭:`ensure_profile_free` / `force_quit_profile_browsers`(只杀命令行含该 `user-data-dir` 的 Chrome/Edge,不碰系统默认浏览器),并可选清理崩溃残留 `SingletonLock`。 + ### RPA 有界就绪等待与启动超时(1.2.11+) ```python @@ -298,6 +300,20 @@ if result.timed_out: # 或环境变量 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 ``` +### RPA Profile 占用释放(1.2.13+) + +```python +from jiangchang_skill_core.rpa import ensure_profile_free, launch_persistent_browser +from jiangchang_skill_core.rpa.errors import ERR_PROFILE_IN_USE + +# 发布前:关掉账号管理「打开浏览器」占用的同一 Profile,再启动 +result = ensure_profile_free(profile_dir) +# result.pids_before / killed_ops / stale_lock_cleared / freed + +# 或在统一启动封装上打开开关(默认 False,保持旧行为) +# context = await launch_persistent_browser(pw, profile_dir=..., ensure_free=True) +``` + 薄错误码(技能自行决定是否 fatal / retryable): - `ERROR:UI_NOT_READY` diff --git a/pyproject.toml b/pyproject.toml index 1d868e9..a68b3f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jiangchang-platform-kit" -version = "1.2.12" +version = "1.2.13" description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树" readme = "README.md" requires-python = ">=3.10" diff --git a/src/jiangchang_skill_core/rpa/__init__.py b/src/jiangchang_skill_core/rpa/__init__.py index b4e5509..b5fcd22 100644 --- a/src/jiangchang_skill_core/rpa/__init__.py +++ b/src/jiangchang_skill_core/rpa/__init__.py @@ -14,6 +14,15 @@ try: except ImportError: RpaVideoSession = None # type: ignore[misc, assignment] +from .profile_lock import ( + EnsureProfileFreeResult, + clear_stale_singleton_locks, + ensure_profile_free, + force_quit_profile_browsers, + is_profile_lock_error, + list_profile_browser_pids, + list_profile_browser_root_pids, +) from .wait import PollUntilResult, poll_until, poll_until_sync __all__ = [ @@ -28,6 +37,13 @@ __all__ = [ "PollUntilResult", "poll_until", "poll_until_sync", + "EnsureProfileFreeResult", + "clear_stale_singleton_locks", + "ensure_profile_free", + "force_quit_profile_browsers", + "is_profile_lock_error", + "list_profile_browser_pids", + "list_profile_browser_root_pids", # re-export common anti_detect helpers "random_delay", "human_delay_short", diff --git a/src/jiangchang_skill_core/rpa/browser.py b/src/jiangchang_skill_core/rpa/browser.py index ccfe0e8..bfce0a2 100644 --- a/src/jiangchang_skill_core/rpa/browser.py +++ b/src/jiangchang_skill_core/rpa/browser.py @@ -44,6 +44,7 @@ async def launch_persistent_browser( headless: bool | None = None, extra_args: list[str] | None = None, timeout_ms: int | None = None, + ensure_free: bool = False, ) -> "BrowserContext": """用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。 @@ -54,10 +55,20 @@ async def launch_persistent_browser( 可选启动超时(毫秒)。为 None 时读 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS; 仍为空则不包裹超时(与历史行为一致)。超时抛 RuntimeError,前缀 ERROR:BROWSER_START_TIMEOUT。本函数不做自动重试。 + + ensure_free: + 为 True 时,启动前调用 ``ensure_profile_free``:精准关闭占用该 + Profile 的 Chrome/Edge,并清理崩溃残留 Singleton 锁。默认 False, + 保持历史行为;发布类技能建议传 True,或自行先调 ensure_profile_free。 """ if headless is None: headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) + if ensure_free: + from .profile_lock import ensure_profile_free + + ensure_profile_free(profile_dir) + chrome = executable_path or find_chrome_executable() if not chrome: raise RuntimeError( diff --git a/src/jiangchang_skill_core/rpa/errors.py b/src/jiangchang_skill_core/rpa/errors.py index 33dfd2d..5929e26 100644 --- a/src/jiangchang_skill_core/rpa/errors.py +++ b/src/jiangchang_skill_core/rpa/errors.py @@ -12,6 +12,7 @@ ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND" ERR_UI_NOT_READY = "ERROR:UI_NOT_READY" ERR_BROWSER_START_TIMEOUT = "ERROR:BROWSER_START_TIMEOUT" ERR_ENV_DEGRADED = "ERROR:ENV_DEGRADED" +ERR_PROFILE_IN_USE = "ERROR:PROFILE_IN_USE" __all__ = [ "ERR_REQUIRE_LOGIN", @@ -24,4 +25,5 @@ __all__ = [ "ERR_UI_NOT_READY", "ERR_BROWSER_START_TIMEOUT", "ERR_ENV_DEGRADED", + "ERR_PROFILE_IN_USE", ] diff --git a/src/jiangchang_skill_core/rpa/profile_lock.py b/src/jiangchang_skill_core/rpa/profile_lock.py new file mode 100644 index 0000000..251c62a --- /dev/null +++ b/src/jiangchang_skill_core/rpa/profile_lock.py @@ -0,0 +1,384 @@ +"""按 Chromium Profile(user-data-dir)精准占用检测 / 关闭 / 僵锁清理。 + +只匹配命令行包含该 Profile 绝对路径的 chrome/msedge,不碰系统默认浏览器。 +供发布类 RPA 在 launch_persistent_context 前调用,避免「账号管理打开浏览器未关」撞锁失败。 +""" + +from __future__ import annotations + +import base64 +import logging +import os +import subprocess +import sys +import time +from dataclasses import dataclass, field +from typing import Iterable, List, Sequence + +from .errors import ERR_PROFILE_IN_USE + +_logger = logging.getLogger(__name__) + +# 过短路径误匹配面过大(正式账号 Profile 远长于此) +_MIN_PROFILE_PATH_LEN = 12 + +_LOCK_HINTS = ( + "processsingleton", + "profile is already in use", + "user data directory is already in use", + "singletonlock", + "browser is already running", + "failed to create a processsingleton", +) + +_SINGLETON_NAMES = ("SingletonLock", "SingletonCookie", "SingletonSocket") + + +@dataclass(frozen=True) +class EnsureProfileFreeResult: + """ensure_profile_free 的结构化结果(便于技能打日志)。""" + + profile_dir: str + pids_before: tuple[int, ...] = () + killed_ops: int = 0 + stale_lock_cleared: bool = False + pids_after: tuple[int, ...] = () + freed: bool = True + notes: tuple[str, ...] = field(default_factory=tuple) + + +def is_profile_lock_error(exc: BaseException) -> bool: + """判断异常文案是否像 Chromium Profile 被占用。""" + msg = str(exc).lower() + return any(n in msg for n in _LOCK_HINTS) + + +def _norm_profile(profile_dir: str) -> str: + return os.path.abspath((profile_dir or "").strip()) + + +def _profile_path_b64(profile_dir: str) -> str: + """UTF-8 Base64,供 PowerShell 解码;避免 -Command 引号/编码弄坏中文路径。""" + return base64.b64encode(_norm_profile(profile_dir).encode("utf-8")).decode("ascii") + + +def _ps_decode_profile_path(path_b64: str) -> str: + return ( + "$ErrorActionPreference = 'SilentlyContinue'\n" + f"$p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{path_b64}'))\n" + "if (-not $p) { exit 0 }\n" + ) + + +def _dedupe_pids(pids: Iterable[int]) -> list[int]: + seen: set[int] = set() + out: list[int] = [] + for pid in pids: + if pid <= 0 or pid in seen: + continue + seen.add(pid) + out.append(pid) + return out + + +def list_profile_browser_pids(profile_dir: str) -> list[int]: + """查找占用该 user-data-dir 的 chrome/msedge 进程(匹配完整 Profile 路径)。""" + profile_abs = _norm_profile(profile_dir) + if not profile_abs or len(profile_abs) < _MIN_PROFILE_PATH_LEN: + return [] + if sys.platform == "win32": + return _list_pids_win(profile_abs, roots_only=False) + return _list_pids_posix(profile_abs, roots_only=False) + + +def list_profile_browser_root_pids(profile_dir: str) -> list[int]: + """仅主进程(命令行不含 --type=),便于 taskkill /T 收整棵进程树。""" + profile_abs = _norm_profile(profile_dir) + if not profile_abs or len(profile_abs) < _MIN_PROFILE_PATH_LEN: + return [] + if sys.platform == "win32": + return _list_pids_win(profile_abs, roots_only=True) + return _list_pids_posix(profile_abs, roots_only=True) + + +def _list_pids_win(profile_abs: str, *, roots_only: bool) -> list[int]: + root_clause = ( + "$_.CommandLine -notmatch '--type='" + if roots_only + else "$true" + ) + ps = ( + _ps_decode_profile_path(_profile_path_b64(profile_abs)) + + "Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='msedge.exe'\" |\n" + + " Where-Object {\n" + + " $_.CommandLine -and\n" + + " $_.CommandLine.Contains($p) -and\n" + + f" ({root_clause})\n" + + " } |\n" + + " ForEach-Object { $_.ProcessId }\n" + ) + try: + out = subprocess.check_output( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps], + text=True, + encoding="utf-8", + errors="replace", + timeout=20, + ) + return _dedupe_pids( + int(line.strip()) for line in (out or "").splitlines() if line.strip().isdigit() + ) + except Exception: + _logger.warning( + "profile_pid_scan_failed profile_dir=%s roots_only=%s", + profile_abs, + roots_only, + exc_info=True, + ) + return [] + + +def _list_pids_posix(profile_abs: str, *, roots_only: bool) -> list[int]: + """尽力而为:扫 chrome/chromium/msedge 命令行。无 psutil 依赖。""" + try: + out = subprocess.check_output( + ["ps", "-ax", "-o", "pid=,command="], + text=True, + encoding="utf-8", + errors="replace", + timeout=15, + ) + except Exception: + _logger.warning("profile_pid_scan_failed_posix profile_dir=%s", profile_abs, exc_info=True) + return [] + needle = profile_abs + names = ("chrome", "chromium", "msedge", "google-chrome") + pids: list[int] = [] + for line in (out or "").splitlines(): + text = line.strip() + if not text: + continue + parts = text.split(None, 1) + if len(parts) < 2: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + cmd = parts[1] + if needle not in cmd: + continue + low = cmd.lower() + if not any(n in low for n in names): + continue + if roots_only and "--type=" in cmd: + continue + pids.append(pid) + return _dedupe_pids(pids) + + +def force_quit_profile_browsers(profile_dir: str, *, settle_sec: float = 0.4) -> int: + """仅结束命令行包含该 Profile 绝对路径的 Chrome/Edge 进程树。 + + 返回发起结束的次数(含二次清残留),不表示最终仍占用的进程数。 + """ + raw = (profile_dir or "").strip() + if not raw: + _logger.warning("browser_force_quit_skipped_bad_profile profile_dir=%r", profile_dir) + return 0 + profile_abs = _norm_profile(raw) + if len(profile_abs) < _MIN_PROFILE_PATH_LEN: + _logger.warning("browser_force_quit_skipped_bad_profile profile_dir=%r", profile_dir) + return 0 + + killed = 0 + roots = list_profile_browser_root_pids(profile_abs) + targets = roots or list_profile_browser_pids(profile_abs) + killed += _kill_pids(targets) + + if settle_sec > 0: + time.sleep(settle_sec) + leftovers = list_profile_browser_pids(profile_abs) + if leftovers: + killed += _kill_pids(leftovers, force=True) + + remain = list_profile_browser_pids(profile_abs) + _logger.info( + "browser_force_quit profile_dir=%s roots=%s killed_ops=%s remain=%s", + profile_abs, + roots, + killed, + remain, + ) + return killed + + +def _kill_pids(pids: Sequence[int], *, force: bool = False) -> int: + ops = 0 + for pid in pids: + try: + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + os.kill(pid, 9 if force else 15) + ops += 1 + except Exception: + _logger.warning("browser_force_quit_pid_failed pid=%s", pid, exc_info=True) + return ops + + +def _pid_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + if sys.platform == "win32": + # 0 信号在 Windows 上不可靠;用 tasklist + out = subprocess.check_output( + ["tasklist", "/FI", f"PID eq {pid}", "/NH"], + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + ) + return str(pid) in (out or "") + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return False + + +def _parse_singleton_target_pid(lock_path: str) -> int | None: + """从 SingletonLock 解析目标 PID(symlink 目标形如 hostname-pid)。""" + try: + if os.path.islink(lock_path): + target = os.readlink(lock_path) + else: + # Windows 上偶发为普通文件,内容也可能是 hostname-pid + with open(lock_path, "rb") as fh: + raw = fh.read(256) + target = raw.decode("utf-8", errors="ignore").strip() + if not target: + return None + pid_s = target.rsplit("-", 1)[-1].strip() + pid = int(pid_s) + return pid if pid > 0 else None + except (OSError, ValueError): + return None + + +def clear_stale_singleton_locks(profile_dir: str) -> bool: + """若 Profile 无存活占用进程,清理崩溃残留的 Singleton* 文件。 + + 有活进程占用时不删。锁指向的 PID 仍存活时也不删。 + 返回是否删除了至少一个文件。 + """ + profile_abs = _norm_profile(profile_dir) + if not profile_abs or not os.path.isdir(profile_abs): + return False + if list_profile_browser_pids(profile_abs): + return False + + lock_path = os.path.join(profile_abs, "SingletonLock") + if os.path.lexists(lock_path): + pid = _parse_singleton_target_pid(lock_path) + if pid is not None and _pid_alive(pid): + # 锁指向的进程仍在,但命令行未必含本路径(少见)——保守不删 + _logger.info( + "singleton_lock_held_by_pid profile_dir=%s pid=%s", + profile_abs, + pid, + ) + return False + + removed = False + for name in _SINGLETON_NAMES: + path = os.path.join(profile_abs, name) + if not os.path.lexists(path): + continue + try: + os.remove(path) + removed = True + _logger.info("stale_singleton_removed profile_dir=%s name=%s", profile_abs, name) + except OSError: + _logger.warning( + "stale_singleton_remove_failed profile_dir=%s name=%s", + profile_abs, + name, + exc_info=True, + ) + return removed + + +def ensure_profile_free( + profile_dir: str, + *, + force_quit: bool = True, + clear_stale_lock: bool = True, + wait_sec: float = 1.0, + raise_if_busy: bool = True, +) -> EnsureProfileFreeResult: + """确保指定 Profile 可被 Playwright launch_persistent_context 独占使用。 + + 默认:精准关闭占用该目录的浏览器 → 清僵锁 → 复查。 + 仍被占用且 raise_if_busy=True 时抛 RuntimeError(前缀 ERROR:PROFILE_IN_USE)。 + """ + profile_abs = _norm_profile(profile_dir) + notes: List[str] = [] + if not profile_abs: + raise ValueError("profile_dir 不能为空") + if len(profile_abs) < _MIN_PROFILE_PATH_LEN: + raise ValueError(f"profile_dir 过短,拒绝操作: {profile_abs!r}") + + before = tuple(list_profile_browser_pids(profile_abs)) + killed_ops = 0 + if before and force_quit: + killed_ops = force_quit_profile_browsers(profile_abs) + notes.append(f"force_quit killed_ops={killed_ops}") + elif before and not force_quit: + notes.append("occupied_skip_quit") + + stale_cleared = False + if clear_stale_lock: + stale_cleared = clear_stale_singleton_locks(profile_abs) + if stale_cleared: + notes.append("stale_singleton_cleared") + + if wait_sec > 0: + time.sleep(wait_sec) + + after = tuple(list_profile_browser_pids(profile_abs)) + freed = len(after) == 0 + result = EnsureProfileFreeResult( + profile_dir=profile_abs, + pids_before=before, + killed_ops=killed_ops, + stale_lock_cleared=stale_cleared, + pids_after=after, + freed=freed, + notes=tuple(notes), + ) + _logger.info( + "ensure_profile_free profile_dir=%s before=%s after=%s killed_ops=%s " + "stale_cleared=%s freed=%s", + profile_abs, + before, + after, + killed_ops, + stale_cleared, + freed, + ) + if not freed and raise_if_busy: + raise RuntimeError( + f"{ERR_PROFILE_IN_USE} 账号浏览器配置目录仍被占用(pids={list(after)}):" + f"{profile_abs}。请关闭账号管理打开的对应窗口后重试。" + ) + return result diff --git a/tests/test_profile_lock.py b/tests/test_profile_lock.py new file mode 100644 index 0000000..555949b --- /dev/null +++ b/tests/test_profile_lock.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""Profile 精准关闭 / 僵锁清理单元测试。""" +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from jiangchang_skill_core.rpa.errors import ERR_PROFILE_IN_USE +from jiangchang_skill_core.rpa.profile_lock import ( + clear_stale_singleton_locks, + ensure_profile_free, + force_quit_profile_browsers, + is_profile_lock_error, +) + + +class TestProfileLockHelpers(unittest.TestCase): + def test_is_profile_lock_error(self) -> None: + self.assertTrue( + is_profile_lock_error( + RuntimeError("User data directory is already in use") + ) + ) + self.assertTrue(is_profile_lock_error(RuntimeError("SingletonLock"))) + self.assertFalse(is_profile_lock_error(RuntimeError("timeout waiting"))) + + def test_force_quit_rejects_short_path(self) -> None: + self.assertEqual(force_quit_profile_browsers("C:\\a"), 0) + + def test_clear_stale_singleton_when_no_pids(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + profile = Path(tmp) / "account-manager" / "profiles" / "acc-1" + profile.mkdir(parents=True) + lock = profile / "SingletonLock" + cookie = profile / "SingletonCookie" + lock.write_text("deadhost-999999", encoding="utf-8") + cookie.write_text("x", encoding="utf-8") + with mock.patch( + "jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids", + return_value=[], + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock._pid_alive", + return_value=False, + ): + removed = clear_stale_singleton_locks(str(profile)) + self.assertTrue(removed) + self.assertFalse(lock.exists()) + self.assertFalse(cookie.exists()) + + def test_clear_stale_skips_when_pids_present(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + profile = Path(tmp) / "profiles" / "acc-2" + profile.mkdir(parents=True) + lock = profile / "SingletonLock" + lock.write_text("host-1", encoding="utf-8") + with mock.patch( + "jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids", + return_value=[1234], + ): + removed = clear_stale_singleton_locks(str(profile)) + self.assertFalse(removed) + self.assertTrue(lock.exists()) + + def test_ensure_profile_free_quits_then_ok(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + profile = Path(tmp) / "profiles" / "acc-long-enough" + profile.mkdir(parents=True) + with mock.patch( + "jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids", + side_effect=[[111], []], + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.force_quit_profile_browsers", + return_value=1, + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.clear_stale_singleton_locks", + return_value=False, + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.time.sleep", + ): + result = ensure_profile_free( + str(profile), wait_sec=0, raise_if_busy=True + ) + self.assertTrue(result.freed) + self.assertEqual(result.pids_before, (111,)) + self.assertEqual(result.pids_after, ()) + self.assertEqual(result.killed_ops, 1) + + def test_ensure_profile_free_raises_when_still_busy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + profile = Path(tmp) / "profiles" / "acc-still-busy" + profile.mkdir(parents=True) + with mock.patch( + "jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids", + return_value=[222], + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.force_quit_profile_browsers", + return_value=1, + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.clear_stale_singleton_locks", + return_value=False, + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.time.sleep", + ): + with self.assertRaises(RuntimeError) as ctx: + ensure_profile_free(str(profile), wait_sec=0, raise_if_busy=True) + self.assertIn(ERR_PROFILE_IN_USE, str(ctx.exception)) + + def test_ensure_profile_free_no_raise_when_requested(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + profile = Path(tmp) / "profiles" / "acc-keep-busy" + profile.mkdir(parents=True) + with mock.patch( + "jiangchang_skill_core.rpa.profile_lock.list_profile_browser_pids", + return_value=[333], + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.force_quit_profile_browsers", + return_value=0, + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.clear_stale_singleton_locks", + return_value=False, + ), mock.patch( + "jiangchang_skill_core.rpa.profile_lock.time.sleep", + ): + result = ensure_profile_free( + str(profile), wait_sec=0, raise_if_busy=False + ) + self.assertFalse(result.freed) + + +if __name__ == "__main__": + unittest.main()