mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
复现的原始问题: 旧实例挂死在 shutdown (热重载撞上挖掘运行) 或 worker
成为孤儿后, `.mining_process.lock` 被无限期占用, 后续所有后端实例
在 lifespan 抢锁时直接启动失败, 用户只能对着笼统报错手动找进程。
- 锁文件记录持有者 {pid, started_at, role, host} (flock 仍是唯一
事实, 内容只是诊断线索; 旧格式 "0" 兼容, 视为持有者未知);
新增非阻塞探测, 无需抢锁即可判断是否被持有。
- 撞锁降级启动: lifespan 抢锁失败不再上抛, 面板照常可用; 挖掘
启动入口 (手动/自动/周度调度) 返回 409 并指引到挖掘页; 桌面版
预检从「秒退失败页」改为留日志继续启动; 守护复验不受影响。
- 挖掘页锁占用横幅: 显示持有者 pid/角色/挖掘存储静默时长; 满足
陈旧判定时可一键「强制停止并接管」(SIGTERM→SIGKILL→当前后端
获取锁, 挖掘入口立即恢复)。
- 陈旧判定全部 fail-closed: 持有者信息可读、进程存活且 pid 未被
复用 (POSIX 比对进程实际启动时间)、无进行中的挖掘运行、挖掘
存储 10 分钟内无写入 — 任一不满足即拒绝并给出原因。不做启动
时自动清理, 终止进程必须人工确认。
测试: 新增 test_mining_lock_admin (含真实外部持锁进程的强停回路与
五类拒绝路径), 重写 lifespan 降级契约与桌面预检继续启动契约;
后端全量 3239 通过, 前端 308 通过, ruff 零新增; 沙盒 E2E 完整
复刻「持锁→降级→强停→接管→恢复」事故场景通过。
Signed-off-by: shy3130 <shy3130@qq.com>
137 lines
4.6 KiB
Python
137 lines
4.6 KiB
Python
"""desktop.py 启动失败路径测试。
|
|
|
|
复现的原始问题 (2026-10-07): panel 模式/残留后端占住 data_dir/.mining_process.lock,
|
|
桌面版 lifespan 抢锁失败被 uvicorn 吞掉, 主线程傻等 150s 后只显示笼统的
|
|
"启动超时", 真因不可见。修复 = 预检秒失败 + 等待循环感知后端线程退出 +
|
|
失败页透传原因。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import app.desktop as desktop
|
|
from app.config import settings
|
|
from app.desktop import (
|
|
_failed_html,
|
|
_precheck_mining_lock,
|
|
_wait_for_server,
|
|
)
|
|
from app.services.mining_process_lock import MiningProcessLock
|
|
|
|
|
|
@pytest.fixture()
|
|
def data_dir(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "data_dir", tmp_path, raising=False)
|
|
return tmp_path
|
|
|
|
|
|
# ---------- _failed_html ----------
|
|
|
|
def test_failed_html_timeout_keeps_original_copy():
|
|
html_text = _failed_html(None)
|
|
assert "启动超时" in html_text
|
|
assert "后端未在预期时间内就绪" in html_text
|
|
|
|
|
|
def test_failed_html_shows_reason_with_title_change():
|
|
html_text = _failed_html("另一个后端实例正在占用数据目录")
|
|
assert "启动失败" in html_text
|
|
assert "另一个后端实例正在占用数据目录" in html_text
|
|
|
|
|
|
def test_failed_html_escapes_reason_text():
|
|
html_text = _failed_html("<script>alert(1)</script>")
|
|
assert "<script>" not in html_text
|
|
assert "<script>" in html_text
|
|
|
|
|
|
def test_failed_html_renders_newlines():
|
|
html_text = _failed_html("第一行\n第二行")
|
|
assert "第一行<br>第二行" in html_text
|
|
|
|
|
|
# ---------- _wait_for_server ----------
|
|
|
|
def test_wait_for_server_returns_immediately_when_backend_thread_dead():
|
|
"""后端线程已退出 (done_event 置位) 时不得等满 timeout —— 把启动失败伪装成超时的根源。"""
|
|
done = threading.Event()
|
|
done.set()
|
|
start = time.monotonic()
|
|
assert _wait_for_server(port=59999, timeout=10.0, done_event=done) is False
|
|
assert time.monotonic() - start < 1.0
|
|
|
|
|
|
def test_wait_for_server_times_out_when_alive_but_not_ready():
|
|
"""后端线程活着但 health 不通: 等满 timeout 返回 False (原行为不变)。"""
|
|
done = threading.Event()
|
|
start = time.monotonic()
|
|
assert _wait_for_server(port=59999, timeout=0.6, done_event=done) is False
|
|
assert time.monotonic() - start >= 0.5
|
|
|
|
|
|
# ---------- _precheck_mining_lock ----------
|
|
|
|
def test_precheck_mining_lock_passes_when_free(data_dir):
|
|
assert _precheck_mining_lock() is None
|
|
|
|
|
|
def test_precheck_mining_lock_pass_releases_lock(data_dir):
|
|
"""预检通过后必须释放 —— 否则预检自己挡死 lifespan。"""
|
|
assert _precheck_mining_lock() is None
|
|
holder = MiningProcessLock(data_dir)
|
|
holder.acquire() # 仍能抢到 = 预检确实释放了
|
|
holder.release()
|
|
|
|
|
|
def test_precheck_mining_lock_detects_conflict(data_dir):
|
|
holder = MiningProcessLock(data_dir)
|
|
holder.acquire()
|
|
reason = _precheck_mining_lock()
|
|
assert reason is not None
|
|
# 失败页要给出可操作的下一步, 不是一句"无法启动"
|
|
assert "结束残留的 python" in reason
|
|
holder.release()
|
|
assert _precheck_mining_lock() is None
|
|
|
|
|
|
# ---------- main() 预检冲突路径 ----------
|
|
|
|
def test_main_proceeds_when_mining_lock_precheck_conflicts(
|
|
tmp_path, monkeypatch, caplog
|
|
):
|
|
"""预检撞锁只降级: 留 WARNING 日志并继续启动, 不再提前退出失败页。
|
|
|
|
lifespan 会以降级模式起面板 (挖掘入口 409, 挖掘页提供强制释放),
|
|
桌面版必须照常开窗, 否则用户没有入口处理锁冲突。
|
|
"""
|
|
import logging
|
|
|
|
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: "另一个后端实例正在占用数据目录"
|
|
)
|
|
monkeypatch.setattr(desktop, "_find_free_port", lambda base: 59998)
|
|
monkeypatch.setattr(
|
|
desktop, "_wait_for_server",
|
|
lambda port, timeout, done_event: True,
|
|
)
|
|
opened = []
|
|
monkeypatch.setattr(desktop, "_open_window", lambda url: opened.append(url))
|
|
|
|
def _fake_uvmicorn(port, ready):
|
|
ready.set()
|
|
|
|
monkeypatch.setattr(desktop, "_run_uvmicorn", _fake_uvmicorn)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="app.desktop"):
|
|
assert desktop.main() == 0
|
|
|
|
assert opened and opened[0].startswith("http://127.0.0.1:")
|
|
assert "启动预检" in caplog.text
|
|
assert not (tmp_path / ".desktop.lock").exists()
|