mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
fix(plugin): close cancellable install lifecycle gaps
This commit is contained in:
@@ -188,9 +188,11 @@ class SystemUtils:
|
||||
grace_seconds: float = 5,
|
||||
) -> None:
|
||||
"""终止安装子进程及其同组子进程,并确保句柄已回收。"""
|
||||
process_tree = SystemUtils._process_tree(process.pid)
|
||||
if process.returncode is None:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
SystemUtils._signal_process_tree(process_tree)
|
||||
process.terminate()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
@@ -205,9 +207,11 @@ class SystemUtils:
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
SystemUtils._signal_process_tree(process_tree, force=True)
|
||||
process.kill()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
SystemUtils._signal_process_tree(process_tree, force=True)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
try:
|
||||
@@ -224,6 +228,27 @@ class SystemUtils:
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _process_tree(pid: int) -> list[psutil.Process]:
|
||||
"""收集安装进程及其后代,避免构建进程继续写运行环境。"""
|
||||
try:
|
||||
parent = psutil.Process(pid)
|
||||
return [parent, *parent.children(recursive=True)]
|
||||
except (psutil.Error, OSError):
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _signal_process_tree(
|
||||
processes: list[psutil.Process],
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""向进程树发送终止或强制结束信号。"""
|
||||
for child in reversed(processes):
|
||||
try:
|
||||
(child.kill if force else child.terminate)()
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
async def execute_with_subprocess_async(
|
||||
command: list,
|
||||
|
||||
@@ -101,16 +101,18 @@ class PluginPackageManager:
|
||||
@staticmethod
|
||||
def rollback(checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""删除当前包并把变更前文件快照恢复到运行目录。"""
|
||||
if checkpoint.plugin_dir.exists():
|
||||
shutil.rmtree(checkpoint.plugin_dir)
|
||||
snapshot_dir = checkpoint.transaction_dir / "package"
|
||||
if checkpoint.existed:
|
||||
if not snapshot_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"插件 {checkpoint.plugin_id} 的补偿快照不存在:{snapshot_dir}"
|
||||
)
|
||||
if checkpoint.plugin_dir.exists():
|
||||
shutil.rmtree(checkpoint.plugin_dir)
|
||||
if checkpoint.existed:
|
||||
shutil.copytree(snapshot_dir, checkpoint.plugin_dir)
|
||||
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
|
||||
if checkpoint.transaction_dir.exists():
|
||||
shutil.rmtree(checkpoint.transaction_dir, ignore_errors=False)
|
||||
|
||||
async def async_rollback(self, checkpoint: PluginPackageCheckpoint) -> None:
|
||||
"""在线程池中恢复插件包文件快照。"""
|
||||
|
||||
@@ -68,6 +68,7 @@ class _InstallState:
|
||||
checkpoint: Any = None
|
||||
stage: str = "package_checkpoint"
|
||||
package_installed: bool = False
|
||||
installed_list_touched: bool = False
|
||||
installed_list_persisted: bool = False
|
||||
runtime_touched: bool = False
|
||||
registrations_touched: bool = False
|
||||
@@ -210,6 +211,8 @@ class PluginInstallCommand:
|
||||
if plugin_id not in installed_plugins:
|
||||
updated_plugins = [*installed_plugins, plugin_id]
|
||||
try:
|
||||
# 写入方可能在返回前已经提交;取消时按已触碰处理,恢复原清单是幂等的。
|
||||
state.installed_list_touched = True
|
||||
await self._installed_plugins_writer(updated_plugins)
|
||||
installed_list_persisted = True
|
||||
state.installed_list_persisted = True
|
||||
@@ -267,9 +270,10 @@ class PluginInstallCommand:
|
||||
|
||||
checkpoint_cleanup_error = ""
|
||||
state.stage = "checkpoint_commit"
|
||||
# 运行态和注册已完成,后续只清理临时快照,不再把取消当作未提交安装回滚。
|
||||
state.committed = True
|
||||
try:
|
||||
await self._package_committer(checkpoint)
|
||||
state.committed = True
|
||||
except Exception as err:
|
||||
checkpoint_cleanup_error = str(err)
|
||||
|
||||
@@ -328,7 +332,7 @@ class PluginInstallCommand:
|
||||
stage=state.stage,
|
||||
message="插件安装已取消",
|
||||
package_installed=state.package_installed,
|
||||
installed_list_persisted=state.installed_list_persisted,
|
||||
installed_list_persisted=state.installed_list_touched,
|
||||
runtime_touched=state.runtime_touched,
|
||||
registrations_touched=state.registrations_touched,
|
||||
)
|
||||
|
||||
@@ -4,8 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Iterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class PluginLifecycleCoordinator:
|
||||
@@ -58,21 +57,6 @@ class PluginLifecycleCoordinator:
|
||||
finally:
|
||||
self._release_plugin(plugin_id)
|
||||
|
||||
@contextmanager
|
||||
def hold_sync(self, plugin_id: str) -> Iterator[None]:
|
||||
"""同步持有单个插件的生命周期资格。"""
|
||||
normalized_id = self._normalize(plugin_id)
|
||||
if not normalized_id:
|
||||
raise ValueError("插件ID不能为空")
|
||||
with self._condition:
|
||||
while self._startup_active or normalized_id in self._active_plugins:
|
||||
self._condition.wait()
|
||||
self._active_plugins.add(normalized_id)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._release_plugin(normalized_id)
|
||||
|
||||
@asynccontextmanager
|
||||
async def hold_startup(self):
|
||||
"""异步持有启动同步的全局资格,阻止安装请求穿过启动收口。"""
|
||||
@@ -85,4 +69,3 @@ class PluginLifecycleCoordinator:
|
||||
|
||||
|
||||
plugin_lifecycle = PluginLifecycleCoordinator()
|
||||
|
||||
|
||||
@@ -63,6 +63,27 @@ class PluginDependencyService:
|
||||
"""安装当前环境缺失的插件依赖并保持历史列表返回合同。"""
|
||||
return self.install_missing_with_status().missing
|
||||
|
||||
async def async_install_missing_with_status(self) -> PluginDependencyInstallResult:
|
||||
"""在异步启动链中恢复缺失依赖,确保安装子进程可取消。"""
|
||||
installer = self._system().dependency
|
||||
missing = await installer.async_find_missing()
|
||||
if not missing:
|
||||
return PluginDependencyInstallResult(missing=[], success=True)
|
||||
self._logger.debug(f"检测到缺失的依赖项: {missing}")
|
||||
self._logger.info(f"开始安装缺失的依赖项,共 {len(missing)} 个...")
|
||||
started = time.time()
|
||||
success, _message = await installer.async_install(missing)
|
||||
elapsed = time.time() - started
|
||||
if success:
|
||||
self._logger.info(
|
||||
f"已完成 {len(missing)} 个依赖项安装,总耗时:{elapsed:.2f} 秒"
|
||||
)
|
||||
else:
|
||||
self._logger.warning(
|
||||
f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{elapsed:.2f} 秒"
|
||||
)
|
||||
return PluginDependencyInstallResult(missing=missing, success=success)
|
||||
|
||||
def classify_plugins(self) -> PluginDependencyClassification:
|
||||
"""返回启动编排使用的轻量插件分类。"""
|
||||
ready, missing_dependencies, missing_source = (
|
||||
|
||||
@@ -603,6 +603,14 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
|
||||
log=logger,
|
||||
).install_missing_with_status()
|
||||
|
||||
@staticmethod
|
||||
async def async_install_plugin_missing_dependencies_with_status() -> PluginDependencyInstallResult:
|
||||
"""在异步启动链中恢复插件依赖并保留取消语义。"""
|
||||
return await PluginDependencyService(
|
||||
system=get_plugin_system,
|
||||
log=logger,
|
||||
).async_install_missing_with_status()
|
||||
|
||||
def classify_plugins(self) -> PluginDependencyClassification:
|
||||
"""按源码依赖状态分类物理插件,并把结果映射到虚拟实例。"""
|
||||
source_classification = PluginDependencyService(
|
||||
|
||||
@@ -148,10 +148,8 @@ async def sync_plugins() -> bool:
|
||||
plugin_manager.set_plugin_settling(True)
|
||||
|
||||
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
|
||||
dependency_result = await execute_task(
|
||||
loop,
|
||||
plugin_manager.install_plugin_missing_dependencies_with_status,
|
||||
"缺失依赖项安装",
|
||||
dependency_result = await (
|
||||
plugin_manager.async_install_plugin_missing_dependencies_with_status()
|
||||
)
|
||||
if dependency_result is None:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user