fix(desktop): 修复单实例锁、窗口显示与重复启动

- 使用系统文件锁替代 PID 存活判断,崩溃后由系统释放锁并避免删除重建竞态
- 将早期预检异常统一交给启动错误提示,保证退出时释放已取得的锁
- GUI 就绪后显式显示主窗口,避免继承隐藏启动状态
- 移除安装包中重复的安装后启动条目

补充跨进程单实例锁、模拟 Windows 锁异常、早期启动失败、窗口显示与安装配置测试。
Windows 真机安装与窗口行为未在本机验证。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-30 20:43:06 +08:00
parent 3f8c0b169e
commit a1301e239a
4 changed files with 166 additions and 47 deletions
+50 -38
View File
@@ -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__":
+105 -3
View File
@@ -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]
+11
View File
@@ -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