[fix]:[FL-48][it looks like wine32 is missing]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
+4
-1
@@ -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 ./
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
probe = await probe_wine_binary_async(resolved)
|
||||
return WineStatusResponse(
|
||||
wine_binary=binary,
|
||||
resolved_binary=resolved,
|
||||
available=False,
|
||||
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=str(exc),
|
||||
)
|
||||
|
||||
version = output.decode("utf-8", errors="replace").strip() or None
|
||||
return WineStatusResponse(
|
||||
wine_binary=binary,
|
||||
resolved_binary=resolved,
|
||||
available=process.returncode == 0,
|
||||
version=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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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 探测逻辑和语法层面。
|
||||
|
||||
Reference in New Issue
Block a user