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 条。
This commit is contained in:
FinDataHub committed 2026-10-26 20:55:31 +08:00
1 parent 0cf9fde232
commit fb76b10033
8 files changed
+728 -15

No files matched your search

@@ -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"