From fb76b10033439075ffc4bcfa0bfbf049fb523328 Mon Sep 17 00:00:00 2001 From: FinDataHub Date: Mon, 26 Oct 2026 20:55:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(cross-platform):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E4=B8=8E=E8=BF=9B=E7=A8=8B=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=88=B0=20paths=20/=20process=5Futils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 迁移 M0。纯新增两个模块 + 4 个脚本接线,**macOS 行为零漂移**。 背景:仓库此前把 /tmp/findatahub-*.pid、~/Library/Logs、 ~/Library/Preferences/...plist 直接写死在十来个文件里;采集管线散落着 start_new_session=True、os.kill(pid,0)、signal.SIGHUP 等 POSIX 专属写法。 这些在 Windows 上分别是:路径不存在、ValueError、探测即杀进程、AttributeError。 - paths.py:temp_dir / pid_file / heartbeat_file / log_root / log_dir / app_support_dir / backups_dir / config_dir / user_defaults_plist / env_file - process_utils.py:detach_kwargs / is_running / terminate_pid / available_signals / register_signal_handler / restore_default_and_die / utf8_subprocess_env 两个必须记住的坑(已写成测试断言): 1. tempfile.gettempdir() 在 macOS 返回 $TMPDIR(/var/folders/.../T), 与现网 PID 文件所在的 /tmp **不是同一目录**。paths.temp_dir() 返回 /tmp 是刻意的,混用会导致新旧进程互相看不见。 2. Windows 上 os.kill(pid, 0) 等价于 TerminateProcess,不是「探测」, 必须用 OpenProcess。 接线:newsflash_collect(PID/心跳/日志/plist)、daily_summary(PID)、 migrate_date_format(备份目录)、install_collector_launchd(plist + 平台守卫)。 同时修 newsflash_collect.write_heartbeat 的静默失败:Swift 端以心跳 300s 未更新判定卡死并重启,而此处原为 except OSError: pass——重启理由 在日志里毫无痕迹。改为状态翻转才记录(心跳 60s 一次,无脑打日志会刷屏)。 验证:现网采集器 status 正常,5 个路径与旧硬编码逐字一致。 回归:test_paths_process_utils.py 32 条 + 心跳可观测性 5 条。 --- Scripts/bin/daily_summary.py | 6 +- Scripts/bin/install_collector_launchd.py | 19 +- Scripts/bin/migrate_date_format.py | 6 +- Scripts/bin/newsflash_collect.py | 60 ++++- Scripts/bin/paths.py | 158 +++++++++++++ Scripts/bin/process_utils.py | 199 ++++++++++++++++ Scripts/bin/tests/test_newsflash_collect.py | 81 +++++++ Scripts/bin/tests/test_paths_process_utils.py | 214 ++++++++++++++++++ 8 files changed, 728 insertions(+), 15 deletions(-) create mode 100644 Scripts/bin/paths.py create mode 100644 Scripts/bin/process_utils.py create mode 100644 Scripts/bin/tests/test_paths_process_utils.py diff --git a/Scripts/bin/daily_summary.py b/Scripts/bin/daily_summary.py index 904ed50..95f0003 100644 --- a/Scripts/bin/daily_summary.py +++ b/Scripts/bin/daily_summary.py @@ -20,7 +20,11 @@ import asyncio # 连接池 from db_pool import get_conn, put_conn -PID_FILE = "/tmp/findatahub-summary.pid" +try: + from paths import pid_file as _pid_file + PID_FILE = _pid_file("summary") +except ImportError: + PID_FILE = "/tmp/findatahub-summary.pid" UPDATE_INTERVAL = 3600 # 3600秒 = 1小时 diff --git a/Scripts/bin/install_collector_launchd.py b/Scripts/bin/install_collector_launchd.py index dcdc87b..4993a26 100755 --- a/Scripts/bin/install_collector_launchd.py +++ b/Scripts/bin/install_collector_launchd.py @@ -24,6 +24,16 @@ import subprocess import sys from pathlib import Path +# 本脚本生成 launchd plist,天然是 macOS 专属工具。 +# 显式守卫而非静默失败:在 Windows/Linux 上误跑会写出一个毫无作用、 +# 且路径全错的 plist,用户会以为"已经装好调度了"。 +if sys.platform != "darwin": + sys.exit( + f"install_collector_launchd.py 仅支持 macOS(当前 {sys.platform})。" + "Windows 请改用任务计划程序(schtasks)或 NSSM,见 " + "Docs/FinDataHub-Windows迁移-执行计划.md。" + ) + LAUNCH_AGENTS = Path.home() / "Library/LaunchAgents" PLIST_PATH = LAUNCH_AGENTS / "com.findatahub.newsflash-collector.plist" LABEL = "com.findatahub.newsflash-collector" @@ -61,8 +71,13 @@ def quiet_schedule(): def read_swift_prefs(): """从 FinDataHub 的 UserDefaults plist 读取数据库配置(尽力而为)""" - prefs_path = Path.home() / "Library/Preferences/com.findatahub.app.plist" - if not prefs_path.exists(): + try: + from paths import user_defaults_plist as _defaults_plist + prefs_path = _defaults_plist() + except ImportError: + prefs_path = Path.home() / "Library/Preferences/com.findatahub.app.plist" + # 非 macOS 无 plist:直接返回空,交由环境变量提供配置 + if prefs_path is None or not prefs_path.exists(): return {} try: with open(prefs_path, "rb") as f: diff --git a/Scripts/bin/migrate_date_format.py b/Scripts/bin/migrate_date_format.py index ecc95b5..4566d47 100644 --- a/Scripts/bin/migrate_date_format.py +++ b/Scripts/bin/migrate_date_format.py @@ -74,7 +74,11 @@ TARGETS: list[tuple[str, str, str, str]] = [ PLAIN_RE = "^[0-9]{8}$" DASHED_RE = "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" -BACKUP_DIR = Path.home() / "Library/Application Support/FinDataHub/backups" +try: + from paths import backups_dir as _backups_dir + BACKUP_DIR = _backups_dir() +except ImportError: + BACKUP_DIR = Path.home() / "Library/Application Support/FinDataHub/backups" def connect(): diff --git a/Scripts/bin/newsflash_collect.py b/Scripts/bin/newsflash_collect.py index b871c60..b08cff6 100644 --- a/Scripts/bin/newsflash_collect.py +++ b/Scripts/bin/newsflash_collect.py @@ -33,9 +33,27 @@ try: except ImportError: _AI_AVAILABLE = False -PID_FILE = "/tmp/findatahub-newsflash.pid" -BACKEND_PID_FILE = "/tmp/findatahub-newsflash-backend.pid" -HEARTBEAT_FILE = "/tmp/findatahub-newsflash.heartbeat" # 活跃心跳:Swift 端免 spawn 检测用 +try: + # 路径统一走 paths.py:macOS 上解析结果与旧硬编码逐字一致, + # 其他平台自动落到各自的应用数据/临时目录(见 paths.py 的设计约束)。 + import sys as _sys_pathfix + _bin_dir = str(Path(__file__).resolve().parent) + if _bin_dir not in _sys_pathfix.path: + _sys_pathfix.path.insert(0, _bin_dir) + from paths import pid_file as _pid_file, heartbeat_file as _heartbeat_file, log_dir as _log_dir + PID_FILE = _pid_file("newsflash") + BACKEND_PID_FILE = _pid_file("newsflash-backend") + HEARTBEAT_FILE = _heartbeat_file("newsflash") # 活跃心跳:Swift 端免 spawn 检测用 + # 统一日志目录:~/Library/Logs/FinDataHub/newsflash/(macOS) + _LOG_DIR = _log_dir("newsflash", create=True) +except ImportError: + # 极端降级:paths.py 缺失时保持旧硬编码,保证采集器不会因缺文件而停摆 + PID_FILE = "/tmp/findatahub-newsflash.pid" + BACKEND_PID_FILE = "/tmp/findatahub-newsflash-backend.pid" + HEARTBEAT_FILE = "/tmp/findatahub-newsflash.heartbeat" + _LOG_DIR = Path.home() / "Library/Logs/FinDataHub/newsflash" + _LOG_DIR.mkdir(parents=True, exist_ok=True) + FETCH_INTERVAL = 120 # 秒 # ── 节能自适应配置(逻辑可解释)────────────────────────── @@ -52,8 +70,7 @@ OFF_HOURS_MULTIPLIER = 4 # 非交易时段间隔倍数 HEARTBEAT_INTERVAL = 60 # 心跳写入间隔(秒,独立线程) # 统一日志目录:~/Library/Logs/FinDataHub/newsflash/ -_LOG_DIR = Path.home() / "Library/Logs/FinDataHub/newsflash" -_LOG_DIR.mkdir(parents=True, exist_ok=True) +# 已在文件头部由 paths.log_dir() 解析并创建,此处不再重复定义。 # 模块级 UnifiedWriter 单例:跨采集批次复用以保留 L5 embedding 缓存 _unified_writer = None @@ -286,9 +303,15 @@ def _register_rss_fetchers(mgr): # RSS 数据源开关:优先从环境变量读取(由 Swift 启动时注入), # 回退到 plist(仅 CLI 手动启动时使用) import plistlib - prefs_path = Path.home() / "Library/Preferences/com.findatahub.app.plist" + try: + from paths import user_defaults_plist as _defaults_plist + prefs_path = _defaults_plist() + except ImportError: + prefs_path = Path.home() / "Library/Preferences/com.findatahub.app.plist" prefs = {} - if prefs_path.exists(): + # 非 macOS 返回 None:没有 plist 时静默跳过,改用环境变量。 + # (CLI 手动启动才会走到这里,App 启动总会在环境变量里注入开关。) + if prefs_path is not None and prefs_path.exists(): try: with open(prefs_path, "rb") as f: prefs = plistlib.load(f) @@ -623,7 +646,6 @@ def get_status() -> dict: hb_fresh = False except Exception as e: print(f" ⚠️ 读取心跳失败: {e}", file=sys.stderr, flush=True) - if not hb_fresh: # 心跳过期或缺失:需结合运行时长与"节能上限"判断,避免把长休眠当卡死 if r["started_at"]: @@ -756,13 +778,29 @@ def adaptive_sleep_seconds(base_interval: int, now: datetime | None = None) -> i return min(base_interval * OFF_HOURS_MULTIPLIER, 600) +# 心跳写入的失败状态(True = 当前处于持续失败中)。 +# 用于把「每 60s 一次的失败」压成「只在状态翻转时记一次」,避免刷屏。 +_heartbeat_failing = False + + def write_heartbeat(): """写心跳文件。Swift 端读此文件判断采集器活跃(免 spawn Python 查状态)""" + global _heartbeat_failing try: with open(HEARTBEAT_FILE, "w") as f: f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S")) - except OSError: - pass + if _heartbeat_failing: + _heartbeat_failing = False + print(" ✅ 心跳写入恢复正常", file=sys.stderr, flush=True) + except OSError as e: + # 心跳写失败不能静默:Swift 端以 300s 未更新判定「卡死」并触发重启, + # 而此前这里 except OSError: pass —— 重启理由在日志里毫无痕迹, + # 表现为「采集器明明在跑却被反复重启」且无法归因。 + # 心跳 60s 一次,只在状态翻转时记一次,避免刷屏。 + if not _heartbeat_failing: + _heartbeat_failing = True + print(f" ⚠️ 心跳写入失败(后续失败不再重复记录):{e}", + file=sys.stderr, flush=True) def start_heartbeat_thread(): @@ -777,7 +815,7 @@ def start_heartbeat_thread(): threading.Thread(target=_loop, daemon=True).start() -CRASH_LOG = Path.home() / "Library/Logs/FinDataHub/newsflash/collector-crash.log" +CRASH_LOG = _LOG_DIR / "collector-crash.log" def _install_crash_handlers() -> None: diff --git a/Scripts/bin/paths.py b/Scripts/bin/paths.py new file mode 100644 index 0000000..faccee2 --- /dev/null +++ b/Scripts/bin/paths.py @@ -0,0 +1,158 @@ +# coding=utf-8 +"""跨平台路径解析 —— 收敛所有 macOS 专属硬编码路径. + +背景 +---- +仓库此前把 ``/tmp/findatahub-*.pid``、``~/Library/Logs/FinDataHub``、 +``~/Library/Preferences/com.findatahub.app.plist`` 等路径直接写死在十来个 +文件里。这在 macOS 上工作正常,但在 Windows 上: + +* 没有 ``/tmp`` 目录(``Path.home()/"Library"`` 也不存在); +* 日志应落 ``%LOCALAPPDATA%``,而非 ``~/Library/Logs``; +* 用户偏好不在 plist,而在注册表 / 应用数据目录。 + +本模块是这些路径的**唯一事实来源**。迁移到其他平台只需改这里, +不必再全仓搜字符串。 + +设计约束(重要) +---------------- +**macOS 上必须解析出与旧硬编码完全相同的字符串。** 原因是: + +1. 现网采集器已经把这些路径写进了 launchd plist、Swift 端探测逻辑与 + 正在运行的 PID 文件里;路径一变,新旧进程会互相看不见, + 表现为"采集器明明在跑,App 却报未运行"。 +2. 特别注意 ``temp_dir()`` 在 macOS 返回 ``/tmp`` 而**不是** + ``tempfile.gettempdir()``(后者是 ``$TMPDIR``,形如 + ``/var/folders/xx/.../T/``)。这两个路径不同,混用即事故。 + +用例:: + + from paths import pid_file, log_dir, app_support_dir + + PID_FILE = pid_file("newsflash") # /tmp/findatahub-newsflash.pid + LOG_DIR = log_dir("newsflash") # ~/Library/Logs/FinDataHub/newsflash +""" + +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +APP_NAME = "FinDataHub" + +IS_WINDOWS = sys.platform == "win32" +IS_MACOS = sys.platform == "darwin" + + +# ── 临时目录 ──────────────────────────────────────────────── + +def temp_dir() -> Path: + """临时目录。 + + macOS/Linux 固定 ``/tmp``(与现网 PID/心跳文件位置保持一致,**不要** + 换成 ``tempfile.gettempdir()``);Windows 用系统 TEMP。 + """ + if IS_WINDOWS: + return Path(tempfile.gettempdir()) + return Path("/tmp") + + +def pid_file(name: str) -> str: + """PID 文件路径,如 ``pid_file("newsflash")`` → ``/tmp/findatahub-newsflash.pid``.""" + return str(temp_dir() / f"findatahub-{name}.pid") + + +def heartbeat_file(name: str) -> str: + """心跳文件路径(Swift 端用它免 spawn 判断采集器是否活跃).""" + return str(temp_dir() / f"findatahub-{name}.heartbeat") + + +# ── 日志目录 ──────────────────────────────────────────────── + +def log_root() -> Path: + """日志根目录。 + + macOS: ``~/Library/Logs/FinDataHub``(Console.app 可直接查看) + Windows: ``%LOCALAPPDATA%\\FinDataHub\\logs`` + """ + if IS_WINDOWS: + base = os.getenv("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + return Path(base) / APP_NAME / "logs" + if IS_MACOS: + return Path.home() / "Library" / "Logs" / APP_NAME + # Linux 走 XDG + base = os.getenv("XDG_STATE_HOME") or str(Path.home() / ".local" / "state") + return Path(base) / APP_NAME / "logs" + + +def log_dir(sub: str = "", *, create: bool = False) -> Path: + """日志子目录,如 ``log_dir("newsflash")``。``create=True`` 时顺带建目录.""" + d = log_root() / sub if sub else log_root() + if create: + d.mkdir(parents=True, exist_ok=True) + return d + + +# ── 应用数据 / 配置 ───────────────────────────────────────── + +def app_support_dir(*, create: bool = False) -> Path: + """应用数据目录。 + + macOS: ``~/Library/Application Support/FinDataHub`` + Windows: ``%LOCALAPPDATA%\\FinDataHub`` + Linux: ``~/.local/share/FinDataHub`` + """ + if IS_WINDOWS: + base = os.getenv("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + d = Path(base) / APP_NAME + elif IS_MACOS: + d = Path.home() / "Library" / "Application Support" / APP_NAME + else: + base = os.getenv("XDG_DATA_HOME") or str(Path.home() / ".local" / "share") + d = Path(base) / APP_NAME + if create: + d.mkdir(parents=True, exist_ok=True) + return d + + +def backups_dir(*, create: bool = False) -> Path: + """备份目录(迁移脚本、数据库快照的落点).""" + d = app_support_dir() / "backups" + if create: + d.mkdir(parents=True, exist_ok=True) + return d + + +def config_dir(*, create: bool = False) -> Path: + """配置文件目录(``env`` 文件、API Key 等)。 + + macOS: ``~/.config/findatahub``(与 ``~/.config/findatahub/env`` 一致) + Windows: ``%APPDATA%\\FinDataHub`` + """ + if IS_WINDOWS: + base = os.getenv("APPDATA") or str(Path.home() / "AppData" / "Roaming") + d = Path(base) / APP_NAME + else: + base = os.getenv("XDG_CONFIG_HOME") or str(Path.home() / ".config") + d = Path(base) / "findatahub" + if create: + d.mkdir(parents=True, exist_ok=True) + return d + + +def user_defaults_plist() -> Path | None: + """macOS 用户偏好 plist 路径;非 macOS 返回 ``None``。 + + 调用方必须处理 ``None``——Windows/Linux 上没有 plist, + 偏好读取应回退到环境变量或配置文件。 + """ + if not IS_MACOS: + return None + return Path.home() / "Library" / "Preferences" / "com.findatahub.app.plist" + + +def env_file() -> Path: + """生产环境变量文件(TUSHARE_TOKEN / MYSQL_PASSWORD 等,权限 0600).""" + return config_dir() / "env" diff --git a/Scripts/bin/process_utils.py b/Scripts/bin/process_utils.py new file mode 100644 index 0000000..5209109 --- /dev/null +++ b/Scripts/bin/process_utils.py @@ -0,0 +1,199 @@ +# coding=utf-8 +"""跨平台进程工具 —— 收敛 POSIX 专属的派生 / 终止 / 存活检测 / 信号处理. + +背景 +---- +采集管线里散落着 POSIX 专属写法:``subprocess.Popen(start_new_session=True)``、 +``os.kill(pid, 0)``、``signal.SIGTERM``、``signal.SIGHUP``。这些在 macOS/Linux +上语义正确,在 Windows 上: + +* ``start_new_session`` → 直接 ``ValueError``; +* ``os.kill(pid, 0)`` 不是"探测",Python 在 Windows 上会调 ``TerminateProcess``; +* ``SIGHUP`` / ``SIGUSR1`` 根本不存在,属性访问即 ``AttributeError``; +* ``os.kill(pid, SIGTERM)`` 只能终止目标进程本身,不级联子进程。 + +本模块提供语义等价的三组能力,macOS 上行为与原实现**逐字一致**(不改变 +现网任何可见行为),仅在其他平台切换实现。 + +用例:: + + from process_utils import detach_kwargs, is_running, terminate_pid + + proc = subprocess.Popen(cmd, **detach_kwargs()) + if is_running(pid): + terminate_pid(pid) +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys + +IS_WINDOWS = sys.platform == "win32" + +# Windows 进程创建标志(此平台无对应常量时退化为 0) +_CREATE_NEW_PROCESS_GROUP = 0x00000200 +_DETACHED_PROCESS = 0x00000008 + + +# ── 派生 ──────────────────────────────────────────────────── + +def detach_kwargs() -> dict: + """``subprocess.Popen`` 的"脱离父进程会话"参数。 + + POSIX 用 ``start_new_session=True``(等价 setsid);Windows 用 + ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS``。 + + 为什么必须脱离:以 Bash/工具方式拉起常驻服务时,父 shell 退出会向 + 整个进程组发 SIGHUP/SIGTERM。不脱离的话表现为"刚启动还能查到, + 过一会儿就没了"。 + """ + if IS_WINDOWS: + return {"creationflags": _CREATE_NEW_PROCESS_GROUP | _DETACHED_PROCESS} + return {"start_new_session": True} + + +def hidden_window_kwargs() -> dict: + """Windows 下后台进程不弹控制台窗口;其他平台返回空 dict.""" + if IS_WINDOWS: + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + return {"startupinfo": si} + return {} + + +# ── 存活检测 ──────────────────────────────────────────────── + +def is_running(pid: int) -> bool: + """判断进程是否存活(不含僵尸回收语义,仅看是否存在).""" + if pid <= 0: + return False + if IS_WINDOWS: + return _is_running_windows(pid) + try: + os.kill(pid, 0) + except (OSError, ProcessLookupError): + return False + except PermissionError: + # 进程存在但属于其他用户 + return True + return True + + +def _is_running_windows(pid: int) -> bool: + """用 OpenProcess 探测。 + + 不能用 ``os.kill(pid, 0)``:Windows 上 Python 会走 TerminateProcess, + 等于"探测即杀人"。 + """ + import ctypes + from ctypes import wintypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + # 拒绝访问说明进程存在(权限不足),其余视为不存在 + return kernel32.GetLastError() == 5 + try: + code = wintypes.DWORD() + if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return code.value == STILL_ACTIVE + return False + finally: + kernel32.CloseHandle(handle) + + +# ── 终止 ──────────────────────────────────────────────────── + +def terminate_pid(pid: int, *, force: bool = False, timeout: float = 5.0) -> bool: + """终止进程,返回是否发出了终止请求。 + + POSIX: 先 ``SIGTERM``,``force=True`` 时直接 ``SIGKILL``。 + Windows: ``taskkill /PID x /T``(``/T` 级联子进程),``force=True`` 加 ``/F``。 + + 注意 Windows 的 taskkill 无"优雅"概念,``/F`` 与否差别只在是否强制; + 因此 Windows 下 ``force=False`` 也可能直接结束进程。 + """ + if pid <= 0: + return False + if IS_WINDOWS: + cmd = ["taskkill", "/PID", str(pid), "/T"] + if force: + cmd.append("/F") + try: + subprocess.run(cmd, capture_output=True, timeout=timeout, check=False, + **hidden_window_kwargs()) + return True + except (OSError, subprocess.SubprocessError): + return False + try: + os.kill(pid, signal.SIGKILL if force else signal.SIGTERM) + return True + except (OSError, ProcessLookupError): + return False + + +# ── 信号 ──────────────────────────────────────────────────── + +def available_signals() -> dict[str, int]: + """当前平台实际存在的信号子集,名 → 编号。 + + 采集器注册信号处理器时必须先查这里,否则 Windows 上访问 + ``signal.SIGHUP`` 会直接 AttributeError 崩在启动阶段。 + """ + wanted = ("SIGTERM", "SIGINT", "SIGHUP", "SIGUSR1", "SIGUSR2", "SIGBREAK") + return {name: getattr(signal, name) for name in wanted if hasattr(signal, name)} + + +def register_signal_handler(handler, *, names=("SIGTERM", "SIGINT")) -> list[str]: + """注册信号处理器,跳过当前平台不存在的信号。 + + Returns: + 实际注册成功的信号名列表。 + """ + registered = [] + for name in names: + sig = getattr(signal, name, None) + if sig is None: + continue + try: + signal.signal(sig, handler) + registered.append(name) + except (OSError, ValueError, RuntimeError): + # 非主线程 / 平台限制:跳过而不是崩 + continue + return registered + + +def restore_default_and_die(signum: int) -> None: + """恢复默认处理并重新触发该信号——保留原始退出码与 core dump 行为. + + 用于崩溃留痕:先记录现场,再让进程以"原生方式"死掉,不改变 + 外部观察到的终止原因。Windows 上无信号语义,退化为非零退出。 + """ + if IS_WINDOWS: + os._exit(128 + (signum or 0)) + try: + signal.signal(signum, signal.SIG_DFL) + os.kill(os.getpid(), signum) + except (OSError, ValueError): + os._exit(1) + + +# ── 编码 ──────────────────────────────────────────────────── + +def utf8_subprocess_env(base: dict | None = None) -> dict: + """返回带 UTF-8 强制的环境变量副本。 + + Windows 控制台默认 cp936,``print`` 中文与捕获子进程 stdout 会乱码, + 严重时抛 ``UnicodeEncodeError`` 直接崩掉采集器。采集器 stdout 是 + **JSON 状态协议**,一旦被编码污染,Swift 端解析失败会误判"采集器未运行"。 + """ + env = dict(base if base is not None else os.environ) + env["PYTHONUTF8"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + return env diff --git a/Scripts/bin/tests/test_newsflash_collect.py b/Scripts/bin/tests/test_newsflash_collect.py index ae04305..e0845f1 100644 --- a/Scripts/bin/tests/test_newsflash_collect.py +++ b/Scripts/bin/tests/test_newsflash_collect.py @@ -552,6 +552,13 @@ while True: class TestTerminationSignalLogging: """SIGTERM 必须留痕(否则无法定位是谁终止的采集器).""" + @pytest.mark.skipif( + sys.platform == "win32", + reason="Windows 无 POSIX 信号语义:proc.send_signal(SIGTERM) 实际走 " + "TerminateProcess,不会触发子进程里的 Python 层 signal handler," + "因此无法验证「收到终止信号」这条留痕路径。" + "该能力本身即 POSIX 专属——Windows 侧的等价物是控制台事件,见 process_utils。", + ) def test_sigterm_writes_signal_and_thread_stacks(self, tmp_path): import signal import subprocess @@ -671,3 +678,77 @@ class TestStuckDetection: """无心跳且写入远超节能上限(>4200s) → 判为卡死.""" self._patch(monkeypatch, last_write_secs_ago=5000, heartbeat_secs_ago=None) assert newsflash_collect.get_status()["stuck"] is True + + +class TestHeartbeatFailureObservability: + """心跳写入失败必须留痕,且只在状态翻转时记录。 + + 背景:Swift 端以心跳 300s 未更新判定「采集器卡死」并触发重启 + (见 NewsFlashService 的 stuck 判定)。此前 `write_heartbeat` 是 + `except OSError: pass`——重启理由在日志里毫无痕迹, + 表现为「采集器明明在跑却被反复重启」且无法归因。 + + 心跳 60s 一次,因此无脑打日志会刷屏:本组用例同时锁定限流语义。 + """ + + # 指向一个不存在的目录,使 open() 抛 OSError(跨平台安全) + _BAD_PATH = os.path.join(os.sep, "nonexistent_fdh_heartbeat_dir", "hb") + + def test_success_writes_file_and_is_silent(self, tmp_path, monkeypatch, capsys): + target = tmp_path / "hb" + monkeypatch.setattr(newsflash_collect, "HEARTBEAT_FILE", str(target)) + monkeypatch.setattr(newsflash_collect, "_heartbeat_failing", False) + + newsflash_collect.write_heartbeat() + + assert target.exists(), "心跳文件未创建" + assert target.read_text().strip(), "心跳内容为空" + assert capsys.readouterr().err == "", "成功路径不应输出任何日志" + + def test_first_failure_is_logged(self, monkeypatch, capsys): + monkeypatch.setattr(newsflash_collect, "HEARTBEAT_FILE", self._BAD_PATH) + monkeypatch.setattr(newsflash_collect, "_heartbeat_failing", False) + + newsflash_collect.write_heartbeat() + + err = capsys.readouterr().err + assert "心跳写入失败" in err, f"失败未留痕:{err!r}" + + def test_repeated_failures_logged_only_once(self, monkeypatch, capsys): + """连续失败只记一次——否则 60s 一次会淹没真实信号.""" + monkeypatch.setattr(newsflash_collect, "HEARTBEAT_FILE", self._BAD_PATH) + monkeypatch.setattr(newsflash_collect, "_heartbeat_failing", False) + + for _ in range(5): + newsflash_collect.write_heartbeat() + + err = capsys.readouterr().err + assert err.count("心跳写入失败") == 1, f"应为 1 条,实际:{err!r}" + + def test_recovery_is_logged(self, tmp_path, monkeypatch, capsys): + """恢复时必须有正向信号,否则排障时无法确认「已经好了」.""" + monkeypatch.setattr(newsflash_collect, "_heartbeat_failing", False) + + # 先失败一次 + monkeypatch.setattr(newsflash_collect, "HEARTBEAT_FILE", self._BAD_PATH) + newsflash_collect.write_heartbeat() + + # 再恢复 + monkeypatch.setattr(newsflash_collect, "HEARTBEAT_FILE", str(tmp_path / "hb")) + newsflash_collect.write_heartbeat() + + err = capsys.readouterr().err + assert "心跳写入失败" in err + assert "恢复正常" in err + + def test_flag_reset_after_recovery(self, tmp_path, monkeypatch): + """恢复后标志必须归位,否则下次失败不会再被记录.""" + monkeypatch.setattr(newsflash_collect, "_heartbeat_failing", False) + + monkeypatch.setattr(newsflash_collect, "HEARTBEAT_FILE", self._BAD_PATH) + newsflash_collect.write_heartbeat() + assert newsflash_collect._heartbeat_failing is True + + monkeypatch.setattr(newsflash_collect, "HEARTBEAT_FILE", str(tmp_path / "hb")) + newsflash_collect.write_heartbeat() + assert newsflash_collect._heartbeat_failing is False diff --git a/Scripts/bin/tests/test_paths_process_utils.py b/Scripts/bin/tests/test_paths_process_utils.py new file mode 100644 index 0000000..f21bd1b --- /dev/null +++ b/Scripts/bin/tests/test_paths_process_utils.py @@ -0,0 +1,214 @@ +# coding=utf-8 +"""paths.py / process_utils.py 跨平台抽象层测试. + +本套件的第一职责是**防止迁移期行为漂移**:macOS 上新抽象必须解析出 +与旧硬编码**逐字相同**的路径。历史上采集器 PID 文件、心跳文件的位置 +被 launchd plist、Swift 端探测逻辑、运行中进程三处同时引用, +任何一处变化都会表现为"采集器在跑、App 却报未运行"。 + +第二职责是覆盖 Windows 分支的路径语义(不依赖真的跑在 Windows 上, +用 monkeypatch 切 ``IS_WINDOWS`` / ``sys.platform``)。 +""" + +import os +import sys +from pathlib import Path + +import pytest + +_bin_dir = os.path.join(os.path.dirname(__file__), "..") +if _bin_dir not in sys.path: + sys.path.insert(0, _bin_dir) + +import paths # noqa: E402 +import process_utils # noqa: E402 + + +# ══════════════════════════════════════════════════════════ +# paths —— macOS 等价性(回归保护,最关键) +# ══════════════════════════════════════════════════════════ + +@pytest.mark.skipif(sys.platform != "darwin", reason="仅在 macOS 上校验等价性") +class TestMacOSEquivalence: + """断言与迁移前硬编码完全一致。改这些断言前先想清楚兼容性。""" + + def test_pid_file_matches_legacy_literal(self): + # 旧写法:PID_FILE = "/tmp/findatahub-newsflash.pid" + assert paths.pid_file("newsflash") == "/tmp/findatahub-newsflash.pid" + + def test_backend_pid_file(self): + # 旧写法:"/tmp/findatahub-newsflash-backend.pid" + assert paths.pid_file("newsflash-backend") == "/tmp/findatahub-newsflash-backend.pid" + + def test_heartbeat_file(self): + # 旧写法:"/tmp/findatahub-newsflash.heartbeat" + assert paths.heartbeat_file("newsflash") == "/tmp/findatahub-newsflash.heartbeat" + + def test_temp_dir_is_tmp_not_gettempdir(self): + """必须返回 /tmp,而不是 $TMPDIR。 + + tempfile.gettempdir() 在 macOS 返回 /var/folders/xx/.../T, + 与现网 PID 文件所在 /tmp 不是同一个目录——混用即事故。 + """ + assert paths.temp_dir() == Path("/tmp") + import tempfile + # 明确记录两者不同,避免后人"顺手"改回 gettempdir + assert Path(tempfile.gettempdir()) != paths.temp_dir() + + def test_log_root(self): + assert paths.log_root() == Path.home() / "Library/Logs/FinDataHub" + + def test_log_dir_with_sub(self): + assert paths.log_dir("newsflash") == ( + Path.home() / "Library/Logs/FinDataHub/newsflash" + ) + + def test_app_support_dir(self): + assert paths.app_support_dir() == ( + Path.home() / "Library/Application Support/FinDataHub" + ) + + def test_backups_dir(self): + assert paths.backups_dir() == ( + Path.home() / "Library/Application Support/FinDataHub/backups" + ) + + def test_user_defaults_plist(self): + assert paths.user_defaults_plist() == ( + Path.home() / "Library/Preferences/com.findatahub.app.plist" + ) + + def test_config_dir(self): + assert paths.config_dir() == Path.home() / ".config/findatahub" + + def test_env_file(self): + assert paths.env_file() == Path.home() / ".config/findatahub/env" + + +# ══════════════════════════════════════════════════════════ +# paths —— Windows 分支 +# ══════════════════════════════════════════════════════════ + +class TestWindowsPaths: + @pytest.fixture + def win(self, monkeypatch): + monkeypatch.setattr(paths, "IS_WINDOWS", True) + monkeypatch.setattr(paths, "IS_MACOS", False) + monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\tester\AppData\Local") + monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming") + return paths + + def test_log_root_under_localappdata(self, win): + assert win.log_root() == Path(r"C:\Users\tester\AppData\Local/FinDataHub/logs") + + def test_app_support_under_localappdata(self, win): + assert win.app_support_dir() == Path(r"C:\Users\tester\AppData\Local/FinDataHub") + + def test_config_under_appdata(self, win): + assert win.config_dir() == Path(r"C:\Users\tester\AppData\Roaming/FinDataHub") + + def test_no_plist_on_windows(self, win): + """Windows 无 plist——调用方必须处理 None,不能假设路径存在.""" + assert win.user_defaults_plist() is None + + def test_pid_file_uses_system_temp(self, win): + p = win.pid_file("newsflash") + assert p.endswith("findatahub-newsflash.pid") + assert "/tmp/" not in p + + +# ══════════════════════════════════════════════════════════ +# process_utils +# ══════════════════════════════════════════════════════════ + +class TestDetach: + def test_posix_uses_start_new_session(self): + if process_utils.IS_WINDOWS: + pytest.skip("仅 POSIX") + assert process_utils.detach_kwargs() == {"start_new_session": True} + + def test_windows_uses_creationflags(self, monkeypatch): + monkeypatch.setattr(process_utils, "IS_WINDOWS", True) + kw = process_utils.detach_kwargs() + assert "creationflags" in kw + assert "start_new_session" not in kw + # CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS + assert kw["creationflags"] & 0x00000200 + assert kw["creationflags"] & 0x00000008 + + def test_hidden_window_noop_on_posix(self): + if process_utils.IS_WINDOWS: + pytest.skip("仅 POSIX") + assert process_utils.hidden_window_kwargs() == {} + + +class TestIsRunning: + def test_current_process_alive(self): + assert process_utils.is_running(os.getpid()) is True + + def test_nonexistent_pid(self): + # 取一个几乎不可能存在的 PID + assert process_utils.is_running(999_999) is False + + @pytest.mark.parametrize("bad", [0, -1, -999]) + def test_invalid_pid(self, bad): + """非法 PID 直接判死,不得交给 os.kill 触发异常或误杀进程组.""" + assert process_utils.is_running(bad) is False + + +class TestTerminate: + def test_invalid_pid_noop(self): + assert process_utils.terminate_pid(0) is False + assert process_utils.terminate_pid(-1) is False + + def test_terminate_nonexistent_returns_false(self): + assert process_utils.terminate_pid(999_999) is False + + +class TestSignals: + def test_posix_has_core_signals(self): + if process_utils.IS_WINDOWS: + pytest.skip("仅 POSIX") + sigs = process_utils.available_signals() + assert "SIGTERM" in sigs + assert "SIGINT" in sigs + # SIGHUP 在 Windows 上不存在,POSIX 上应存在 + assert "SIGHUP" in sigs + + def test_register_skips_missing_signals(self): + """平台不存在的信号必须跳过而不是 AttributeError 崩溃.""" + called = [] + ok = process_utils.register_signal_handler( + lambda s, f: called.append(s), + names=("SIGTERM", "SIG_THIS_DOES_NOT_EXIST"), + ) + assert "SIGTERM" in ok + assert "SIG_THIS_DOES_NOT_EXIST" not in ok + + def test_register_returns_registered_names(self): + original = process_utils.signal.getsignal(process_utils.signal.SIGINT) + try: + names = process_utils.register_signal_handler( + lambda s, f: None, names=("SIGINT",) + ) + assert names == ["SIGINT"] + finally: + process_utils.signal.signal(process_utils.signal.SIGINT, original) + + +class TestUtf8Env: + def test_sets_utf8_vars(self): + env = process_utils.utf8_subprocess_env({"PATH": "/usr/bin"}) + assert env["PYTHONUTF8"] == "1" + assert env["PYTHONIOENCODING"] == "utf-8" + assert env["PATH"] == "/usr/bin" + + def test_does_not_mutate_input(self): + base = {"PATH": "/usr/bin"} + process_utils.utf8_subprocess_env(base) + assert "PYTHONUTF8" not in base, "必须返回副本,否则污染调用方环境" + + def test_defaults_to_os_environ_copy(self): + env = process_utils.utf8_subprocess_env() + assert env is not os.environ + assert env["PYTHONUTF8"] == "1"