mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
fix(plugin): make installation lifecycle cancellable
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
@@ -101,6 +103,7 @@ class SystemUtils:
|
||||
command: list,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
safe_command: Optional[list[str]] = None,
|
||||
timeout: Optional[float] = 300,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行命令并捕获标准输出和错误输出,记录日志。
|
||||
@@ -108,6 +111,7 @@ class SystemUtils:
|
||||
:param command: 要执行的命令,以列表形式提供
|
||||
:param env: 传递给子进程的环境变量
|
||||
:param safe_command: 用于错误信息展示的脱敏命令
|
||||
:param timeout: 子进程最长运行时间,None 表示不设置超时
|
||||
:return: (命令是否成功, 输出信息或错误信息)
|
||||
"""
|
||||
display_command = safe_command or command
|
||||
@@ -120,6 +124,7 @@ class SystemUtils:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
)
|
||||
# 合并 stdout 和 stderr
|
||||
output = SystemUtils.redact_url_userinfo(result.stdout + result.stderr)
|
||||
@@ -140,6 +145,26 @@ class SystemUtils:
|
||||
f"返回码:{e.returncode},{'; '.join(output_parts)}"
|
||||
)
|
||||
return False, error_message
|
||||
except subprocess.TimeoutExpired as e:
|
||||
stdout = SystemUtils.redact_url_userinfo(
|
||||
SystemUtils._decode_subprocess_output(e.stdout)
|
||||
)
|
||||
stderr = SystemUtils.redact_url_userinfo(
|
||||
SystemUtils._decode_subprocess_output(e.stderr)
|
||||
)
|
||||
output = ";".join(
|
||||
part for part in (
|
||||
f"标准输出:{stdout}" if stdout else "",
|
||||
f"错误输出:{stderr}" if stderr else "",
|
||||
) if part
|
||||
)
|
||||
if output:
|
||||
output = f",{output}"
|
||||
timeout_text = "未设置" if timeout is None else f"{timeout:g} 秒"
|
||||
return False, (
|
||||
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||||
f"执行超时({timeout_text}){output}"
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = (
|
||||
f"未知错误,命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||||
@@ -147,6 +172,136 @@ class SystemUtils:
|
||||
)
|
||||
return False, error_message
|
||||
|
||||
@staticmethod
|
||||
def _decode_subprocess_output(value: object) -> str:
|
||||
"""把 subprocess 的文本或字节输出统一成可脱敏的字符串。"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode(errors="replace").strip()
|
||||
return str(value).strip()
|
||||
|
||||
@staticmethod
|
||||
async def _terminate_async_subprocess(
|
||||
process: asyncio.subprocess.Process,
|
||||
communication_task: "asyncio.Task[tuple[bytes, bytes]]",
|
||||
grace_seconds: float = 5,
|
||||
) -> None:
|
||||
"""终止安装子进程及其同组子进程,并确保句柄已回收。"""
|
||||
if process.returncode is None:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
process.terminate()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(communication_task),
|
||||
timeout=grace_seconds,
|
||||
)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
process.kill()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(communication_task),
|
||||
timeout=grace_seconds,
|
||||
)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
communication_task.cancel()
|
||||
await asyncio.gather(communication_task, return_exceptions=True)
|
||||
finally:
|
||||
try:
|
||||
await process.wait()
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def execute_with_subprocess_async(
|
||||
command: list,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
safe_command: Optional[list[str]] = None,
|
||||
timeout: Optional[float] = 300,
|
||||
) -> Tuple[bool, str]:
|
||||
"""异步执行可取消的子进程,超时或取消时回收整个进程组。"""
|
||||
display_command = safe_command or command
|
||||
process: Optional[asyncio.subprocess.Process] = None
|
||||
communication_task: Optional["asyncio.Task[tuple[bytes, bytes]]"] = None
|
||||
try:
|
||||
creationflags = (
|
||||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
if os.name == "nt"
|
||||
else 0
|
||||
)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
start_new_session=os.name != "nt",
|
||||
creationflags=creationflags,
|
||||
)
|
||||
communication_task = asyncio.create_task(process.communicate())
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
asyncio.shield(communication_task),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await SystemUtils._terminate_async_subprocess(
|
||||
process,
|
||||
communication_task,
|
||||
)
|
||||
timeout_text = "未设置" if timeout is None else f"{timeout:g} 秒"
|
||||
return False, (
|
||||
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||||
f"执行超时({timeout_text})"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await SystemUtils._terminate_async_subprocess(
|
||||
process,
|
||||
communication_task,
|
||||
)
|
||||
raise
|
||||
|
||||
output = SystemUtils.redact_url_userinfo(
|
||||
SystemUtils._decode_subprocess_output(stdout)
|
||||
+ SystemUtils._decode_subprocess_output(stderr)
|
||||
)
|
||||
if process.returncode == 0:
|
||||
return True, output
|
||||
return False, (
|
||||
f"命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||||
f"执行失败,返回码:{process.returncode},"
|
||||
f"{output or '无标准输出或错误输出'}"
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
if process is not None and communication_task is not None:
|
||||
await SystemUtils._terminate_async_subprocess(
|
||||
process,
|
||||
communication_task,
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
if process is not None and communication_task is not None:
|
||||
await SystemUtils._terminate_async_subprocess(
|
||||
process,
|
||||
communication_task,
|
||||
)
|
||||
error_message = (
|
||||
f"未知错误,命令:{' '.join(SystemUtils.redact_command_url_userinfo(display_command))},"
|
||||
f"错误:{SystemUtils.redact_url_userinfo(str(e))}"
|
||||
)
|
||||
return False, error_message
|
||||
|
||||
@staticmethod
|
||||
def is_docker() -> bool:
|
||||
"""
|
||||
|
||||
@@ -364,5 +364,17 @@ class PluginDependencyInstaller:
|
||||
return await asyncio.to_thread(self.find_missing)
|
||||
|
||||
async def async_install(self, dependencies: list[str]) -> tuple[bool, str]:
|
||||
"""在线程池中安装依赖,复用同步包安装策略。"""
|
||||
return await asyncio.to_thread(self.install, dependencies)
|
||||
"""异步安装依赖,使用可取消的包安装子进程。"""
|
||||
if not dependencies:
|
||||
return False, "没有传入需要安装的依赖项"
|
||||
try:
|
||||
manifest_paths = [manifest.path for manifest in self._plugin_manifests()]
|
||||
if not manifest_paths:
|
||||
return False, "没有找到已安装插件的依赖清单"
|
||||
return await self._helper.async_install_packages_with_fallback(
|
||||
manifest_paths,
|
||||
self._wheels_dirs(),
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"安装依赖项时发生错误:{err}")
|
||||
return False, f"安装依赖项时发生错误:{err}"
|
||||
|
||||
@@ -19,6 +19,19 @@ from app.runtime.settings import RuntimeSettingsCompat
|
||||
settings = RuntimeSettingsCompat()
|
||||
|
||||
|
||||
async def _await_thread_operation(func, *args, **kwargs):
|
||||
"""取消请求到达时先等待文件操作收口,避免后台线程继续写运行目录。"""
|
||||
task = asyncio.create_task(asyncio.to_thread(func, *args, **kwargs))
|
||||
try:
|
||||
return await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
await asyncio.shield(task)
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginPackageCheckpoint:
|
||||
"""记录一次插件包变更前可用于补偿恢复的文件快照。"""
|
||||
@@ -74,7 +87,7 @@ class PluginPackageManager:
|
||||
|
||||
async def async_checkpoint(self, plugin_id: str) -> PluginPackageCheckpoint:
|
||||
"""在线程池中创建插件包文件快照。"""
|
||||
return await asyncio.to_thread(self.checkpoint, plugin_id)
|
||||
return await _await_thread_operation(self.checkpoint, plugin_id)
|
||||
|
||||
@staticmethod
|
||||
def commit(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
@@ -83,7 +96,7 @@ class PluginPackageManager:
|
||||
|
||||
async def async_commit(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在线程池中清理已提交的插件包快照。"""
|
||||
await asyncio.to_thread(self.commit, checkpoint)
|
||||
await _await_thread_operation(self.commit, checkpoint)
|
||||
|
||||
@staticmethod
|
||||
def rollback(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
@@ -101,7 +114,7 @@ class PluginPackageManager:
|
||||
|
||||
async def async_rollback(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在线程池中恢复插件包文件快照。"""
|
||||
await asyncio.to_thread(self.rollback, checkpoint)
|
||||
await _await_thread_operation(self.rollback, checkpoint)
|
||||
|
||||
def install(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user