From a1301e239a4271327300687fbe7de62efcc012f1 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Fri, 30 Oct 2026 20:43:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E4=BF=AE=E5=A4=8D=E5=8D=95?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E9=94=81=E3=80=81=E7=AA=97=E5=8F=A3=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=B8=8E=E9=87=8D=E5=A4=8D=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用系统文件锁替代 PID 存活判断,崩溃后由系统释放锁并避免删除重建竞态 - 将早期预检异常统一交给启动错误提示,保证退出时释放已取得的锁 - GUI 就绪后显式显示主窗口,避免继承隐藏启动状态 - 移除安装包中重复的安装后启动条目 补充跨进程单实例锁、模拟 Windows 锁异常、早期启动失败、窗口显示与安装配置测试。 Windows 真机安装与窗口行为未在本机验证。 Signed-off-by: shy3130 --- backend/app/desktop.py | 88 ++++++++++++--------- backend/tests/test_desktop_startup.py | 108 +++++++++++++++++++++++++- backend/tests/test_packaging_spec.py | 11 +++ packaging/tsp.iss | 6 -- 4 files changed, 166 insertions(+), 47 deletions(-) diff --git a/backend/app/desktop.py b/backend/app/desktop.py index f06c42be..22db2a29 100644 --- a/backend/app/desktop.py +++ b/backend/app/desktop.py @@ -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__": diff --git a/backend/tests/test_desktop_startup.py b/backend/tests/test_desktop_startup.py index 5a71d42c..b0a9daf2 100644 --- a/backend/tests/test_desktop_startup.py +++ b/backend/tests/test_desktop_startup.py @@ -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 等 直链在桌面版点击无反应 (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] diff --git a/backend/tests/test_packaging_spec.py b/backend/tests/test_packaging_spec.py index 2f80a05f..8831351c 100644 --- a/backend/tests/test_packaging_spec.py +++ b/backend/tests/test_packaging_spec.py @@ -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 diff --git a/packaging/tsp.iss b/packaging/tsp.iss index 5cf62bb7..dc4465f6 100644 --- a/packaging/tsp.iss +++ b/packaging/tsp.iss @@ -99,12 +99,6 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de ; asInvoker 场景行为不变。 Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent shellexec -; 安装完成后启动应用 -; shellexec: 部分机器对 exe 路径存有「以管理员身份运行」兼容标志/策略要求提权, -; CreateProcess 无法弹 UAC 会直接报错误码 740; ShellExecute 遇提权正常弹 UAC, -; asInvoker 场景行为不变。 -Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent shellexec - [UninstallRun] ; 卸载前先关闭正在运行的应用 (否则 exe 被占用删不掉) Filename: "{cmd}"; Parameters: "/C taskkill /F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "KillApp"