mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-11 14:36:56 +08:00
fix: address stable-review findings across config, update, downloader
Ship-readiness review of main...3.3-dev (multi-agent + codex verify pass) surfaced 10 confirmed defects; all fixed with red-first tests (+24): - config: masked list secrets restored by identity match, not index — deleting/reordering a notification provider no longer corrupts the survivor's credentials; unresolvable masks return 400 instead of guessing - statics: mount dist/fonts — self-hosted Inter 404'd in every production build and the whole UI fell back to OS fonts - boot_overlay: chown replaced trees to the app user (crash-loop under UMASK=077); snapshot-and-restore around the EXDEV in-place fallback so a mid-copy failure can't gut /app/module - updater: rollback refuses to swap without the retained signed backup bundle — reverts to image version honestly instead of half-swapping - qbittorrent: credential-failure latch (cleared on settings save) plus single-flighted auth sharing the negative result with queued waiters — failed logins no longer accumulate into a WebUI IP ban - download_client: holder refcount registered before auth (no mid-login close), stale clients accumulated in a list (no pool leak on rapid settings saves), holder released on cancellation during __aenter__ - entrypoint: ownership marker written only after chown -R succeeds - aria2: torrents_info provides num_seeds/num_leechs/eta/added_on and maps states to qB vocabulary (UI showed undefined / NaNhNaNm / raw states) - program API: deprecated GET aliases for /restart /start /stop /shutdown (3.2 automation compatibility) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
@@ -70,12 +70,8 @@ def _read_applied(updates_root: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _replace_contents(dst: Path, src: Path) -> None:
|
||||
"""让 dst 的子项与 src 一致,但不 rename dst 本身。
|
||||
|
||||
这是 EXDEV 的兜底路径:overlayfs 拒绝把镜像下层目录跨挂载边界 rename,
|
||||
但逐文件删/写会触发 copy-up,因而可行(非原子,但拓扑无关)。
|
||||
"""
|
||||
def _clear_and_fill(dst: Path, src: Path) -> None:
|
||||
"""删光 dst 的子项后从 src 复制(EXDEV 兜底的非原子核心步骤)。"""
|
||||
for child in list(dst.iterdir()):
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
shutil.rmtree(child)
|
||||
@@ -89,6 +85,63 @@ def _replace_contents(dst: Path, src: Path) -> None:
|
||||
shutil.copy2(child, target)
|
||||
|
||||
|
||||
def _replace_contents(dst: Path, src: Path) -> None:
|
||||
"""让 dst 的子项与 src 一致,但不 rename dst 本身。
|
||||
|
||||
这是 EXDEV 的兜底路径:overlayfs 拒绝把镜像下层目录跨挂载边界 rename,
|
||||
但逐文件删/写会触发 copy-up,因而可行。非原子——中途失败(磁盘满/IO
|
||||
错误)会留下"删光了但没灌满"的残树,启动进去必然 ImportError 崩溃循环。
|
||||
因此先把 dst 现有内容快照到 .ab_bak:快照失败则 dst 未被触碰、直接放弃;
|
||||
填充失败则从快照恢复旧树后再抛出,保证 dst 要么是新树要么是旧树。
|
||||
"""
|
||||
backup = dst.parent / (dst.name + ".ab_bak")
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
shutil.copytree(dst, backup)
|
||||
try:
|
||||
_clear_and_fill(dst, src)
|
||||
except BaseException:
|
||||
try:
|
||||
_clear_and_fill(dst, backup)
|
||||
# 恢复也是 root 在复制:属主变回 root,受限 UMASK 下的 600
|
||||
# 模式会让 ab 读不了恢复出来的旧树——同样要交还应用用户
|
||||
# (成功路径的 chown 在上层,异常路径走不到那里)。
|
||||
_chown_app_tree(dst)
|
||||
except Exception:
|
||||
logger.critical(
|
||||
"Failed to restore %s from its snapshot after a partial "
|
||||
"overlay write; the tree may be incomplete.",
|
||||
dst,
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(backup, ignore_errors=True)
|
||||
|
||||
|
||||
def _chown_app_tree(path: Path) -> None:
|
||||
"""把覆盖层落地的树交还应用用户(ab)。
|
||||
|
||||
boot_overlay 以 root 运行;extractall/copytree 产物的权限跟随 UMASK
|
||||
(entrypoint 在调用前已 ``umask ${UMASK}``),受限 UMASK(如 077)下是
|
||||
600/700 root:root——以 ab 运行的 main.py 读不了 /app/module,启动即
|
||||
ImportError 崩溃循环。落地后统一 chown 给 ab,使可读性与 UMASK 无关
|
||||
(3.2 的每次启动全量 ``chown -R /app`` 已移除,必须在这里补上)。
|
||||
只处理刚写入的树,不触碰镜像层文件,避免无谓的 overlayfs copy-up。
|
||||
"""
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
return
|
||||
try:
|
||||
import pwd
|
||||
|
||||
pwd.getpwnam("ab")
|
||||
except (ImportError, KeyError):
|
||||
logger.warning("User 'ab' not found; skipping ownership fix for %s", path)
|
||||
return
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["chown", "-R", "ab:ab", str(path)], check=False)
|
||||
|
||||
|
||||
def _replace_tree(src: Path, dst: Path) -> None:
|
||||
"""用 src 原子地替换 dst(先复制到 .new,再 rename 交换,最后清理 .old)。
|
||||
|
||||
@@ -352,11 +405,13 @@ def apply_overlay(
|
||||
except Exception as exc:
|
||||
logger.error("Failed to overlay module tree: %s", exc)
|
||||
return False
|
||||
_chown_app_tree(app_root / "module")
|
||||
|
||||
src_dist = tmp / "webui-dist"
|
||||
if src_dist.exists():
|
||||
try:
|
||||
_replace_tree(src_dist, app_root / "dist")
|
||||
_chown_app_tree(app_root / "dist")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to overlay webui dist: %s", exc)
|
||||
|
||||
@@ -370,6 +425,7 @@ def apply_overlay(
|
||||
(app_root / ".venv" / _VENV_LOCK_MARKER).write_text(
|
||||
lock_sha, encoding="utf-8"
|
||||
)
|
||||
_chown_app_tree(app_root / ".venv")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to overlay staged venv: %s", exc)
|
||||
else:
|
||||
|
||||
@@ -93,6 +93,11 @@ def posters(path: str):
|
||||
if VERSION != "DEV_VERSION":
|
||||
app.mount("/assets", StaticFiles(directory="dist/assets"), name="assets")
|
||||
app.mount("/images", StaticFiles(directory="dist/images"), name="images")
|
||||
# 自托管 Inter 字体:index.html 以 /fonts/*.woff2 预加载,不挂载则被下面的
|
||||
# SPA 兜底路由回成 index.html,全站字体静默回退。在线更新可能覆盖进来
|
||||
# 更旧的、没有 fonts/ 的 dist,故按存在性挂载。
|
||||
if os.path.isdir("dist/fonts"):
|
||||
app.mount("/fonts", StaticFiles(directory="dist/fonts"), name="fonts")
|
||||
# app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons")
|
||||
templates = Jinja2Templates(directory="dist")
|
||||
|
||||
|
||||
@@ -41,20 +41,90 @@ def _sanitize_dict(d: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
class MaskRestoreError(ValueError):
|
||||
"""无法为掩码密钥可靠定位旧值来源(列表项被删/重排且身份字段同时被改)。"""
|
||||
|
||||
|
||||
def _identity(d: dict) -> tuple:
|
||||
"""列表项的身份 = 全部非敏感标量字段(敏感侧是掩码,无法参与匹配)。"""
|
||||
return tuple(
|
||||
sorted(
|
||||
(k, v)
|
||||
for k, v in d.items()
|
||||
if not isinstance(v, (dict, list)) and not _is_sensitive(k)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _contains_mask(value) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_mask(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_mask(v) for v in value)
|
||||
return value == _MASK
|
||||
|
||||
|
||||
def _restore_masked_list(incoming: list, current: list) -> None:
|
||||
"""按身份匹配列表项后再恢复各自的掩码密钥。
|
||||
|
||||
掩码恢复不能盲目按下标:删掉第 0 个通知渠道后,幸存渠道的 ``********``
|
||||
会从被删渠道取值——密钥被静默写坏且原值永久丢失。只有含掩码的项才
|
||||
需要配对(新增项带明文密钥,不参与、也不许抢占候选)。长度未变时视为
|
||||
原地编辑:同下标同身份 → 唯一身份(识别重排)→ 按下标兜底;长度变了
|
||||
(增/删)只接受唯一身份匹配——包括"两项身份完全相同、删了其中一个"
|
||||
的场景在内,凡无法唯一定位来源的掩码项直接报错,绝不猜。
|
||||
"""
|
||||
masked = [
|
||||
i
|
||||
for i, item in enumerate(incoming)
|
||||
if isinstance(item, dict) and _contains_mask(item)
|
||||
]
|
||||
if not masked:
|
||||
return
|
||||
unconsumed = {j for j, item in enumerate(current) if isinstance(item, dict)}
|
||||
matched: dict[int, int] = {}
|
||||
|
||||
def match_unique_identity(i: int) -> None:
|
||||
candidates = [
|
||||
j for j in unconsumed if _identity(current[j]) == _identity(incoming[i])
|
||||
]
|
||||
if len(candidates) == 1:
|
||||
matched[i] = candidates[0]
|
||||
unconsumed.discard(candidates[0])
|
||||
|
||||
if len(incoming) == len(current):
|
||||
for i in masked:
|
||||
if i in unconsumed and _identity(incoming[i]) == _identity(current[i]):
|
||||
matched[i] = i
|
||||
unconsumed.discard(i)
|
||||
for i in masked:
|
||||
if i not in matched:
|
||||
match_unique_identity(i)
|
||||
for i in masked:
|
||||
if i not in matched and i in unconsumed:
|
||||
matched[i] = i
|
||||
unconsumed.discard(i)
|
||||
else:
|
||||
for i in masked:
|
||||
match_unique_identity(i)
|
||||
|
||||
for i in masked:
|
||||
if i in matched:
|
||||
_restore_masked(incoming[i], current[matched[i]])
|
||||
else:
|
||||
raise MaskRestoreError(
|
||||
f"cannot determine which stored entry list item #{i} refers to; "
|
||||
"re-enter its secret value and save again"
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked(incoming: dict, current: dict) -> dict:
|
||||
"""Replace masked sentinel values with real values from current config."""
|
||||
for k, v in incoming.items():
|
||||
if isinstance(v, dict) and isinstance(current.get(k), dict):
|
||||
_restore_masked(v, current[k])
|
||||
elif isinstance(v, list) and isinstance(current.get(k), list):
|
||||
cur_list = current[k]
|
||||
for i, item in enumerate(v):
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and i < len(cur_list)
|
||||
and isinstance(cur_list[i], dict)
|
||||
):
|
||||
_restore_masked(item, cur_list[i])
|
||||
_restore_masked_list(v, current[k])
|
||||
elif v == _MASK and _is_sensitive(k):
|
||||
incoming[k] = current.get(k, v)
|
||||
return incoming
|
||||
@@ -86,6 +156,15 @@ async def update_config(config: Config, ctx: AppContext = Depends(get_context)):
|
||||
"msg_zh": "更新配置成功。",
|
||||
},
|
||||
)
|
||||
except MaskRestoreError as e:
|
||||
logger.warning(e)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"msg_en": str(e),
|
||||
"msg_zh": "无法恢复被掩码的密钥来源,请重新输入该密钥后再保存。",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return JSONResponse(
|
||||
|
||||
@@ -108,3 +108,22 @@ async def shutdown_program(ctx: AppContext = Depends(get_context)):
|
||||
)
|
||||
async def check_downloader_status(ctx: AppContext = Depends(get_context)):
|
||||
return await ctx.check_downloader()
|
||||
|
||||
|
||||
# 3.2 兼容:这些控制端点在 3.2 及更早版本是 GET,外部自动化(cron/Home
|
||||
# Assistant 等)沿用旧方法,升级后不得 405 静默失效。GET 别名标记为
|
||||
# deprecated,计划下个大版本移除。
|
||||
for _path, _endpoint in (
|
||||
("/restart", restart),
|
||||
("/start", start),
|
||||
("/stop", stop),
|
||||
("/shutdown", shutdown_program),
|
||||
):
|
||||
router.add_api_route(
|
||||
_path,
|
||||
_endpoint,
|
||||
methods=["GET"],
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
deprecated=True,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from module.checker import Checker
|
||||
from module.conf import LEGACY_DATA_PATH, VERSION, settings
|
||||
from module.database import Database
|
||||
from module.downloader.download_client import clear_credential_latch
|
||||
from module.downloader.download_client import shutdown as downloader_shutdown
|
||||
from module.models import ResponseModel
|
||||
from module.network.request_url import reset_shared_client
|
||||
@@ -420,6 +421,9 @@ class AppContext:
|
||||
reset_mikan_cache()
|
||||
reset_poster_cache()
|
||||
reset_llm_parser()
|
||||
# 用户保存了设置即视为已处理凭据问题:解除下载器的凭据失败闩锁,
|
||||
# 允许重试(哪怕保存的值没变——qB 侧密码可能被改回来了)。
|
||||
clear_credential_latch()
|
||||
self.notifier.rebuild()
|
||||
if self.scheduler.running:
|
||||
await self.scheduler.stop_all()
|
||||
|
||||
@@ -26,6 +26,22 @@ _STATUS_FILTER_MAP: dict[str, set[str]] = {
|
||||
}
|
||||
|
||||
|
||||
def _map_qb_state(aria2_status: str, finished: bool) -> str:
|
||||
"""aria2 status -> qB 状态词汇。WebUI 的状态徽标/文案只认 qB 的取值,
|
||||
直接透传 aria2 原词会显示未翻译的原始字符串。"""
|
||||
if aria2_status == "active":
|
||||
return "uploading" if finished else "downloading"
|
||||
if aria2_status == "waiting":
|
||||
return "queuedUP" if finished else "queuedDL"
|
||||
if aria2_status == "paused":
|
||||
return "pausedUP" if finished else "pausedDL"
|
||||
if aria2_status == "complete":
|
||||
return "pausedUP"
|
||||
if aria2_status in ("error", "removed"):
|
||||
return "error"
|
||||
return aria2_status
|
||||
|
||||
|
||||
class Aria2RpcError(Exception):
|
||||
"""aria2 JSON-RPC 返回的业务错误(服务器收到了请求,但拒绝执行)。"""
|
||||
|
||||
@@ -453,6 +469,17 @@ class Aria2Downloader:
|
||||
# 转 int 再判断,否则磁力链拉元数据阶段 totalLength "0" 会除零。
|
||||
total = int(d.get("totalLength", 0) or 0)
|
||||
completed = int(d.get("completedLength", 0) or 0)
|
||||
dlspeed = int(d.get("downloadSpeed", 0) or 0)
|
||||
num_seeds = int(d.get("numSeeders", 0) or 0)
|
||||
connections = int(d.get("connections", 0) or 0)
|
||||
finished = total > 0 and completed >= total
|
||||
# ETA 沿 qB 约定:8640000 = 未知/无穷(UI 渲染为 "-")。
|
||||
if finished:
|
||||
eta = 0
|
||||
elif dlspeed > 0 and total > 0:
|
||||
eta = (total - completed) // dlspeed
|
||||
else:
|
||||
eta = 8640000
|
||||
result.append(
|
||||
{
|
||||
"hash": gid,
|
||||
@@ -460,11 +487,22 @@ class Aria2Downloader:
|
||||
"save_path": d.get("dir", ""),
|
||||
"tags": tags_str,
|
||||
"category": torrent_category or "",
|
||||
"state": aria2_status,
|
||||
"state": _map_qb_state(aria2_status, finished),
|
||||
"size": total,
|
||||
"progress": completed / total if total > 0 else 0.0,
|
||||
"dlspeed": int(d.get("downloadSpeed", 0) or 0),
|
||||
"dlspeed": dlspeed,
|
||||
"upspeed": int(d.get("uploadSpeed", 0) or 0),
|
||||
"num_seeds": num_seeds,
|
||||
# qB 的 num_leechs ≈ 非做种连接数;aria2 只报连接总数。
|
||||
"num_leechs": max(connections - num_seeds, 0),
|
||||
"eta": eta,
|
||||
# aria2 不记录添加时间;用本地 gid 映射的入库时间近似
|
||||
# (UI 按 added_on 排序),无记录时归 0 排到最后。
|
||||
"added_on": (
|
||||
int(info.created_at.timestamp())
|
||||
if info and info.created_at
|
||||
else 0
|
||||
),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -40,6 +40,9 @@ class QbDownloader:
|
||||
# requests don't each fire their own login attempt and trip
|
||||
# qBittorrent's IP ban (#1046-adjacent).
|
||||
self._auth_lock = asyncio.Lock()
|
||||
# 每完成一次真实登录尝试 +1:让排在锁后的并发等待者识别"我等待期间
|
||||
# 已有一次登录被凭据拒绝",从而不再补发自己的 POST。
|
||||
self._auth_generation = 0
|
||||
|
||||
def _url(self, endpoint: str) -> str:
|
||||
return f"{self.host}/api/v2/{endpoint}"
|
||||
@@ -65,6 +68,34 @@ class QbDownloader:
|
||||
# repeated operations don't re-login every cycle (#1039 / #900).
|
||||
if self._client is not None and self._authed:
|
||||
return True
|
||||
# 单飞:并发的 loop tick 同时 auth() 时只发一次 login POST——失败的
|
||||
# 并发登录会各自计入 qB 的 WebUI IP ban 阈值(默认 5 次即封禁)。
|
||||
generation = self._auth_generation
|
||||
async with self._auth_lock:
|
||||
if self._client is not None and self._authed:
|
||||
return True
|
||||
if (
|
||||
generation != self._auth_generation
|
||||
and self.last_auth_error == "credentials"
|
||||
):
|
||||
# 等锁期间已有一次真实登录被凭据拒绝:共享这个否定结果,
|
||||
# 不再补发自己的 POST。之后的全新 auth() 调用(如 checker
|
||||
# 主动探测)不受影响,仍可重试并在成功后清除失败原因。
|
||||
return False
|
||||
return await self._locked_auth(retry)
|
||||
|
||||
async def _locked_auth(self, retry=3):
|
||||
"""auth() 的主体;调用方必须已持有 ``_auth_lock``。"""
|
||||
if self._client is not None and self._authed:
|
||||
return True
|
||||
try:
|
||||
return await self._login_attempt(retry)
|
||||
finally:
|
||||
# 尝试结束后才 +1:在本次尝试期间排队的等待者捕获的是旧值,
|
||||
# 醒来后据此识别"结果已出",共享失败结论而非重复 POST。
|
||||
self._auth_generation += 1
|
||||
|
||||
async def _login_attempt(self, retry=3):
|
||||
times = 0
|
||||
use_https = self.host.startswith("https://")
|
||||
if self._client is None:
|
||||
@@ -154,7 +185,7 @@ class QbDownloader:
|
||||
# Another waiter may have already refreshed the session while
|
||||
# we were blocked on the lock.
|
||||
if not self._authed:
|
||||
await self.auth()
|
||||
await self._locked_auth()
|
||||
if not self._authed:
|
||||
raise ConnectionError(
|
||||
f"Re-authentication to qBittorrent at {self.host} "
|
||||
|
||||
@@ -30,10 +30,15 @@ logger = logging.getLogger(__name__)
|
||||
# awaits in ``__aenter__``/``__aexit__``.
|
||||
# ---------------------------------------------------------------------------
|
||||
_client_cache: tuple[tuple, DownloaderClient] | None = None
|
||||
_stale_client: DownloaderClient | None = None
|
||||
_stale_clients: list[DownloaderClient] = []
|
||||
_active_holders: dict[int, int] = {}
|
||||
_pending_close: set[int] = set()
|
||||
_bookkeeping_lock = asyncio.Lock()
|
||||
# 凭据被服务端明确拒绝后的闩锁:记录失败时的连接设置 key。命中时 enter 直接
|
||||
# 失败、不再发 login POST——每个 tick 重试一次登录,约 5 次即触发 qB 的
|
||||
# WebUI IP ban。设置变更(key 不同)自然解锁;同值重存经
|
||||
# clear_credential_latch()(AppContext.reload_settings)解锁。
|
||||
_credential_failed_key: tuple | None = None
|
||||
|
||||
# Warn at most once per (client type, operation) when a backend cannot perform
|
||||
# an operation, so aria2 users are not spammed every rename cycle.
|
||||
@@ -47,11 +52,18 @@ def _settings_key() -> tuple:
|
||||
|
||||
def _reset_client_cache() -> None:
|
||||
"""Drop the cached/stale concrete clients and refcount state (used by tests)."""
|
||||
global _client_cache, _stale_client, _active_holders, _pending_close
|
||||
global _client_cache, _stale_clients, _active_holders, _pending_close
|
||||
_client_cache = None
|
||||
_stale_client = None
|
||||
_stale_clients = []
|
||||
_active_holders = {}
|
||||
_pending_close = set()
|
||||
clear_credential_latch()
|
||||
|
||||
|
||||
def clear_credential_latch() -> None:
|
||||
"""解除凭据失败闩锁(配置保存后调用,允许用户重试同值凭据)。"""
|
||||
global _credential_failed_key
|
||||
_credential_failed_key = None
|
||||
|
||||
|
||||
async def _close_client(client) -> None:
|
||||
@@ -66,14 +78,12 @@ async def shutdown() -> None:
|
||||
|
||||
Invoked by the composition root (`AppContext`) on application shutdown.
|
||||
"""
|
||||
global _client_cache, _stale_client
|
||||
clients = []
|
||||
if _stale_client is not None:
|
||||
clients.append(_stale_client)
|
||||
global _client_cache, _stale_clients
|
||||
clients = list(_stale_clients)
|
||||
if _client_cache is not None:
|
||||
clients.append(_client_cache[1])
|
||||
_client_cache = None
|
||||
_stale_client = None
|
||||
_stale_clients = []
|
||||
for client in clients:
|
||||
await _close_client(client)
|
||||
|
||||
@@ -89,15 +99,18 @@ class DownloadClient:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
global _client_cache, _stale_client
|
||||
global _client_cache
|
||||
key = _settings_key()
|
||||
self.client: DownloaderClient
|
||||
self._cache_key = key
|
||||
if _client_cache is not None and _client_cache[0] == key:
|
||||
self.client = _client_cache[1]
|
||||
else:
|
||||
if _client_cache is not None:
|
||||
# Settings changed: retire the previous client, close it later.
|
||||
_stale_client = _client_cache[1]
|
||||
# 用列表累积——连续两次改设置(期间没有 enter)不得把第一个
|
||||
# 被撤下的客户端顶掉,否则它的连接池泄漏到进程结束。
|
||||
_stale_clients.append(_client_cache[1])
|
||||
self.client = self.__getClient()
|
||||
_client_cache = (key, self.client)
|
||||
self.authed = False
|
||||
@@ -156,52 +169,69 @@ class DownloadClient:
|
||||
return False
|
||||
|
||||
async def __aenter__(self):
|
||||
global _stale_client, _client_cache
|
||||
stale_to_close = None
|
||||
async with _bookkeeping_lock:
|
||||
if _stale_client is not None:
|
||||
if _active_holders.get(id(_stale_client), 0) > 0:
|
||||
# Still in use by another overlapping block; the last
|
||||
# holder's __aexit__ will close it once released.
|
||||
_pending_close.add(id(_stale_client))
|
||||
else:
|
||||
stale_to_close = _stale_client
|
||||
_stale_client = None
|
||||
if stale_to_close is not None:
|
||||
await _close_client(stale_to_close)
|
||||
|
||||
if not self.authed:
|
||||
await self.auth()
|
||||
if not self.authed:
|
||||
# __aexit__ never runs when we raise here, so close the
|
||||
# concrete client's connection pool now or it leaks on every
|
||||
# failed connect (#1043) -- unless another already-entered
|
||||
# block still holds it, in which case defer to its
|
||||
# __aexit__ instead of yanking the pool out from under it.
|
||||
close_now = False
|
||||
async with _bookkeeping_lock:
|
||||
if _client_cache is not None and _client_cache[1] is self.client:
|
||||
_client_cache = None
|
||||
if _active_holders.get(id(self.client), 0) > 0:
|
||||
_pending_close.add(id(self.client))
|
||||
else:
|
||||
close_now = True
|
||||
if close_now:
|
||||
await _close_client(self.client)
|
||||
raise ConnectionError("Download client authentication failed")
|
||||
global _client_cache, _credential_failed_key
|
||||
if (
|
||||
_credential_failed_key is not None
|
||||
and _credential_failed_key == self._cache_key
|
||||
):
|
||||
# 凭据上次已被服务端明确拒绝且设置未变:不再发 login POST,
|
||||
# 避免逐 tick 累积到 qB 的 IP ban。checker/等待循环读的是
|
||||
# 具体客户端上的失败原因,这里补齐。
|
||||
if hasattr(self.client, "last_auth_error"):
|
||||
self.client.last_auth_error = "credentials" # type: ignore[attr-defined]
|
||||
raise ConnectionError(
|
||||
"Download client credentials were rejected previously; "
|
||||
"update the downloader settings to retry"
|
||||
)
|
||||
|
||||
stale_to_close: list[DownloaderClient] = []
|
||||
async with _bookkeeping_lock:
|
||||
# 先把自己计入引用——auth() 是 await 点,期间一个"设置变更"块
|
||||
# 可能进来关闭被撤下的客户端;不先占坑的话,自己正在登录的
|
||||
# 客户端会被从脚下抽走。
|
||||
_active_holders[id(self.client)] = (
|
||||
_active_holders.get(id(self.client), 0) + 1
|
||||
)
|
||||
while _stale_clients:
|
||||
stale = _stale_clients.pop()
|
||||
if _active_holders.get(id(stale), 0) > 0:
|
||||
# Still in use by another overlapping block; the last
|
||||
# holder's __aexit__ will close it once released.
|
||||
_pending_close.add(id(stale))
|
||||
else:
|
||||
stale_to_close.append(stale)
|
||||
for stale in stale_to_close:
|
||||
await _close_client(stale)
|
||||
|
||||
if not self.authed:
|
||||
# __aexit__ never runs when __aenter__ raises, so every exit path
|
||||
# below must release the holder slot itself -- including
|
||||
# cancellation mid-login, or the count leaks and a retired client
|
||||
# stays pending-close (pool open) forever.
|
||||
try:
|
||||
await self.auth()
|
||||
except BaseException:
|
||||
await self._release_holder()
|
||||
raise
|
||||
if not self.authed:
|
||||
if self.last_auth_error == "credentials":
|
||||
_credential_failed_key = self._cache_key
|
||||
# Release our slot and close the concrete client's connection
|
||||
# pool now or it leaks on every failed connect (#1043) --
|
||||
# unless another already-entered block still holds it, in
|
||||
# which case defer to its __aexit__ instead of yanking the
|
||||
# pool out from under it.
|
||||
async with _bookkeeping_lock:
|
||||
if _client_cache is not None and _client_cache[1] is self.client:
|
||||
_client_cache = None
|
||||
_pending_close.add(id(self.client))
|
||||
await self._release_holder()
|
||||
raise ConnectionError("Download client authentication failed")
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
# The concrete session is reused across operations; do NOT log out
|
||||
# here unless this was the last holder of a client that a concurrent
|
||||
# block already marked for (deferred) close. Teardown is otherwise
|
||||
# deferred to shutdown() (composition root).
|
||||
self.authed = False
|
||||
async def _release_holder(self) -> None:
|
||||
"""释放本块对具体客户端的引用;若是最后一个且已标记待关,则关闭。"""
|
||||
client = self.client
|
||||
to_close = False
|
||||
async with _bookkeeping_lock:
|
||||
@@ -216,6 +246,14 @@ class DownloadClient:
|
||||
if to_close:
|
||||
await _close_client(client)
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
# The concrete session is reused across operations; do NOT log out
|
||||
# here unless this was the last holder of a client that a concurrent
|
||||
# block already marked for (deferred) close. Teardown is otherwise
|
||||
# deferred to shutdown() (composition root).
|
||||
self.authed = False
|
||||
await self._release_holder()
|
||||
|
||||
async def auth(self):
|
||||
self.authed = await self.client.auth()
|
||||
if self.authed:
|
||||
|
||||
@@ -622,7 +622,22 @@ class Updater:
|
||||
"refusing rollback"
|
||||
)
|
||||
|
||||
if backup.exists():
|
||||
# backup 树必须连同它的已验签 bundle 一起换回:boot_overlay
|
||||
# 只认 bundle.zip(.sig),缺了它互换会把现有 bundle.zip 换丢,
|
||||
# 下次启动被判"legacy/unsigned"整体清除——报告回滚成功、
|
||||
# 实际落在镜像版本。旧 beta 遗留的 backup 树可能没有留存
|
||||
# bundle-backup.zip,此时走"回退镜像版本"分支才是诚实结果。
|
||||
has_signed_backup = (
|
||||
backup.exists()
|
||||
and (self.root / "bundle-backup.zip").exists()
|
||||
and (self.root / "bundle-backup.zip.sig").exists()
|
||||
)
|
||||
if backup.exists() and not has_signed_backup:
|
||||
logger.warning(
|
||||
"Backup tree exists but its signed bundle is missing; "
|
||||
"reverting to the image version instead of swapping."
|
||||
)
|
||||
if has_signed_backup:
|
||||
# current <-> backup 互换,使回滚本身可再次撤销。
|
||||
swap = self.root / "_swap_tmp"
|
||||
if swap.exists():
|
||||
@@ -687,9 +702,11 @@ class Updater:
|
||||
)
|
||||
message = "rolled back to previous version; restarting"
|
||||
else:
|
||||
# 无备份:删除覆盖层,回退到镜像自带版本。
|
||||
# 无(可验签的)备份:删除覆盖层,回退到镜像自带版本。
|
||||
if current.exists():
|
||||
shutil.rmtree(current)
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
if applied.exists():
|
||||
applied.unlink()
|
||||
for name in (
|
||||
|
||||
@@ -487,6 +487,112 @@ class TestRestoreMasked:
|
||||
assert sanitized["downloader"]["host"] == "10.0.0.1"
|
||||
assert sanitized["llm"]["model"] == "gpt-4o-mini"
|
||||
|
||||
def test_list_item_deleted_restores_survivor_from_own_entry(self):
|
||||
"""删除列表首项后,幸存项的掩码密钥必须从它自己的旧值恢复,
|
||||
不能按下标错拿被删项的密钥。"""
|
||||
incoming = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "2", "token": "********"},
|
||||
]
|
||||
}
|
||||
current = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "1", "token": "tg-first"},
|
||||
{"type": "telegram", "chat_id": "2", "token": "tg-second"},
|
||||
]
|
||||
}
|
||||
_restore_masked(incoming, current)
|
||||
assert incoming["providers"][0]["token"] == "tg-second"
|
||||
|
||||
def test_list_reordered_restores_each_from_matching_entry(self):
|
||||
"""列表重排后,每个掩码项按自身身份(非敏感字段)匹配恢复。"""
|
||||
incoming = {
|
||||
"providers": [
|
||||
{"type": "bark", "chat_id": "b", "token": "********"},
|
||||
{"type": "telegram", "chat_id": "a", "token": "********"},
|
||||
]
|
||||
}
|
||||
current = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "a", "token": "tg-token"},
|
||||
{"type": "bark", "chat_id": "b", "token": "bark-token"},
|
||||
]
|
||||
}
|
||||
_restore_masked(incoming, current)
|
||||
assert incoming["providers"][0]["token"] == "bark-token"
|
||||
assert incoming["providers"][1]["token"] == "tg-token"
|
||||
|
||||
def test_list_same_length_edited_fields_falls_back_to_index(self):
|
||||
"""长度不变时编辑了非敏感字段(无法按身份匹配)→ 按下标恢复,
|
||||
保持旧行为:改 chat_id 不应要求重输 token。"""
|
||||
incoming = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "changed", "token": "********"},
|
||||
]
|
||||
}
|
||||
current = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "orig", "token": "tg-token"},
|
||||
]
|
||||
}
|
||||
_restore_masked(incoming, current)
|
||||
assert incoming["providers"][0]["token"] == "tg-token"
|
||||
|
||||
def test_list_structural_change_plus_edit_raises(self):
|
||||
"""删项 + 同时编辑幸存项的身份字段 → 无法可靠匹配,必须报错
|
||||
而不是猜错来源静默写坏密钥。"""
|
||||
from module.api.config import MaskRestoreError
|
||||
|
||||
incoming = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "edited", "token": "********"},
|
||||
]
|
||||
}
|
||||
current = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "1", "token": "tg-first"},
|
||||
{"type": "telegram", "chat_id": "2", "token": "tg-second"},
|
||||
]
|
||||
}
|
||||
with pytest.raises(MaskRestoreError):
|
||||
_restore_masked(incoming, current)
|
||||
|
||||
def test_list_item_deleted_among_identical_identities_raises(self):
|
||||
"""两个身份完全相同(只差密钥)的项删掉一个:无法知道幸存的是哪个,
|
||||
必须报错而不是按下标把被删项的密钥塞给幸存者。"""
|
||||
from module.api.config import MaskRestoreError
|
||||
|
||||
incoming = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "same", "token": "********"},
|
||||
]
|
||||
}
|
||||
current = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "same", "token": "tg-first"},
|
||||
{"type": "telegram", "chat_id": "same", "token": "tg-second"},
|
||||
]
|
||||
}
|
||||
with pytest.raises(MaskRestoreError):
|
||||
_restore_masked(incoming, current)
|
||||
|
||||
def test_list_item_added_with_new_secret_passes(self):
|
||||
"""新增列表项带明文新密钥:无掩码,不需要匹配,不得报错。"""
|
||||
incoming = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "1", "token": "********"},
|
||||
{"type": "bark", "chat_id": "b", "token": "new-bark-token"},
|
||||
]
|
||||
}
|
||||
current = {
|
||||
"providers": [
|
||||
{"type": "telegram", "chat_id": "1", "token": "tg-token"},
|
||||
]
|
||||
}
|
||||
_restore_masked(incoming, current)
|
||||
assert incoming["providers"][0]["token"] == "tg-token"
|
||||
assert incoming["providers"][1]["token"] == "new-bark-token"
|
||||
|
||||
def test_update_config_preserves_password_when_masked(
|
||||
self, authed_client, mock_settings
|
||||
):
|
||||
|
||||
@@ -148,6 +148,30 @@ class TestRestartProgram:
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3.2 GET compatibility shims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLegacyGetCompat:
|
||||
"""3.2 及更早版本的控制端点是 GET;外部自动化(cron/Home Assistant)
|
||||
升级到 3.3 后不得 405 静默失效。"""
|
||||
|
||||
def test_restart_get_compat(self, authed_client):
|
||||
assert authed_client.get("/api/v1/restart").status_code == 200
|
||||
|
||||
def test_start_get_compat(self, authed_client):
|
||||
assert authed_client.get("/api/v1/start").status_code == 200
|
||||
|
||||
def test_stop_get_compat(self, authed_client):
|
||||
assert authed_client.get("/api/v1/stop").status_code == 200
|
||||
|
||||
def test_shutdown_get_route_exists(self, unauthed_client):
|
||||
# 不真调用(会杀掉测试进程):无鉴权应得 401,说明路由存在;
|
||||
# 不存在的方法是 405。
|
||||
assert unauthed_client.get("/api/v1/shutdown").status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -585,7 +585,7 @@ class TestTorrentsInfo:
|
||||
assert info["save_path"] == "/downloads/Show/Season 1"
|
||||
assert info["tags"] == "ab:5"
|
||||
assert info["category"] == "Bangumi"
|
||||
assert info["state"] == "active"
|
||||
assert info["state"] == "downloading" # active + 未完成 → qB 词汇
|
||||
assert info["name"] == "gidA.mkv"
|
||||
|
||||
async def test_status_filter_completed_excludes_active(self):
|
||||
@@ -683,6 +683,75 @@ class TestTorrentsInfo:
|
||||
assert result[0]["progress"] == 0.0
|
||||
assert result[0]["size"] == 0
|
||||
|
||||
async def test_torrents_info_provides_ui_fields(self):
|
||||
"""WebUI 下载页无条件消费 num_seeds/num_leechs/eta/added_on——aria2
|
||||
载荷缺了它们会渲染成 'undefined / undefined' 和 'NaNhNaNm'。"""
|
||||
aria2 = _aria2()
|
||||
download = _download("gidUi", status="active")
|
||||
download["numSeeders"] = "3"
|
||||
download["connections"] = "8"
|
||||
download["downloadSpeed"] = "100" # 剩余 500 字节 → eta 5s
|
||||
|
||||
async def fake_call(method, params=None, timeout=10.0):
|
||||
if method == "tellActive":
|
||||
return [download]
|
||||
return []
|
||||
|
||||
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
|
||||
result = await aria2.torrents_info(status_filter=None, category=None)
|
||||
|
||||
info = result[0]
|
||||
assert info["num_seeds"] == 3
|
||||
assert info["num_leechs"] == 5 # connections - seeders
|
||||
assert info["eta"] == 5
|
||||
assert isinstance(info["added_on"], int)
|
||||
|
||||
async def test_torrents_info_eta_infinite_when_stalled(self):
|
||||
"""下载中但速度为 0:eta 用 qB 的"无穷"约定 8640000(UI 显示 '-')。"""
|
||||
aria2 = _aria2()
|
||||
download = _download("gidStall", status="active") # dlspeed "0"
|
||||
|
||||
async def fake_call(method, params=None, timeout=10.0):
|
||||
if method == "tellActive":
|
||||
return [download]
|
||||
return []
|
||||
|
||||
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
|
||||
result = await aria2.torrents_info(status_filter=None, category=None)
|
||||
|
||||
assert result[0]["eta"] == 8640000
|
||||
|
||||
async def test_torrents_info_maps_states_to_qb_vocabulary(self):
|
||||
"""状态徽标/筛选只认 qB 词汇:active/waiting/paused/complete/error
|
||||
必须映射,不能把 aria2 原词直接透传给 UI。"""
|
||||
aria2 = _aria2()
|
||||
cases = [
|
||||
("active", "500", "downloading"),
|
||||
("active", "1000", "uploading"), # 下载完成、做种中
|
||||
("waiting", "500", "queuedDL"),
|
||||
("paused", "500", "pausedDL"),
|
||||
("paused", "1000", "pausedUP"),
|
||||
("complete", "1000", "pausedUP"),
|
||||
("error", "500", "error"),
|
||||
]
|
||||
downloads = []
|
||||
for idx, (status, completed, _) in enumerate(cases):
|
||||
d = _download(f"gid{idx}", status=status)
|
||||
d["completedLength"] = completed
|
||||
downloads.append(d)
|
||||
|
||||
async def fake_call(method, params=None, timeout=10.0):
|
||||
if method == "tellActive":
|
||||
return downloads
|
||||
return []
|
||||
|
||||
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
|
||||
result = await aria2.torrents_info(status_filter=None, category=None)
|
||||
|
||||
by_gid = {info["hash"]: info["state"] for info in result}
|
||||
for idx, (_, _, expected) in enumerate(cases):
|
||||
assert by_gid[f"gid{idx}"] == expected, f"case {idx}"
|
||||
|
||||
async def test_torrents_info_string_numbers_parsed_to_int(self):
|
||||
"""aria2 JSON-RPC 的数值字段都是字符串,映射结果必须是 int/float。"""
|
||||
aria2 = _aria2()
|
||||
|
||||
@@ -5,6 +5,7 @@ we log in once, re-authenticate only when the session expires, and rebuild when
|
||||
connection settings change.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -146,3 +147,249 @@ class TestSessionReuse:
|
||||
assert await qb.auth() is True # cheap, no second login
|
||||
|
||||
assert len(login_calls) == 1
|
||||
|
||||
async def test_concurrent_auth_calls_share_one_login(self):
|
||||
"""并发的 auth()(重叠的 loop tick)必须单飞:只发一次 login POST,
|
||||
否则失败时并发登录会叠加计入 qB 的 WebUI IP ban 阈值。"""
|
||||
qb = QbDownloader("localhost:8080", "admin", "admin", ssl=False)
|
||||
login_calls: list = []
|
||||
|
||||
class _SlowLogin:
|
||||
async def post(self, url, data=None):
|
||||
if url.endswith("auth/login"):
|
||||
login_calls.append(url)
|
||||
await asyncio.sleep(0.01)
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = "Ok."
|
||||
return resp
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=_SlowLogin(),
|
||||
):
|
||||
r1, r2 = await asyncio.gather(qb.auth(), qb.auth())
|
||||
|
||||
assert r1 is True and r2 is True
|
||||
assert len(login_calls) == 1
|
||||
|
||||
|
||||
class _FakeRejectHTTP:
|
||||
"""登录永远被拒(错误密码)的 httpx 替身。"""
|
||||
|
||||
def __init__(self, login_calls: list):
|
||||
self._login_calls = login_calls
|
||||
|
||||
async def post(self, url, data=None):
|
||||
if url.endswith("auth/login"):
|
||||
self._login_calls.append(url)
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = "Fails."
|
||||
return resp
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestCredentialFailureLatch:
|
||||
"""凭据被拒后不得每个 tick 重试登录:约 5 次失败就会触发 qB 的 IP ban。"""
|
||||
|
||||
async def test_bad_credentials_do_not_relogin_every_block(self):
|
||||
login_calls: list = []
|
||||
|
||||
with patch("module.downloader.download_client.settings") as mock_settings:
|
||||
_patch_qb_settings(mock_settings)
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
side_effect=lambda *a, **k: _FakeRejectHTTP(login_calls),
|
||||
):
|
||||
for _ in range(3): # 三个先后到来的 loop tick
|
||||
with pytest.raises(ConnectionError):
|
||||
async with DownloadClient():
|
||||
pass
|
||||
|
||||
assert len(login_calls) == 1
|
||||
|
||||
async def test_concurrent_bad_credential_auths_send_one_login(self):
|
||||
"""并发等待者不得在首个失败者确认"凭据被拒"后再各发一次 login POST。"""
|
||||
login_calls: list = []
|
||||
|
||||
class _SlowReject:
|
||||
async def post(self, url, data=None):
|
||||
if url.endswith("auth/login"):
|
||||
login_calls.append(url)
|
||||
await asyncio.sleep(0.01)
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = "Fails."
|
||||
return resp
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
qb = QbDownloader("localhost:8080", "admin", "wrong", ssl=False)
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=_SlowReject(),
|
||||
):
|
||||
r = await asyncio.gather(qb.auth(), qb.auth(), qb.auth())
|
||||
|
||||
assert r == [False, False, False]
|
||||
assert len(login_calls) == 1
|
||||
|
||||
async def test_latch_cleared_on_explicit_clear(self):
|
||||
"""配置保存(reload_settings)后清除闩锁,允许重试一次登录。"""
|
||||
from module.downloader.download_client import clear_credential_latch
|
||||
|
||||
login_calls: list = []
|
||||
|
||||
with patch("module.downloader.download_client.settings") as mock_settings:
|
||||
_patch_qb_settings(mock_settings)
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
side_effect=lambda *a, **k: _FakeRejectHTTP(login_calls),
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
async with DownloadClient():
|
||||
pass
|
||||
clear_credential_latch()
|
||||
with pytest.raises(ConnectionError):
|
||||
async with DownloadClient():
|
||||
pass
|
||||
|
||||
assert len(login_calls) == 2
|
||||
|
||||
|
||||
class TestRetiredClientLifecycle:
|
||||
async def test_two_rapid_settings_changes_close_both_retired_clients(self):
|
||||
"""连续两次改设置(期间没有任何 enter)不得丢失第一个被撤下的
|
||||
客户端——它的连接池必须在下次 enter 时被关闭,而不是泄漏到进程结束。"""
|
||||
fakes: list = []
|
||||
|
||||
class _TrackedHTTP:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
fakes.append(self)
|
||||
|
||||
async def post(self, url, data=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = "Ok."
|
||||
return resp
|
||||
|
||||
async def aclose(self):
|
||||
self.closed = True
|
||||
|
||||
with patch("module.downloader.download_client.settings") as mock_settings:
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
side_effect=lambda *a, **k: _TrackedHTTP(),
|
||||
):
|
||||
_patch_qb_settings(mock_settings, host="host-a:8080")
|
||||
async with DownloadClient():
|
||||
pass # A 已认证,连接池已打开
|
||||
_patch_qb_settings(mock_settings, host="host-b:8080")
|
||||
DownloadClient() # 撤下 A(未 enter)
|
||||
_patch_qb_settings(mock_settings, host="host-c:8080")
|
||||
DownloadClient() # 撤下 B——不得把 A 顶掉忘掉
|
||||
async with DownloadClient():
|
||||
pass
|
||||
|
||||
assert fakes[0].closed is True # A 的连接池被关闭,未泄漏
|
||||
|
||||
async def test_cancelled_auth_releases_holder_bookkeeping(self):
|
||||
"""__aenter__ 的 auth() 被取消/抛异常时必须释放引用计数,否则被撤下
|
||||
的客户端永远挂在 pending-close 上、连接池永不关闭。"""
|
||||
from module.downloader import download_client as dc
|
||||
|
||||
class _HangingLogin:
|
||||
async def post(self, url, data=None):
|
||||
if url.endswith("auth/login"):
|
||||
await asyncio.Event().wait() # 永不返回,等着被取消
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = "Ok."
|
||||
return resp
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
with patch("module.downloader.download_client.settings") as mock_settings:
|
||||
_patch_qb_settings(mock_settings)
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
return_value=_HangingLogin(),
|
||||
):
|
||||
|
||||
async def enter_block():
|
||||
async with DownloadClient():
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(enter_block())
|
||||
await asyncio.sleep(0.01) # 进入 auth() 等待
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert dc._active_holders == {}
|
||||
|
||||
async def test_settings_change_does_not_close_client_mid_login(self):
|
||||
"""设置变更时,另一个块还在 __aenter__ 的 auth() 中途(尚未计入
|
||||
引用计数)——不得把它正在登录的客户端关掉。"""
|
||||
events: list[str] = []
|
||||
login_started = asyncio.Event()
|
||||
release_login = asyncio.Event()
|
||||
|
||||
class _SlowLoginHTTP:
|
||||
async def post(self, url, data=None):
|
||||
if url.endswith("auth/login"):
|
||||
login_started.set()
|
||||
await release_login.wait()
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = "Ok."
|
||||
return resp
|
||||
|
||||
async def aclose(self):
|
||||
events.append("A-closed")
|
||||
|
||||
class _FastHTTP:
|
||||
async def post(self, url, data=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.text = "Ok."
|
||||
return resp
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
with patch("module.downloader.download_client.settings") as mock_settings:
|
||||
with patch(
|
||||
"module.downloader.client.qb_downloader.httpx.AsyncClient",
|
||||
side_effect=[_SlowLoginHTTP(), _FastHTTP()],
|
||||
):
|
||||
_patch_qb_settings(mock_settings, host="host-a:8080")
|
||||
|
||||
async def block1():
|
||||
async with DownloadClient():
|
||||
events.append("block1-in")
|
||||
events.append("block1-out")
|
||||
|
||||
task = asyncio.create_task(block1())
|
||||
await login_started.wait() # block1 正在 auth() 中途
|
||||
|
||||
_patch_qb_settings(mock_settings, host="host-b:8080")
|
||||
async with DownloadClient(): # 撤下 A 并触发关闭决策
|
||||
pass
|
||||
|
||||
release_login.set()
|
||||
await task
|
||||
|
||||
assert "A-closed" in events
|
||||
# A 只能在 block1 成功 enter 之后关闭(由 block1 的 __aexit__ 收尾),
|
||||
# 不得在其登录中途被关。
|
||||
assert events.index("A-closed") > events.index("block1-in")
|
||||
|
||||
@@ -447,6 +447,17 @@ class TestApplyUpdate:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_backup_bundles(root):
|
||||
"""正常签名流程下 backup 树总是伴随留存的已验签 bundle 及其签名。"""
|
||||
for name in (
|
||||
"bundle.zip",
|
||||
"bundle.zip.sig",
|
||||
"bundle-backup.zip",
|
||||
"bundle-backup.zip.sig",
|
||||
):
|
||||
(root / name).write_bytes(b"stub-" + name.encode())
|
||||
|
||||
|
||||
class TestRollback:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_swaps_backup_into_current(self, tmp_path):
|
||||
@@ -457,6 +468,7 @@ class TestRollback:
|
||||
backup.mkdir(parents=True)
|
||||
(current / "manifest.json").write_text(json.dumps({"version": "3.3.0"}))
|
||||
(backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"}))
|
||||
_seed_backup_bundles(root)
|
||||
|
||||
up = Updater(root=root, current_version="3.3.0", image_version="3.1.0")
|
||||
res = await up.rollback()
|
||||
@@ -467,6 +479,34 @@ class TestRollback:
|
||||
assert cur["version"] == "3.2.0"
|
||||
applied = json.loads((root / "applied.json").read_text())
|
||||
assert applied["version"] == "3.2.0"
|
||||
# bundle 与 backup-bundle 同步互换,boot_overlay 验签对象跟 current 一致
|
||||
assert (root / "bundle.zip").read_bytes() == b"stub-bundle-backup.zip"
|
||||
assert (root / "bundle-backup.zip").read_bytes() == b"stub-bundle.zip"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_without_backup_bundle_reverts_to_image(self, tmp_path):
|
||||
"""backup 树存在但没有留存的已验签 bundle-backup.zip(旧 beta 遗留):
|
||||
直接互换会把 bundle.zip 换丢,boot_overlay 下次启动拒绝并清除覆盖层。
|
||||
必须改走"回退镜像版本"路径,而不是报告回滚成功却落在另一个版本。"""
|
||||
root = tmp_path / "u"
|
||||
current = root / "current"
|
||||
backup = root / "backup"
|
||||
current.mkdir(parents=True)
|
||||
backup.mkdir(parents=True)
|
||||
(current / "manifest.json").write_text(json.dumps({"version": "3.3.0"}))
|
||||
(backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"}))
|
||||
(root / "bundle.zip").write_bytes(b"stub")
|
||||
(root / "bundle.zip.sig").write_bytes(b"stub")
|
||||
(root / "applied.json").write_text(json.dumps({"version": "3.3.0"}))
|
||||
|
||||
up = Updater(root=root, current_version="3.3.0", image_version="3.1.0")
|
||||
res = await up.rollback()
|
||||
assert res.success is True
|
||||
assert res.version == "3.1.0" # 镜像版本,而非无法验签的 3.2.0
|
||||
assert not current.exists()
|
||||
assert not backup.exists()
|
||||
assert not (root / "bundle.zip").exists()
|
||||
assert not (root / "applied.json").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_restores_db_snapshot(self, tmp_path):
|
||||
@@ -477,6 +517,7 @@ class TestRollback:
|
||||
backup.mkdir(parents=True)
|
||||
(current / "manifest.json").write_text(json.dumps({"version": "3.3.0"}))
|
||||
(backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"}))
|
||||
_seed_backup_bundles(root)
|
||||
db_path = tmp_path / "data" / "data.db"
|
||||
_seed_schema_db(db_path, 9)
|
||||
backup_db = root / "db-backup" / "data.db"
|
||||
@@ -517,6 +558,7 @@ class TestRollback:
|
||||
backup.mkdir(parents=True)
|
||||
(current / "manifest.json").write_text(json.dumps({"version": "3.3.0"}))
|
||||
(backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"}))
|
||||
_seed_backup_bundles(root)
|
||||
db_path = tmp_path / "data" / "data.db"
|
||||
_seed_schema_db(db_path, 4)
|
||||
backup_db = root / "db-backup" / "data.db"
|
||||
@@ -552,6 +594,7 @@ class TestRollback:
|
||||
backup.mkdir(parents=True)
|
||||
(current / "manifest.json").write_text(json.dumps({"version": "3.3.0"}))
|
||||
(backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"}))
|
||||
_seed_backup_bundles(root)
|
||||
db_path = tmp_path / "data" / "data.db"
|
||||
_seed_schema_db(db_path, 9)
|
||||
# 上一轮更新遗留的快照目录;本轮 applied.json 没有 db_backup 记录
|
||||
@@ -588,6 +631,7 @@ class TestRollback:
|
||||
backup.mkdir(parents=True)
|
||||
(current / "manifest.json").write_text(json.dumps({"version": "3.3.0"}))
|
||||
(backup / "manifest.json").write_text(json.dumps({"version": "3.2.0"}))
|
||||
_seed_backup_bundles(root)
|
||||
db_path = tmp_path / "data" / "data.db"
|
||||
_seed_schema_db(db_path, 999)
|
||||
|
||||
@@ -793,6 +837,146 @@ class TestBootOverlay:
|
||||
)
|
||||
assert applied is False
|
||||
|
||||
def test_exdev_fallback_restores_old_tree_on_partial_copy(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""EXDEV 就地替换中途失败(磁盘满/IO 错)不得留下残缺的 module 树——
|
||||
必须恢复旧树,让启动继续跑镜像版本而不是 ImportError 崩溃循环。"""
|
||||
import errno
|
||||
import os as os_mod
|
||||
import shutil as shutil_mod
|
||||
|
||||
import boot_overlay
|
||||
|
||||
app, updates, ivp, lock, pubkey = self._seed(tmp_path, "3.4.0")
|
||||
|
||||
real_rename = os_mod.rename
|
||||
|
||||
def fake_rename(a, b):
|
||||
if str(a).endswith(("module", "dist")):
|
||||
raise OSError(errno.EXDEV, "Cross-device link")
|
||||
return real_rename(a, b)
|
||||
|
||||
monkeypatch.setattr(boot_overlay.os, "rename", fake_rename)
|
||||
|
||||
real_copy2 = shutil_mod.copy2
|
||||
|
||||
def fake_copy2(src, dst, *args, **kwargs):
|
||||
d = Path(dst)
|
||||
# 只让"往 /app/module 里灌新文件"这一步失败;快照备份(old.txt)
|
||||
# 与 staging 复制不受影响。
|
||||
if d.parent == app / "module" and d.name == "__marker__.txt":
|
||||
raise OSError(errno.ENOSPC, "No space left on device")
|
||||
return real_copy2(src, dst, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(boot_overlay.shutil, "copy2", fake_copy2)
|
||||
|
||||
applied = boot_overlay.apply_overlay(
|
||||
app_root=app,
|
||||
updates_root=updates,
|
||||
image_version_path=ivp,
|
||||
baseline_lock=lock,
|
||||
pubkey_path=pubkey,
|
||||
)
|
||||
assert applied is False
|
||||
# 旧树必须完整恢复,不能是"删光了但没灌进去"的空壳
|
||||
assert (app / "module" / "old.txt").exists()
|
||||
assert not (app / "module" / "__marker__.txt").exists()
|
||||
# 恢复用的快照目录不残留
|
||||
assert not (app / "module.ab_bak").exists()
|
||||
|
||||
def test_overlay_chowns_replaced_trees_when_root(self, tmp_path, monkeypatch):
|
||||
"""以 root 落地覆盖层后必须把树交还应用用户:受限 UMASK(如 077)下
|
||||
extractall 产物是 600/700 root:root,ab 读不了 → 启动崩溃循环。"""
|
||||
import pwd
|
||||
import subprocess
|
||||
|
||||
import boot_overlay
|
||||
|
||||
app, updates, ivp, lock, pubkey = self._seed(tmp_path, "3.4.0")
|
||||
|
||||
monkeypatch.setattr(boot_overlay.os, "geteuid", lambda: 0, raising=False)
|
||||
monkeypatch.setattr(pwd, "getpwnam", lambda name: object())
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
calls.append([str(c) for c in cmd])
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
applied = boot_overlay.apply_overlay(
|
||||
app_root=app,
|
||||
updates_root=updates,
|
||||
image_version_path=ivp,
|
||||
baseline_lock=lock,
|
||||
pubkey_path=pubkey,
|
||||
)
|
||||
assert applied is True
|
||||
chown_targets = {
|
||||
cmd[-1] for cmd in calls if cmd[:3] == ["chown", "-R", "ab:ab"]
|
||||
}
|
||||
assert str(app / "module") in chown_targets
|
||||
assert str(app / "dist") in chown_targets
|
||||
assert str(app / ".venv") in chown_targets
|
||||
|
||||
def test_exdev_restore_also_chowns_restored_tree_when_root(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""EXDEV 部分复制失败后恢复的旧树同样要交还 ab:以 root 复制回来的
|
||||
文件在受限 UMASK 下可能 600 root:root,不 chown 则恢复了也读不了。"""
|
||||
import errno
|
||||
import os as os_mod
|
||||
import pwd
|
||||
import shutil as shutil_mod
|
||||
import subprocess
|
||||
|
||||
import boot_overlay
|
||||
|
||||
app, updates, ivp, lock, pubkey = self._seed(tmp_path, "3.4.0")
|
||||
|
||||
real_rename = os_mod.rename
|
||||
|
||||
def fake_rename(a, b):
|
||||
if str(a).endswith(("module", "dist")):
|
||||
raise OSError(errno.EXDEV, "Cross-device link")
|
||||
return real_rename(a, b)
|
||||
|
||||
monkeypatch.setattr(boot_overlay.os, "rename", fake_rename)
|
||||
|
||||
real_copy2 = shutil_mod.copy2
|
||||
|
||||
def fake_copy2(src, dst, *args, **kwargs):
|
||||
d = Path(dst)
|
||||
if d.parent == app / "module" and d.name == "__marker__.txt":
|
||||
raise OSError(errno.ENOSPC, "No space left on device")
|
||||
return real_copy2(src, dst, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(boot_overlay.shutil, "copy2", fake_copy2)
|
||||
monkeypatch.setattr(boot_overlay.os, "geteuid", lambda: 0, raising=False)
|
||||
monkeypatch.setattr(pwd, "getpwnam", lambda name: object())
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
calls.append([str(c) for c in cmd])
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
applied = boot_overlay.apply_overlay(
|
||||
app_root=app,
|
||||
updates_root=updates,
|
||||
image_version_path=ivp,
|
||||
baseline_lock=lock,
|
||||
pubkey_path=pubkey,
|
||||
)
|
||||
assert applied is False
|
||||
assert (app / "module" / "old.txt").exists()
|
||||
chown_targets = {
|
||||
cmd[-1] for cmd in calls if cmd[:3] == ["chown", "-R", "ab:ab"]
|
||||
}
|
||||
assert str(app / "module") in chown_targets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API contract: path + verb + auth-gated
|
||||
|
||||
@@ -43,8 +43,12 @@ while true; do
|
||||
if [ "$(cat "${own_marker}" 2>/dev/null)" != "${want}" ] ||
|
||||
[ "$(stat -c '%u:%g' /app/data 2>/dev/null)" != "${want}" ] ||
|
||||
[ "$(stat -c '%u:%g' /app/config 2>/dev/null)" != "${want}" ]; then
|
||||
echo "${want}" >"${own_marker}"
|
||||
chown ab:ab -R /app/data /app/config
|
||||
# 先 chown、后写 marker:中途被打断(停容器/崩溃)或部分失败
|
||||
# (NAS/root-squash 报错)时下次启动必须重跑树遍历,否则半途的
|
||||
# 属主状态被记成"已完成"、永不自愈。
|
||||
if chown ab:ab -R /app/data /app/config; then
|
||||
echo "${want}" >"${own_marker}"
|
||||
fi
|
||||
fi
|
||||
|
||||
su-exec "${PUID}:${PGID}" python main.py &
|
||||
|
||||
Reference in New Issue
Block a user