From 785f11af0e5baaada94c3fe51c31371afc7f6761 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:05:14 +0800 Subject: [PATCH] fix: offload log zip generation (#5948) --- app/api/endpoints/system.py | 35 ++++++++++++++++++++----------- tests/test_system_log_download.py | 29 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index 20b3b357..33995169 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -10,6 +10,7 @@ from typing import Any, Optional, Union, Annotated from urllib.parse import urljoin, urlparse import aiofiles +import anyio import pillow_avif # noqa 用于自动注册AVIF支持 from anyio import Path as AsyncPath from app.helper.sites import SitesHelper # noqa # noqa @@ -444,35 +445,45 @@ async def _build_log_zip_response(name: str) -> StreamingResponse: `name` 到固定目录的映射约束。zip 内使用日志根目录相对路径,便于区分 主程序日志与插件日志。 """ + zip_data, zip_stem = await anyio.to_thread.run_sync(_build_log_zip_data, name) + headers = { + "Content-Disposition": f'attachment; filename="{zip_stem}.zip"' + } + return StreamingResponse( + iter([zip_data]), + media_type="application/zip", + headers=headers, + ) + + +def _build_log_zip_data(name: str) -> tuple[bytes, str]: + """ + 同步生成日志 zip 内容和文件名前缀。 + + 日志收集、路径解析、文件读取和压缩都属于可能阻塞的本地 I/O;调用方需要 + 将本函数放到 worker thread 中执行,避免日志下载占用 ASGI 事件循环。 + """ log_files = _collect_named_log_files(name) if not log_files: raise HTTPException(status_code=404, detail="Not Found") log_root = settings.LOG_PATH - async_log_root = AsyncPath(log_root) zip_buffer = io.BytesIO() filename_time = datetime.now().strftime("%Y%m%d-%H%M%S") safe_name = (name or "logs").strip().lower() or "logs" zip_stem = f"{safe_name}-logs-{filename_time}" with zipfile.ZipFile(zip_buffer, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: for log_file in log_files: - if not await SecurityUtils.async_is_safe_path( - base_path=async_log_root, - user_path=AsyncPath(log_file), + if not SecurityUtils.is_safe_path( + base_path=log_root, + user_path=log_file, ): raise HTTPException(status_code=404, detail="Not Found") arcname = f"{zip_stem}/{log_file.name}" archive.write(log_file, arcname) zip_buffer.seek(0) - headers = { - "Content-Disposition": f'attachment; filename="{zip_stem}.zip"' - } - return StreamingResponse( - iter([zip_buffer.getvalue()]), - media_type="application/zip", - headers=headers, - ) + return zip_buffer.getvalue(), zip_stem def _validate_nettest_url(url: str) -> Optional[str]: diff --git a/tests/test_system_log_download.py b/tests/test_system_log_download.py index 79ac4961..d214b88d 100644 --- a/tests/test_system_log_download.py +++ b/tests/test_system_log_download.py @@ -2,6 +2,7 @@ import asyncio import io +import threading import zipfile from pathlib import Path from types import SimpleNamespace @@ -111,6 +112,34 @@ def test_download_plugin_logs_packages_plugin_files_only(isolated_log_path): assert "moviepilot.log" not in names +def test_download_log_zip_generation_runs_outside_event_loop_thread(monkeypatch, isolated_log_path): + """日志压缩 I/O 必须离开事件循环线程执行,避免大日志下载阻塞其他请求。""" + (isolated_log_path / "moviepilot.log").write_text("current", encoding="utf-8") + event_loop_thread = threading.current_thread().name + write_threads = [] + original_write = zipfile.ZipFile.write + + def capture_write_thread(self, filename, arcname=None, compress_type=None, compresslevel=None): + """记录实际 zip 写入线程,并保持原始 ZipFile.write 行为。""" + write_threads.append(threading.current_thread().name) + return original_write( + self, + filename, + arcname=arcname, + compress_type=compress_type, + compresslevel=compresslevel, + ) + + monkeypatch.setattr(zipfile.ZipFile, "write", capture_write_thread) + + response = asyncio.run(system_endpoint.download_logging(name="moviepilot", _=SimpleNamespace())) + body = asyncio.run(_read_streaming_body(response)) + + assert body + assert write_threads + assert all(thread_name != event_loop_thread for thread_name in write_threads) + + async def _read_streaming_body(response) -> bytes: """读取 StreamingResponse 内容,便于断言 zip 文件条目。""" return b"".join([chunk async for chunk in response.body_iterator])