fix(runtime): validate process identity and preserve detached launch flags

This commit is contained in:
jxxghp
2026-10-17 03:41:13 +08:00
parent 6c51e3555f
commit 16bec4c09e
2 changed files with 91 additions and 9 deletions

View File

@@ -35,7 +35,7 @@ class SystemHelper(ConfigReloadMixin):
__local_restart_log_file = (
get_runtime_setting('LOG_PATH') / "moviepilot.restart.stdout.log"
)
__one_shot_dev_update_flag_file = (
__one_shot_dev_update_flag_file: Path = (
get_runtime_setting('TEMP_PATH') / "moviepilot.pending_dev_update"
)
__prepared_update_manifest = (
@@ -46,11 +46,11 @@ class SystemHelper(ConfigReloadMixin):
__supervisor_socket = Path("/run/moviepilot/supervisor.sock")
__supervisor_update_worker = "moviepilot-update-worker"
def on_config_changed(self):
def on_config_changed(self) -> None:
"""配置变化后重新应用日志设置。"""
logger.update_loggers()
def get_reload_name(self):
def get_reload_name(self) -> str:
"""返回配置重载日志使用的组件名称。"""
return "日志设置"
@@ -69,7 +69,7 @@ class SystemHelper(ConfigReloadMixin):
)
@staticmethod
def _load_runtime_file(path: Path) -> Optional[dict]:
def _load_runtime_file(path: Path) -> Optional[dict[str, object]]:
"""安全读取本地进程运行状态文件。"""
if not path.exists():
return None
@@ -88,12 +88,12 @@ class SystemHelper(ConfigReloadMixin):
pid = runtime.get("pid")
create_time = runtime.get("create_time")
if not pid:
if not pid or not isinstance(pid, (str, int, float)):
return False
try:
pid = int(pid)
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
return False
if pid != os.getpid():
@@ -101,10 +101,13 @@ class SystemHelper(ConfigReloadMixin):
if create_time is None:
return True
if not isinstance(create_time, (str, int, float)):
return False
try:
current_process = psutil.Process(os.getpid())
return abs(current_process.create_time() - float(create_time)) <= 2
started_at: float = current_process.create_time()
return abs(started_at - float(create_time)) <= 2
except (psutil.Error, TypeError, ValueError):
return False
@@ -174,7 +177,7 @@ class SystemHelper(ConfigReloadMixin):
"close_fds": True,
"env": env,
}
if os.name == "nt":
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
else:
kwargs["start_new_session"] = True
@@ -199,6 +202,7 @@ class SystemHelper(ConfigReloadMixin):
) -> None:
"""延迟调用本地 supervisor 控制命令,确保重启接口有机会完成响应。"""
def run_command() -> None:
"""执行已选定的 supervisor 操作并记录启动失败。"""
command = [
str(SystemHelper.__supervisorctl),
"-c",
@@ -296,7 +300,7 @@ class SystemHelper(ConfigReloadMixin):
return False, message
return True, "已安排 Dev 更新并重启"
def set_system_modified(self):
def set_system_modified(self) -> None:
"""
设置系统已修改标志
"""

View File

@@ -0,0 +1,78 @@
"""本地进程状态记录及跨平台子进程参数的兼容回归。"""
import json
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app import cli
from app.runtime.state import SystemHelper
@pytest.mark.parametrize(
("created_at", "expected"),
[(None, True), (100, True), (102.0, True), (103, False), ("100", True), ("invalid", False), ([], False), ({}, False)],
)
def test_runtime_identity_checks_creation_time(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, created_at: object, expected: bool
) -> None:
"""创建时间可缺省或使用数字字符串;损坏记录不能冒充当前受管进程。"""
state_path = tmp_path / "runtime.json"
state_path.write_text(json.dumps({"pid": os.getpid(), "create_time": created_at}), encoding="utf-8")
monkeypatch.setattr(SystemHelper, "_SystemHelper__local_backend_runtime_file", state_path)
monkeypatch.setattr("app.runtime.state.psutil.Process", lambda _pid: SimpleNamespace(create_time=lambda: 100.0))
assert SystemHelper._is_local_cli_managed() is expected
@pytest.mark.parametrize("pid", [None, [], {}, "invalid", 0, float("inf"), float("-inf"), float("nan")])
def test_invalid_process_identity_is_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, pid: object
) -> None:
"""不可转换的 PID 和空 PID 均不建立受管进程身份。"""
state_path = tmp_path / "runtime.json"
state_path.write_text(json.dumps({"pid": pid}), encoding="utf-8")
monkeypatch.setattr(SystemHelper, "_SystemHelper__local_backend_runtime_file", state_path)
assert SystemHelper._is_local_cli_managed() is False
@pytest.mark.parametrize("platform", ["win32", "linux", "darwin"])
@pytest.mark.parametrize("entrypoint", ["cli", "restart"])
def test_detached_process_flags_match_platform(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, platform: str, entrypoint: str
) -> None:
"""模拟平台并截获启动参数,验证 Windows 分离标志和 Unix 新会话保持一致。"""
for attribute, name in (
("_SystemHelper__local_restart_log_file", "restart.log"),
("_SystemHelper__prepared_update_manifest", "install.json"),
("_SystemHelper__one_shot_dev_update_flag_file", "pending-update"),
):
monkeypatch.setattr(SystemHelper, attribute, tmp_path / name)
spawn = Mock(return_value=SimpleNamespace(pid=123))
monkeypatch.setattr(subprocess, "Popen", spawn)
monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x200, raising=False)
monkeypatch.setattr(subprocess, "DETACHED_PROCESS", 0x8, raising=False)
with monkeypatch.context() as platform_patch:
platform_patch.setattr(sys, "platform", platform)
if entrypoint == "cli":
cli._spawn_process(["test-command"], cwd=tmp_path, log_file=None)
else:
SystemHelper._spawn_local_restart_helper()
spawn.assert_called_once()
options = spawn.call_args.kwargs
assert options["stdin"] == subprocess.DEVNULL
assert options["close_fds"] is True
if platform == "win32":
assert options["creationflags"] == 0x208
assert "start_new_session" not in options
else:
assert options["start_new_session"] is True
assert "creationflags" not in options