mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
+12
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user