diff --git a/backend/app/desktop.py b/backend/app/desktop.py index 6e9e00f7..a2a4006e 100644 --- a/backend/app/desktop.py +++ b/backend/app/desktop.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 3488effe..1ef54036 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/tests/test_desktop_startup.py b/backend/tests/test_desktop_startup.py index 8ca901aa..1bcb97da 100644 --- a/backend/tests/test_desktop_startup.py +++ b/backend/tests/test_desktop_startup.py @@ -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() diff --git a/backend/tests/test_main_mining_lifespan.py b/backend/tests/test_main_mining_lifespan.py index 8b6e9e0b..4181649c 100644 --- a/backend/tests/test_main_mining_lifespan.py +++ b/backend/tests/test_main_mining_lifespan.py @@ -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