fix(plugin): 补上换入删除旧目录中途失败时的回滚 (#6691)

This commit is contained in:
Aqr-K
2026-09-14 15:12:51 +08:00
committed by GitHub
parent 1afda39ee7
commit bc99355b2a
2 changed files with 237 additions and 21 deletions

View File

@@ -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 = (

View File

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