fix(desktop): 失败页排查链路收尾 — 预检窗口兜底, 启动失败真因落盘 desktop.log

PR #497 复审建议落地:
- main() 预检冲突路径在 try/finally 之外, _open_static_window 自身失败
  (如缺 webview 依赖) 会裸穿 main(): console=False 闪退无反馈且
  .desktop.lock 跳过释放 → 补兜底, 锁必释放、退出码 1
- uvicorn log_config=None: uvicorn.* 默认独立 console handler 且
  propagate=False, windowed 模式 stderr 已被守护替换, lifespan 启动失败
  到不了 desktop.log (失败页承诺的排查入口) → 走 root FileHandler 落盘
- lifespan 抢挖掘锁失败先 logger.error(含 traceback) 再 raise, 不依赖
  uvicorn 版本的日志行为

测试: uv run --extra dev pytest tests/test_desktop_startup.py
tests/test_main_mining_lifespan.py -q → 13 passed; 全量 tests -q →
2995 passed, 7 skipped, 1 xfailed; ruff 改动前后均 19 条历史遗留, 零新增
This commit is contained in:
shy3130
2026-10-23 14:13:44 +08:00
parent 1fa7e84f45
commit c286af7040
4 changed files with 83 additions and 3 deletions
+12 -1
View File
@@ -300,6 +300,11 @@ def _run_uvmicorn(port: int, ready_event: threading.Event) -> None:
log_level="info",
access_log=False, # 桌面版不需要访问日志
loop="auto",
# log_config=None: uvicorn 默认给 uvicorn.* 挂独立 console handler 且
# propagate=False, windowed 模式下 stderr 已被守护替换, lifespan 启动
# 失败的 "Application startup failed" 永远到不了 desktop.log (失败页
# 承诺的排查入口)。置 None 让 uvicorn 日志走 root 的 FileHandler 落盘。
log_config=None,
)
server = uvicorn.Server(config)
@@ -416,7 +421,13 @@ def main() -> int:
conflict = _precheck_mining_lock()
if conflict is not None:
logger.error("启动预检: %s", conflict.replace("\n", " "))
_open_static_window(_failed_html(conflict))
try:
_open_static_window(_failed_html(conflict))
except Exception:
# 失败页自身也打不开 (如缺 webview 依赖) 时不能让异常裸穿 main():
# console=False 下进程闪退无反馈, 且本路径在 try/finally 之外,
# 单实例锁会跳过释放
logger.exception("启动失败页窗口打开失败")
_release_single_instance()
return 1
+8 -2
View File
@@ -57,7 +57,7 @@ from app.extensions.loader import (
)
from app.jobs import daily_pipeline
from app.services.matrix_prewarm_owner import MatrixCachePrewarmOwner
from app.services.mining_process_lock import MiningProcessLock
from app.services.mining_process_lock import MiningProcessLock, MiningProcessLockError
from app.services.quote_service import QuoteService
from app.tickflow import client as tf_client
from app.tickflow.policy import detect_capabilities
@@ -493,7 +493,13 @@ async def _application_lifespan(app: FastAPI):
@asynccontextmanager
async def lifespan(app: FastAPI):
mining_process_lock = MiningProcessLock(settings.data_dir)
mining_process_lock.acquire()
try:
mining_process_lock.acquire()
except MiningProcessLockError:
# uvicorn 吞掉 lifespan 异常只留一行 startup failed; 桌面版失败页指向
# data/desktop.log, 先把真因 (含 traceback) 落盘, 不依赖 uvicorn 的日志行为
logger.error("挖掘锁被其他后端实例占用, lifespan 启动失败", exc_info=True)
raise
try:
async with _application_lifespan(app):
yield
+30
View File
@@ -12,6 +12,7 @@ import time
import pytest
import app.desktop as desktop
from app.config import settings
from app.desktop import (
_failed_html,
@@ -94,3 +95,32 @@ def test_precheck_mining_lock_detects_conflict(data_dir):
assert "结束残留的 python" in reason
holder.release()
assert _precheck_mining_lock() is None
# ---------- main() 预检冲突路径 ----------
def test_main_releases_lock_when_failure_window_crashes(
tmp_path, monkeypatch, caplog
):
"""预检冲突 + 失败页窗口自身崩溃 (如缺 webview 依赖): 异常不得裸穿 main()。
该路径在 main() 的 try/finally 之外, 不兜底会导致 console=False 闪退无反馈、
.desktop.lock 跳过释放。断言: 记录异常日志、单实例锁仍释放、退出码 1。
"""
monkeypatch.setattr(settings, "data_dir", tmp_path, raising=False)
monkeypatch.setattr(desktop, "_guard_streams", lambda: None)
monkeypatch.setattr(desktop, "_setup_logging", lambda: None)
monkeypatch.setattr(desktop, "_precheck_mining_lock", lambda: "冲突原因")
def _boom(_html_text):
raise RuntimeError("webview unavailable")
monkeypatch.setattr(desktop, "_open_static_window", _boom)
import logging
with caplog.at_level(logging.ERROR, logger="app.desktop"):
assert desktop.main() == 1
assert "启动失败页窗口打开失败" in caplog.text
assert not (tmp_path / ".desktop.lock").exists()
@@ -81,3 +81,36 @@ def test_lifespan_releases_lock_when_application_shutdown_raises(monkeypatch) ->
asyncio.run(exercise())
assert events == ["acquired", "released"]
def test_lifespan_logs_true_cause_when_lock_held(monkeypatch, caplog) -> None:
"""抢锁失败: 先落 ERROR 日志 (桌面版失败页指向 desktop.log 的排查依据) 再上抛。
uvicorn 会吞掉 lifespan 异常只留一行笼统的 startup failed, 真因必须由
app 自己先写日志, 否则用户按失败页指引打开 desktop.log 查无对证。
"""
from app.services.mining_process_lock import MiningProcessLockError
class LockStub:
def __init__(self, data_dir) -> None:
del data_dir
def acquire(self) -> None:
raise MiningProcessLockError("another process owns mining")
def release(self) -> None:
raise AssertionError("acquire 失败后不应调用 release")
monkeypatch.setattr(main_module, "MiningProcessLock", LockStub)
async def exercise() -> None:
async with main_module.lifespan(SimpleNamespace()):
pass
with (
caplog.at_level("ERROR", logger="app.main"),
pytest.raises(MiningProcessLockError),
):
asyncio.run(exercise())
assert "挖掘锁" in caplog.text