diff --git a/MEMORY.md b/MEMORY.md index a0d79d3..dd8c74a 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -242,7 +242,8 @@ - `WINE_DEFAULT_TIMEOUT_SECONDS` / `WINE_MAX_TIMEOUT_SECONDS`:默认与最大执行超时。 - 后端执行必须使用 `asyncio.create_subprocess_exec` 参数数组,不走 shell;EXE 路径与工作目录必须限制在 `WINE_ALLOWED_ROOT` 下。 - 实时日志通过 `StreamingResponse` + SSE 事件输出;前端使用 `fetchWithAuth` 读取 `ReadableStream`,避免原生 `EventSource` 无法携带现有 Bearer Token 的鉴权问题。 -- `api/Dockerfile` 构建出的共享后端镜像已内置 `wine`;`api/celery-worker/celery-beat/flower` 当前都复用该镜像,若需切换二进制仍可通过 `WINE_BINARY_PATH` 覆盖。 +- `api/Dockerfile` 构建出的共享后端镜像已内置 `wine + wine32:i386`;`api/celery-worker/celery-beat/flower` 当前都复用该镜像,若需切换二进制仍可通过 `WINE_BINARY_PATH` 覆盖。 +- Wine / ATP 的状态探测口径中,若 `wine --version` 输出包含 `wine32 is missing`,必须标记为不可用,避免前端把半可用环境误显示为“可用”。 ## 前端菜单交互口径(2026-04-19) diff --git a/api/Dockerfile b/api/Dockerfile index ecbb973..ebe1efe 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -13,11 +13,14 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PIP_DEFAULT_TIMEOUT=${PIP_DEFAULT_TIMEOUT} ENV PIP_RETRIES=${PIP_RETRIES} -RUN apt-get update \ +RUN dpkg --add-architecture i386 \ + && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ libexpat1 \ wine \ + wine32:i386 \ && command -v wine >/dev/null \ + && dpkg-query -W -f='${Status}' wine32:i386 | grep -q "install ok installed" \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ diff --git a/api/app/services/atp_model_service.py b/api/app/services/atp_model_service.py index db8943c..09addee 100644 --- a/api/app/services/atp_model_service.py +++ b/api/app/services/atp_model_service.py @@ -35,6 +35,7 @@ from ..schemas.atp_model import ( AtpSimulationRunSummary, ) from .push_service import publish_topic +from .wine_probe import probe_wine_binary settings = get_settings() @@ -164,7 +165,8 @@ def _resolve_wine_engine_executable() -> tuple[str | None, str | None, str | Non if not configured.exists() or not configured.is_file(): return wine_binary, None, f"ATP engine executable not found: {configured}" - return wine_binary, str(configured), None + probe = probe_wine_binary(wine_binary) + return wine_binary, str(configured), None if probe.available else (probe.error or "Wine binary unavailable") def _resolve_native_engine_executable() -> tuple[str | None, str | None]: diff --git a/api/app/services/wine_probe.py b/api/app/services/wine_probe.py new file mode 100644 index 0000000..c968854 --- /dev/null +++ b/api/app/services/wine_probe.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import asyncio +import subprocess +from dataclasses import dataclass + + +_WINE32_MISSING_HINTS = ( + "it looks like wine32 is missing", + "wine32 is missing", +) + + +@dataclass(slots=True) +class WineProbeResult: + available: bool + version: str | None + error: str | None + + +def interpret_wine_probe_output(returncode: int, output: bytes | str | None) -> WineProbeResult: + if isinstance(output, bytes): + text = output.decode("utf-8", errors="replace") + else: + text = output or "" + + normalized = text.strip() or None + if normalized: + lowered = normalized.lower() + if any(hint in lowered for hint in _WINE32_MISSING_HINTS): + return WineProbeResult(available=False, version=normalized, error=normalized) + + if returncode != 0: + return WineProbeResult(available=False, version=normalized, error=normalized) + + return WineProbeResult(available=True, version=normalized, error=None) + + +def probe_wine_binary(resolved_binary: str, *, timeout_seconds: int = 10) -> WineProbeResult: + try: + completed = subprocess.run( + [resolved_binary, "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + timeout=timeout_seconds, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return WineProbeResult(available=False, version=None, error=str(exc)) + + return interpret_wine_probe_output(completed.returncode, completed.stdout) + + +async def probe_wine_binary_async(resolved_binary: str, *, timeout_seconds: int = 10) -> WineProbeResult: + try: + process = await asyncio.create_subprocess_exec( + resolved_binary, + "--version", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout_seconds) + except (OSError, asyncio.TimeoutError) as exc: + return WineProbeResult(available=False, version=None, error=str(exc)) + + return interpret_wine_probe_output(process.returncode, output) diff --git a/api/app/services/wine_service.py b/api/app/services/wine_service.py index 874f306..420cd7d 100644 --- a/api/app/services/wine_service.py +++ b/api/app/services/wine_service.py @@ -13,6 +13,7 @@ from fastapi import HTTPException, status from ..core.config import get_settings from ..schemas.wine import WineRunRequest, WineStatusResponse +from .wine_probe import probe_wine_binary_async settings = get_settings() @@ -106,35 +107,16 @@ async def get_wine_status() -> WineStatusResponse: error="Wine binary not found", ) - try: - process = await asyncio.create_subprocess_exec( - resolved, - "--version", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - output, _ = await asyncio.wait_for(process.communicate(), timeout=10) - except (OSError, asyncio.TimeoutError) as exc: - return WineStatusResponse( - wine_binary=binary, - resolved_binary=resolved, - available=False, - allowed_root=allowed_root, - default_timeout_seconds=settings.wine_default_timeout_seconds, - max_timeout_seconds=settings.wine_max_timeout_seconds, - error=str(exc), - ) - - version = output.decode("utf-8", errors="replace").strip() or None + probe = await probe_wine_binary_async(resolved) return WineStatusResponse( wine_binary=binary, resolved_binary=resolved, - available=process.returncode == 0, - version=version, + available=probe.available, + version=probe.version, allowed_root=allowed_root, default_timeout_seconds=settings.wine_default_timeout_seconds, max_timeout_seconds=settings.wine_max_timeout_seconds, - error=None if process.returncode == 0 else version, + error=probe.error, ) @@ -156,6 +138,11 @@ async def stream_wine_run(payload: WineRunRequest) -> AsyncGenerator[str, None]: yield _sse_event("error", {"run_id": run_id, "message": "Wine binary not found"}) return + probe = await probe_wine_binary_async(resolved_binary) + if not probe.available: + yield _sse_event("error", {"run_id": run_id, "message": probe.error or "Wine binary unavailable"}) + return + try: executable = _resolve_executable(payload.exe_path) working_dir = _resolve_working_dir(payload.working_dir, executable) diff --git a/api/tests/test_wine_probe.py b/api/tests/test_wine_probe.py new file mode 100644 index 0000000..6026223 --- /dev/null +++ b/api/tests/test_wine_probe.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from app.services.wine_probe import interpret_wine_probe_output + + +def test_interpret_wine_probe_output_marks_wine32_warning_unavailable() -> None: + output = ( + "it looks like wine32 is missing, you should install it. multiarch needs to be enabled first.\n" + 'as root, please execute "dpkg --add-architecture i386 && apt-get update && apt-get install wine32:i386"\n' + "wine-10.0 (Debian 10.0~repack-6)" + ) + + result = interpret_wine_probe_output(0, output) + + assert result.available is False + assert result.version == output + assert result.error == output + + +def test_interpret_wine_probe_output_accepts_normal_version() -> None: + result = interpret_wine_probe_output(0, "wine-10.0 (Debian 10.0~repack-6)") + + assert result.available is True + assert result.version == "wine-10.0 (Debian 10.0~repack-6)" + assert result.error is None + + +def test_interpret_wine_probe_output_propagates_nonzero_exit() -> None: + result = interpret_wine_probe_output(1, "wine: failed to load") + + assert result.available is False + assert result.version == "wine: failed to load" + assert result.error == "wine: failed to load" diff --git a/memory/2026-06-08.md b/memory/2026-06-08.md index 091b856..da7fa17 100644 --- a/memory/2026-06-08.md +++ b/memory/2026-06-08.md @@ -64,3 +64,31 @@ - 风险与关注点: - 由于部署链路复用同一个后端镜像,本次会同时影响 `api/celery-worker/celery-beat/flower` 的基础镜像体积,而不只是 `celery-worker`。 + +## Work Log - 修正 Wine 缺少 wine32 时的误报(2026-06-08) + +- 背景: + - 当前环境里 `wine --version` 会输出 `it looks like wine32 is missing`,但后端 `/api/v1/wine/status` 之前只按返回码判定,可能把半可用环境误报成“可用”。 + - `ATP` 的 Wine 引擎状态也复用同一 Wine 二进制,存在同类误报风险。 + +- 本次处理: + - `api/app/services/wine_probe.py` + - 新增共享 Wine 探测 helper,统一将 `wine32 is missing` 识别为不可用状态。 + - `api/app/services/wine_service.py` + - Wine 状态接口与测试执行前校验改为复用共享探测 helper。 + - `api/app/services/atp_model_service.py` + - ATP Wine 引擎状态与执行前校验同步复用共享探测 helper。 + - `api/Dockerfile` + - 构建阶段显式开启 `i386` 并安装 `wine32:i386`,避免镜像只装到半套 Wine 运行时。 + - `api/tests/test_wine_probe.py` + - 新增针对 `wine32 is missing` 误报场景的最小单测。 + +- 验证: + - 变更前基线:`python3 -m pytest api/tests/test_fl_analysis_external.py`、`python3 -m pytest api/tests/test_fl_analysis_service.py` 因本地运行环境缺少 `pytest/sqlalchemy` 无法执行。 + - 变更后执行: + - `python3 -m py_compile api/app/services/wine_probe.py api/app/services/wine_service.py api/app/services/atp_model_service.py api/tests/test_wine_probe.py` 通过。 + - `./.venv/bin/python -m pytest api/tests/test_wine_probe.py` 通过(3 passed)。 + - `git diff --check` 通过。 + +- 风险与关注点: + - 当前本地环境仍不具备完整后端依赖,无法直接回归所有 FastAPI/SQLAlchemy 相关测试;本次验证聚焦在 Wine 探测逻辑和语法层面。