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 条。
200 lines
7.3 KiB
Python
200 lines
7.3 KiB
Python
# 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
|