From bc99355b2a9598c57735fd936df39b3c22536e0e Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:12:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20=E8=A1=A5=E4=B8=8A=E6=8D=A2?= =?UTF-8?q?=E5=85=A5=E5=88=A0=E9=99=A4=E6=97=A7=E7=9B=AE=E5=BD=95=E4=B8=AD?= =?UTF-8?q?=E9=80=94=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=9A=84=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=20(#6691)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/adapters/system/plugin/package.py | 115 ++++++++++++++---- tests/test_plugin_install_atomic_swap.py | 143 ++++++++++++++++++++++- 2 files changed, 237 insertions(+), 21 deletions(-) diff --git a/app/adapters/system/plugin/package.py b/app/adapters/system/plugin/package.py index fad53ffa3..b13ba50de 100644 --- a/app/adapters/system/plugin/package.py +++ b/app/adapters/system/plugin/package.py @@ -44,6 +44,25 @@ from app.runtime.settings import get_runtime_setting from app.runtime.version import get_app_version +class PluginContentSwapError(OSError): + """插件内容换入失败,并随异常携带运行目录的实际恢复结论。 + + 上层能观察到的只有运行目录还在不在,而被删到一半的运行目录同样"存在"; + 只凭存在与否判断就会把残骸当成完好的旧插件,进而把本次的临时备份当作多余 + 材料删掉,结果是安装报失败、运行目录却只剩半份旧文件且无处可取。因此把 + "运行目录是否仍是换入前那一份"由换入方作为事实上报,不让上层去猜。 + """ + + def __init__(self, cause: BaseException, *, runtime_intact: bool) -> None: + """按原始失败构造换入异常,并记录运行目录的恢复结论。 + + :param cause: 触发换入失败的原始异常,消息原样透传给上层展示 + :param runtime_intact: 运行目录是否仍是换入前那一份(未触碰或已回滚到位) + """ + super().__init__(str(cause)) + self.runtime_intact = runtime_intact + + class PluginPackageSourcePort(Protocol): """声明包 owner 读取市场元数据和远端制品所需的外部端口。""" @@ -1460,17 +1479,22 @@ class PluginPackageManager: 两个改名都可能因跨文件系统失败:overlayfs 会拒绝把镜像层的插件目录直接改 名到可写层,暂存目录又常落在独立的临时分区。两处都退化为复制,代价是复制 期间可能中途失败留下半份目录,因此复制失败一律先删掉半成品再把旧目录换 - 回;换回本身也失败时只记录旧目录的保留位置、不吞掉原始异常,避免出现 - "看起来只是安装失败"实则运行目录已空的假象。 + 回;换回本身也失败时只记录旧目录的保留位置,原始失败原样挂在异常链上不被 + 吞掉,避免出现"看起来只是安装失败"实则运行目录已空的假象。 + + 旧目录退化为复制后还得逐个删掉原目录,这一步同样可能删到一半才失败。删除 + 一旦开始运行目录就不再完整,因此把"可回滚"状态提前到删除之前置位:只要运 + 行目录有被改动的可能就必须走回滚,绝不能因为删除没跑完就跳过回滚。回滚到 + 底有没有把旧内容放回,由抛出的异常如实上报,供上层决定要不要动用备份。 :param staging_dir: 已就位的待安装内容目录 :param final_dir: 插件运行目录,可能已存在旧内容 - :raise OSError: 换入失败,且已尽力把运行目录恢复或保留为换入前的内容 + :raise PluginContentSwapError: 换入失败,异常携带运行目录的实际恢复结论 """ final_dir.parent.mkdir(parents=True, exist_ok=True) previous = final_dir.parent / f".{final_dir.name}.previous-{uuid.uuid4().hex}" previous_available = False - cleared = False + runtime_dirty = False published = False try: if final_dir.exists(): @@ -1481,11 +1505,13 @@ class PluginPackageManager: raise shutil.copytree(final_dir, previous, symlinks=True) previous_available = True + # 删除从这一刻起就可能只删一半,先认账再动手 + runtime_dirty = True PluginPackageManager.__remove_snapshot_path(final_dir) else: previous_available = True # 旧内容已挪开或本来就不存在,此后才允许重建运行目录 - cleared = True + runtime_dirty = True try: staging_dir.replace(final_dir) except OSError as error: @@ -1496,19 +1522,24 @@ class PluginPackageManager: ) shutil.copytree(staging_dir, final_dir, symlinks=True) published = True - except Exception: - if cleared and not published: + except Exception as error: + # 运行目录从未被触碰时无需回滚,它本身就还是换入前那一份 + runtime_intact = not runtime_dirty + if runtime_dirty and not published: try: PluginPackageManager.__remove_snapshot_path(final_dir) if previous_available: previous.replace(final_dir) previous_available = False + runtime_intact = True except Exception as rollback_error: logger.error( f"插件安装换入失败后恢复旧目录失败,已保留恢复材料 {previous}: " f"{rollback_error}" ) - raise + raise PluginContentSwapError( + error, runtime_intact=runtime_intact + ) from error finally: if previous.exists() and (published or not previous_available): shutil.rmtree(previous, ignore_errors=True) @@ -1521,6 +1552,60 @@ class PluginPackageManager: self.__swap_staged_plugin_content, staging_dir, final_dir ) + @staticmethod + def __swap_left_runtime_intact(error: OSError) -> bool: + """判断换入失败后运行目录是否确定仍是换入前那一份。 + + 只认换入方随异常带出的回滚结论:运行目录存在与否区分不了"完整旧内容"和 + "删到一半的残骸"。拿不到结论的异常按最坏情况处理,宁可多走一次备份兜底。 + + :param error: 换入阶段抛出的异常 + :return: 运行目录是否确定保持换入前内容 + """ + return isinstance(error, PluginContentSwapError) and error.runtime_intact + + def __recover_after_swap_failure( + self, pid: str, error: OSError, backup_dir: Optional[str] + ) -> None: + """换入失败后按实际回滚结论处置本次临时备份。 + + :param pid: 插件 ID + :param error: 换入阶段抛出的异常 + :param backup_dir: 本次安装前留下的临时备份目录,强制安装时为空 + """ + if self.__swap_left_runtime_intact(error): + if backup_dir: + shutil.rmtree(backup_dir, ignore_errors=True) + return + if backup_dir: + self.__restore_plugin(pid, backup_dir) + logger.warn(f"{pid} 插件安装失败,已还原备份插件") + return + logger.error( + f"{pid} 换入失败且运行目录未能恢复,本次未留临时备份," + f"请按上一条日志保留的恢复材料人工处理" + ) + + async def __async_recover_after_swap_failure( + self, pid: str, error: OSError, backup_dir: Optional[str] + ) -> None: + """异步流程的备份处置,判据与同步一致;备份清理由统一 finally 负责。 + + :param pid: 插件 ID + :param error: 换入阶段抛出的异常 + :param backup_dir: 本次安装前留下的临时备份目录,强制安装时为空 + """ + if self.__swap_left_runtime_intact(error): + return + if backup_dir: + await self.__async_restore_plugin(pid, backup_dir) + logger.warning(f"{pid} 插件安装失败,已还原备份插件") + return + logger.error( + f"{pid} 换入失败且运行目录未能恢复,本次未留临时备份," + f"请按上一条日志保留的恢复材料人工处理" + ) + def __install_flow_sync( self, pid: str, @@ -1552,13 +1637,7 @@ class PluginPackageManager: except OSError as error: message = f"写入插件内容失败:{error}" logger.error(f"{pid} {message}") - # 换入函数已保证失败时运行目录保持换入前的内容;只有连恢复都失败、 - # 运行目录确实缺失时,才动用本次的临时备份兜底。 - if backup_dir and not plugin_dir.exists(): - self.__restore_plugin(pid, backup_dir) - logger.warn(f"{pid} 插件安装失败,已还原备份插件") - elif backup_dir: - shutil.rmtree(backup_dir, ignore_errors=True) + self.__recover_after_swap_failure(pid, error, backup_dir) return False, message dependencies_exist, dep_ok, dep_msg = ( @@ -2159,11 +2238,7 @@ class PluginPackageManager: except OSError as error: message = f"写入插件内容失败:{error}" logger.error(f"{pid} {message}") - # 换入函数已保证失败时运行目录保持换入前的内容;只有连恢复都失败、 - # 运行目录确实缺失时,才动用本次的临时备份兜底。 - if backup_dir and not plugin_dir.exists(): - await self.__async_restore_plugin(pid, backup_dir) - logger.warning(f"{pid} 插件安装失败,已还原备份插件") + await self.__async_recover_after_swap_failure(pid, error, backup_dir) return False, message dependencies_exist, dep_ok, dep_msg = ( diff --git a/tests/test_plugin_install_atomic_swap.py b/tests/test_plugin_install_atomic_swap.py index 63d87c3ea..89591a9c9 100644 --- a/tests/test_plugin_install_atomic_swap.py +++ b/tests/test_plugin_install_atomic_swap.py @@ -8,7 +8,10 @@ from unittest.mock import Mock import pytest -from app.adapters.system.plugin.package import PluginPackageManager +from app.adapters.system.plugin.package import ( + PluginContentSwapError, + PluginPackageManager, +) PLUGIN_ID = "DemoPlugin" REPO_URL = "https://github.com/demo/MoviePilot-Plugins" @@ -262,6 +265,29 @@ def _break_replace(monkeypatch, *, source: Path, code: int) -> None: monkeypatch.setattr(Path, "replace", guarded) +def _break_rmtree(monkeypatch, *, target: Path, failures: int) -> None: + """让指定目录的前若干次删除在删掉部分内容后失败,模拟删到一半中断。 + + 逐次按逆字典序删掉一个文件再抛错,既留下"目录还在但内容残缺"的现场, + 也让后续断言能稳定指出是哪一份内容丢了。 + """ + original = shutil.rmtree + remaining = {"count": failures} + + def guarded(path, *args, **kwargs): + """只拦截被指定的目录,其它删除仍走真实实现。""" + if Path(path) == target and remaining["count"] > 0: + remaining["count"] -= 1 + for child in sorted(Path(path).iterdir(), reverse=True): + if child.is_file(): + child.unlink() + break + raise OSError(errno.EIO, "simulated") + return original(path, *args, **kwargs) + + monkeypatch.setattr("app.adapters.system.plugin.package.shutil.rmtree", guarded) + + def test_swap_publishes_new_content_and_drops_old_content(tmp_path: Path) -> None: """换入后运行目录只剩新内容,回滚材料被清理。""" staging_dir, final_dir = _staged_pair(tmp_path) @@ -354,3 +380,118 @@ def test_swap_rolls_back_when_cross_filesystem_copy_fails( assert (final_dir / "stale.py").exists() assert not (final_dir / "partial.py").exists() _assert_no_swap_residue(final_dir) + + +def test_swap_rolls_back_when_removing_old_directory_fails_midway( + monkeypatch, tmp_path: Path +) -> None: + """旧目录退化为复制后删到一半失败,也必须回滚出完整的旧内容。""" + staging_dir, final_dir = _staged_pair(tmp_path) + _break_replace(monkeypatch, source=final_dir, code=errno.EXDEV) + _break_rmtree(monkeypatch, target=final_dir, failures=1) + + with pytest.raises(PluginContentSwapError) as failure: + _swap(staging_dir, final_dir) + + assert failure.value.runtime_intact is True + assert (final_dir / "__init__.py").read_text(encoding="utf-8") == INSTALLED_MARK + assert (final_dir / "stale.py").exists() + assert (staging_dir / "__init__.py").exists() + _assert_no_swap_residue(final_dir) + + +def test_swap_reports_broken_runtime_and_keeps_material_when_rollback_fails( + monkeypatch, tmp_path: Path +) -> None: + """回滚也失败时必须上报运行目录未恢复,并把完整旧内容留在恢复材料里。""" + staging_dir, final_dir = _staged_pair(tmp_path) + _break_replace(monkeypatch, source=final_dir, code=errno.EXDEV) + _break_rmtree(monkeypatch, target=final_dir, failures=2) + + with pytest.raises(PluginContentSwapError) as failure: + _swap(staging_dir, final_dir) + + assert failure.value.runtime_intact is False + materials = list(final_dir.parent.glob(f".{final_dir.name}.previous-*")) + assert len(materials) == 1 + assert (materials[0] / "__init__.py").read_text(encoding="utf-8") == INSTALLED_MARK + assert (materials[0] / "stale.py").exists() + + +def test_swap_reports_intact_runtime_when_old_directory_cannot_be_moved_aside( + monkeypatch, tmp_path: Path +) -> None: + """旧目录连挪都没挪动时运行目录天然完好,不该让上层白走一次备份还原。""" + staging_dir, final_dir = _staged_pair(tmp_path) + _break_replace(monkeypatch, source=final_dir, code=errno.EACCES) + + with pytest.raises(PluginContentSwapError) as failure: + _swap(staging_dir, final_dir) + + assert failure.value.runtime_intact is True + _assert_no_swap_residue(final_dir) + + +def test_sync_install_restores_backup_when_swap_rollback_fails( + monkeypatch, tmp_path: Path +) -> None: + """回滚也失败时上层必须用本次备份补齐运行目录,而不是看目录还在就丢掉备份。""" + manager, plugin_dir = _installed_manager(monkeypatch, tmp_path) + (plugin_dir / "stale.py").write_text("stale", encoding="utf-8") + _stub_market_lookup(manager, monkeypatch) + + def prepare(_pid, _user_repo, _package_version, dest_root: Path) -> tuple[bool, str]: + """内容准备成功,失败只发生在随后的换入阶段。""" + dest_root.mkdir(parents=True, exist_ok=True) + (dest_root / "__init__.py").write_text("upgraded", encoding="utf-8") + return True, "" + + monkeypatch.setattr( + manager, "_PluginPackageManager__prepare_content_via_filelist_sync", prepare + ) + _break_replace(monkeypatch, source=plugin_dir, code=errno.EXDEV) + _break_rmtree(monkeypatch, target=plugin_dir, failures=2) + + success, message = manager.install_raw( + PLUGIN_ID, REPO_URL, package_version="v2", force_install=False + ) + + assert not success + assert "写入插件内容失败" in message + assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == INSTALLED_MARK + assert (plugin_dir / "stale.py").exists() + _assert_no_staging_residue(tmp_path) + + +@pytest.mark.asyncio +async def test_async_install_restores_backup_when_swap_rollback_fails( + monkeypatch, tmp_path: Path +) -> None: + """异步流程的备份处置同样只认回滚结论,不认运行目录是否还在。""" + manager, plugin_dir = _installed_manager(monkeypatch, tmp_path) + (plugin_dir / "stale.py").write_text("stale", encoding="utf-8") + _stub_market_lookup(manager, monkeypatch) + + async def prepare( + _pid, _user_repo, _package_version, dest_root: Path + ) -> tuple[bool, str]: + """内容准备成功,失败只发生在随后的换入阶段。""" + dest_root.mkdir(parents=True, exist_ok=True) + (dest_root / "__init__.py").write_text("upgraded", encoding="utf-8") + return True, "" + + monkeypatch.setattr( + manager, "_PluginPackageManager__prepare_content_via_filelist_async", prepare + ) + _break_replace(monkeypatch, source=plugin_dir, code=errno.EXDEV) + _break_rmtree(monkeypatch, target=plugin_dir, failures=2) + + success, message = await manager.async_install_raw( + PLUGIN_ID, REPO_URL, package_version="v2", force_install=False + ) + + assert not success + assert "写入插件内容失败" in message + assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == INSTALLED_MARK + assert (plugin_dir / "stale.py").exists() + _assert_no_staging_residue(tmp_path)