Files
FinDataHub/Scripts/bin/process_utils.py
T
FinDataHub fb76b10033 feat(cross-platform): 收敛路径与进程语义到 paths / process_utils
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 条。
2026-10-26 20:55:31 +08:00

200 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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