mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(desktop): 修复单实例锁、窗口显示与重复启动
- 使用系统文件锁替代 PID 存活判断,崩溃后由系统释放锁并避免删除重建竞态 - 将早期预检异常统一交给启动错误提示,保证退出时释放已取得的锁 - GUI 就绪后显式显示主窗口,避免继承隐藏启动状态 - 移除安装包中重复的安装后启动条目 补充跨进程单实例锁、模拟 Windows 锁异常、早期启动失败、窗口显示与安装配置测试。 Windows 真机安装与窗口行为未在本机验证。 Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
+50
-38
@@ -15,6 +15,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
@@ -23,13 +24,14 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_APP_NAME = "TSP"
|
||||
_BASE_PORT = 3018
|
||||
_PORT_PROBE_RANGE = 50 # 从 3018 起最多试 50 个端口
|
||||
_instance_lock: BinaryIO | None = None
|
||||
|
||||
|
||||
def _failed_html(reason: str | None = None) -> str:
|
||||
@@ -111,38 +113,52 @@ def _precheck_mining_lock() -> str | None:
|
||||
def _acquire_single_instance() -> bool:
|
||||
"""单实例锁。已运行返回 False (本进程应退出), 否则 True。
|
||||
|
||||
用 data_dir/.desktop.lock 文件锁实现。跨进程, 文件存在即视为已运行
|
||||
(简单可靠; 不引入 msvcrt/fcntl 平台差异)。
|
||||
文件内容仅供诊断, 实际以系统文件锁为准。进程崩溃后系统会自动释放,
|
||||
避免残留 PID 被其他进程复用时永远误判为「已运行」。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
global _instance_lock
|
||||
if _instance_lock is not None:
|
||||
return False
|
||||
|
||||
lock_path = settings.data_dir / ".desktop.lock"
|
||||
if lock_path.exists():
|
||||
# 软检测: 写入进程 PID, 若该 PID 已不存在则视为残留锁, 允许接管
|
||||
stream = lock_path.open("a+b")
|
||||
try:
|
||||
os.set_inheritable(stream.fileno(), False)
|
||||
try:
|
||||
pid_str = lock_path.read_text(encoding="utf-8").strip()
|
||||
pid = int(pid_str) if pid_str.isdigit() else None
|
||||
except Exception: # noqa: BLE001
|
||||
pid = None
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
if pid is not None and _pid_alive(pid):
|
||||
logger.warning("检测到已有实例运行 (PID %d), 本进程退出", pid)
|
||||
stream.seek(0)
|
||||
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
||||
raise
|
||||
logger.warning("检测到已有桌面实例持锁, 本进程退出")
|
||||
return False
|
||||
# 残留锁: 清理后继续
|
||||
logger.info("清理残留单实例锁 (PID %s 已不存在)", pid)
|
||||
|
||||
lock_path.write_text(str(_current_pid()), encoding="utf-8")
|
||||
return True
|
||||
stream.seek(0)
|
||||
stream.truncate()
|
||||
stream.write(str(_current_pid()).encode("ascii"))
|
||||
stream.flush()
|
||||
_instance_lock = stream
|
||||
return True
|
||||
finally:
|
||||
if _instance_lock is not stream:
|
||||
stream.close()
|
||||
|
||||
|
||||
def _release_single_instance() -> None:
|
||||
from app.config import settings
|
||||
|
||||
lock_path = settings.data_dir / ".desktop.lock"
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
global _instance_lock
|
||||
stream, _instance_lock = _instance_lock, None
|
||||
if stream is not None:
|
||||
# 不 unlink: 删除再重建会让并发启动的进程锁住不同文件, 同时通过检查。
|
||||
stream.close()
|
||||
|
||||
|
||||
def _guard_streams() -> None:
|
||||
@@ -351,8 +367,8 @@ def _open_window(url: str) -> None:
|
||||
# 桌面版固定单窗口, 禁用外部浏览器跳转
|
||||
confirm_close=False,
|
||||
)
|
||||
# pywebview 会阻塞主线程直到窗口关闭
|
||||
webview.start(debug=False)
|
||||
# 继承隐藏启动标志时首次显示可能被抑制, GUI 就绪后再显式显示 (同失败页)。
|
||||
webview.start(lambda: window.show(), debug=False)
|
||||
|
||||
|
||||
def _open_static_window(html_text: str) -> None:
|
||||
@@ -391,24 +407,19 @@ def main() -> int:
|
||||
|
||||
return run_worker()
|
||||
|
||||
instance_acquired = False
|
||||
try:
|
||||
_ensure_data_dir_writable()
|
||||
except Exception:
|
||||
# 数据目录不可写是致命错误, 无法继续
|
||||
return 1
|
||||
|
||||
# 单实例: 已运行则退出
|
||||
if not _acquire_single_instance():
|
||||
return 0
|
||||
if not _acquire_single_instance():
|
||||
return 0
|
||||
instance_acquired = True
|
||||
|
||||
# 挖掘锁预检: 撞锁只降级不再拦截启动 — lifespan 会以降级模式起面板
|
||||
# (挖掘入口 409), 用户可在挖掘页查看持有者并强制释放陈旧持有者;
|
||||
# 这里只提前留一条日志方便排查。
|
||||
conflict = _precheck_mining_lock()
|
||||
if conflict is not None:
|
||||
logger.warning("启动预检: %s", conflict.replace("\n", " "))
|
||||
# 撞挖掘锁只降级, 其他预检异常必须同样落盘、弹框并释放桌面锁。
|
||||
conflict = _precheck_mining_lock()
|
||||
if conflict is not None:
|
||||
logger.warning("启动预检: %s", conflict.replace("\n", " "))
|
||||
|
||||
try:
|
||||
port = _find_free_port(_BASE_PORT)
|
||||
logger.info("桌面版后端将监听 127.0.0.1:%d", port)
|
||||
|
||||
@@ -448,7 +459,8 @@ def main() -> int:
|
||||
_show_crash("TSP 启动失败", traceback.format_exc())
|
||||
return 1
|
||||
finally:
|
||||
_release_single_instance()
|
||||
if instance_acquired:
|
||||
_release_single_instance()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -25,7 +29,65 @@ 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
|
||||
yield tmp_path
|
||||
desktop._release_single_instance()
|
||||
|
||||
|
||||
def test_single_instance_ignores_unlocked_file_with_live_pid(data_dir):
|
||||
"""崩溃残留的 PID 被复用, 没有实际持锁者时仍须允许启动。"""
|
||||
(data_dir / ".desktop.lock").write_text(str(os.getpid()), encoding="utf-8")
|
||||
assert desktop._acquire_single_instance() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lock_error", [None, errno.EACCES, errno.EIO])
|
||||
def test_single_instance_windows_lock_errors(data_dir, monkeypatch, lock_error):
|
||||
"""锁冲突才作为已有实例处理, 磁盘/系统错误须交给启动错误提示。"""
|
||||
opened = []
|
||||
|
||||
def lock(fd, mode, count):
|
||||
assert os.get_inheritable(fd) is False
|
||||
opened.append(fd)
|
||||
if lock_error is not None:
|
||||
raise OSError(lock_error, "locking failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
desktop, "os", SimpleNamespace(name="nt", set_inheritable=os.set_inheritable)
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", SimpleNamespace(LK_NBLCK=2, locking=lock))
|
||||
|
||||
if lock_error == errno.EIO:
|
||||
with pytest.raises(OSError, match="locking failed"):
|
||||
desktop._acquire_single_instance()
|
||||
else:
|
||||
assert desktop._acquire_single_instance() is (lock_error is None)
|
||||
desktop._release_single_instance()
|
||||
assert len(opened) == 1
|
||||
with pytest.raises(OSError):
|
||||
os.fstat(opened[0]) # 成功、冲突、系统错误三条路径都必须关闭句柄。
|
||||
|
||||
|
||||
def test_single_instance_rejects_other_process_then_allows_after_release(data_dir):
|
||||
import subprocess
|
||||
|
||||
script = (
|
||||
"from app.desktop import _acquire_single_instance; "
|
||||
"print(_acquire_single_instance())"
|
||||
)
|
||||
|
||||
def acquire_in_child():
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
env={**os.environ, "DATA_DIR": str(data_dir)},
|
||||
capture_output=True, text=True, timeout=15, check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
assert desktop._acquire_single_instance() is True
|
||||
assert acquire_in_child() == "False"
|
||||
desktop._release_single_instance()
|
||||
assert acquire_in_child() == "True"
|
||||
# 子进程没有手动释放; 进程结束仍须自动解锁。
|
||||
assert desktop._acquire_single_instance() is True
|
||||
|
||||
|
||||
# ---------- _failed_html ----------
|
||||
@@ -133,7 +195,33 @@ def test_main_proceeds_when_mining_lock_precheck_conflicts(
|
||||
|
||||
assert opened and opened[0].startswith("http://127.0.0.1:")
|
||||
assert "启动预检" in caplog.text
|
||||
assert not (tmp_path / ".desktop.lock").exists()
|
||||
assert desktop._acquire_single_instance() is True
|
||||
desktop._release_single_instance()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stage", ["data_dir", "single_instance", "mining_precheck"])
|
||||
def test_main_reports_early_startup_errors(data_dir, monkeypatch, stage):
|
||||
monkeypatch.setattr(desktop, "_guard_streams", lambda: None)
|
||||
monkeypatch.setattr(desktop, "_setup_logging", lambda: None)
|
||||
monkeypatch.delenv("TDX_PROBE_WORKER", raising=False)
|
||||
errors = []
|
||||
monkeypatch.setattr(desktop, "_show_crash", lambda *args: errors.append(args))
|
||||
target = {
|
||||
"data_dir": "_ensure_data_dir_writable",
|
||||
"single_instance": "_acquire_single_instance",
|
||||
"mining_precheck": "_precheck_mining_lock",
|
||||
}[stage]
|
||||
|
||||
def fail():
|
||||
raise PermissionError(f"startup failed at {stage}")
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(desktop, target, fail)
|
||||
assert desktop.main() == 1
|
||||
assert len(errors) == 1
|
||||
assert "TSP 启动失败" in errors[0][0]
|
||||
assert f"startup failed at {stage}" in errors[0][1]
|
||||
assert desktop._acquire_single_instance() is True
|
||||
|
||||
|
||||
# ---------- _open_window: pywebview 下载启用 ----------
|
||||
@@ -142,7 +230,6 @@ def test_open_window_enables_downloads_before_start(monkeypatch):
|
||||
"""pywebview 5.0+ 默认取消所有文件下载 (Edge WebView2 / WKWebView 一致) —
|
||||
必须在 webview.start() 前显式开启 ALLOW_DOWNLOADS, 否则配置备份导出、
|
||||
回测 CSV 等 <a download> 直链在桌面版点击无反应 (2026-10-27 用户反馈)。"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
calls: list[str] = []
|
||||
@@ -162,3 +249,18 @@ def test_open_window_enables_downloads_before_start(monkeypatch):
|
||||
|
||||
assert fake.settings.get("ALLOW_DOWNLOADS") is True
|
||||
assert calls == ["set:ALLOW_DOWNLOADS=True", "create_window", "start"]
|
||||
|
||||
|
||||
def test_open_window_explicitly_shows_window_after_gui_start(monkeypatch):
|
||||
"""Windows 继承 SW_HIDE 时, GUI 就绪后应显式显示主窗口。"""
|
||||
shown = []
|
||||
window = SimpleNamespace(show=lambda: shown.append(True))
|
||||
fake = SimpleNamespace(
|
||||
settings={},
|
||||
create_window=lambda *args, **kwargs: window,
|
||||
start=lambda func=None, **kwargs: func() if func else None,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "webview", fake)
|
||||
|
||||
desktop._open_window("http://127.0.0.1:3018")
|
||||
assert shown == [True]
|
||||
|
||||
@@ -37,3 +37,14 @@ def test_spec_declares_builtin_plugin_tdx():
|
||||
assert "app.plugins.tdxquant.client" in SPEC_CODE
|
||||
assert "app.plugins.tdxquant.probe" in SPEC_CODE
|
||||
assert '"plugins" / "tdxquant" / "plugin.yaml"' in SPEC_CODE # 源路径必须指向现存目录
|
||||
|
||||
|
||||
def test_installer_launches_desktop_only_once():
|
||||
"""重复 postinstall 条目会同时拉起两个桌面后端。"""
|
||||
text = (REPO_ROOT / "packaging" / "tsp.iss").read_text(encoding="utf-8")
|
||||
section = text.split("[Run]", 1)[1].split("[UninstallRun]", 1)[0]
|
||||
launches = [
|
||||
line for line in section.splitlines()
|
||||
if line.startswith("Filename:") and "{#MyAppExeName}" in line
|
||||
]
|
||||
assert len(launches) == 1
|
||||
|
||||
Reference in New Issue
Block a user