mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(desktop): TdxQuant Key 探测打包版失效 — 哨兵重执行 + tdxaidata 入包
- probe: worker 移入主模块, frozen 以「重执行自身 + TDX_PROBE_WORKER 哨兵」 拉起子进程 (sys.executable -c 被 PyInstaller bootloader 无视, 子进程会 重启整个桌面应用 → Key 永远验证失败); dev 保持 python -c, 共用 run_worker - spec: tdxaidata 显式入包 — 后端全部懒导入 Analysis 不跟随; lib/ 跨平台 原生库按 datas 整目录复制 (collect_all 会让 macholib 解析 Linux ELF 崩) - desktop: 单实例 _pid_alive Windows 改 OpenProcess+GetExitCodeProcess (os.kill(pid,0) 在 Windows 是 TerminateProcess, 会误杀在跑实例); main() 哨兵分发探测 worker, 先于单实例/后端/窗口 - 测试: 新增 test_tdxquant_probe.py (8 例); 全量 2692 passed; 打包产物端到端实测探测 API ok, tdxaidata 真实加载连服
This commit is contained in:
+26
-5
@@ -16,6 +16,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
@@ -173,12 +174,25 @@ def _pid_alive(pid: int) -> bool:
|
||||
import os
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows: 0 表示存在, 其它是异常
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
# Windows 的 os.kill 没有 POSIX 探测语义: 除 CTRL_C/CTRL_BREAK 外
|
||||
# 任何信号值 (含 0) 都是 TerminateProcess 无条件杀进程。存活探测
|
||||
# 必须用 OpenProcess + GetExitCodeProcess, 否则单实例检查会误杀
|
||||
# 正在运行的实例 (例如探测子进程重启自身时)。
|
||||
import ctypes
|
||||
|
||||
STILL_ACTIVE = 259
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
exit_code = ctypes.c_ulong()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return False
|
||||
return exit_code.value == STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
else:
|
||||
try:
|
||||
os.kill(pid, 0) # signal 0 = 探测存活, 不实际发信号
|
||||
@@ -297,6 +311,13 @@ def main() -> int:
|
||||
# 放在 basicConfig 之后 (它先建好 root logger 的格式), 这里只追加 handler。
|
||||
_setup_logging()
|
||||
|
||||
# TdxQuant 探测 worker 哨兵 (见 probe.run): 打包版以「重执行自身 + 哨兵」
|
||||
# 拉起独立探测进程, 必须先于单实例锁/后端/窗口等任何桌面启动动作分发。
|
||||
if os.environ.get("TDX_PROBE_WORKER") == "1":
|
||||
from app.plugins.tdxquant.probe import run_worker
|
||||
|
||||
return run_worker()
|
||||
|
||||
try:
|
||||
_ensure_data_dir_writable()
|
||||
except Exception:
|
||||
|
||||
@@ -21,84 +21,92 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROBE_ENV_KEY = "TDX_PROBE_CANDIDATE_KEY"
|
||||
_PROBE_ENV_TMP = "TDX_PROBE_TMP_DIR"
|
||||
# frozen 版哨兵: 桌面版没有可用的 python 解释器 (sys.executable 是应用本身,
|
||||
# -c 会被 PyInstaller bootloader 无视而重启整个桌面应用), 因此以
|
||||
# 「重执行自身 + 本哨兵」拉起 worker, 由 desktop.main 最先分发到 run_worker
|
||||
_PROBE_ENV_WORKER = "TDX_PROBE_WORKER"
|
||||
|
||||
# 子进程入口: 复用 client.materialize_lib 保持与主进程同一套物化/写 token 逻辑
|
||||
_SCRIPT = """
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
key = os.environ.pop("TDX_PROBE_CANDIDATE_KEY", "")
|
||||
tmp = Path(os.environ.pop("TDX_PROBE_TMP_DIR", ""))
|
||||
progress = tmp / "progress.json" if str(tmp) else None
|
||||
# dev 模式子进程入口: 直接调用本模块的 run_worker, 与 frozen 哨兵共用同一份实现
|
||||
_SCRIPT = "from app.plugins.tdxquant.probe import run_worker; run_worker()"
|
||||
|
||||
|
||||
def stage(name, **extra):
|
||||
if progress is None:
|
||||
return
|
||||
try:
|
||||
progress.write_text(json.dumps({"stage": name, **extra}), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
def run_worker() -> int:
|
||||
"""探测 worker — 必须在独立子进程内执行 (见 run 的拉起方式)。
|
||||
|
||||
从环境变量读候选 Key 与进度目录, 各阶段进度写入 progress.json,
|
||||
结论以单行 JSON 打到 stdout 供父进程解析。复用 client.materialize_lib
|
||||
保持与主进程同一套物化/写 token 逻辑。
|
||||
"""
|
||||
key = os.environ.pop(_PROBE_ENV_KEY, "")
|
||||
tmp = Path(os.environ.pop(_PROBE_ENV_TMP, ""))
|
||||
os.environ.pop(_PROBE_ENV_WORKER, None)
|
||||
progress = tmp / "progress.json" if str(tmp) else None
|
||||
|
||||
try:
|
||||
stage("imports")
|
||||
from app.plugins.tdxquant import client as tc
|
||||
|
||||
stage("materialize")
|
||||
dll = tc.materialize_lib(key, tmp)
|
||||
os.environ[tc.LIB_ENV] = str(dll)
|
||||
|
||||
stage("load_dll")
|
||||
from tdxaidata.tdxaidata import _NativeTdxAiData
|
||||
|
||||
native = _NativeTdxAiData.get_instance()
|
||||
|
||||
# 连通性判定: 快照端点不依赖 token 鉴权, 拿到数据即证明
|
||||
# 服务器可达 + DLL 正常。轮询容忍 DLL 冷启动的未就绪窗口。
|
||||
stage("start")
|
||||
reachable = False
|
||||
for _ in range(3):
|
||||
def stage(name: str, **extra) -> None:
|
||||
if progress is None:
|
||||
return
|
||||
try:
|
||||
if native.get_market_snapshot_batch("600519.SH", "Now"):
|
||||
reachable = True
|
||||
break
|
||||
except Exception:
|
||||
progress.write_text(json.dumps({"stage": name, **extra}), encoding="utf-8")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1.2)
|
||||
if not reachable:
|
||||
stage("done", ok=False)
|
||||
print(json.dumps({"ok": False, "reason": "行情服务器连接失败(网络不可达)"}))
|
||||
raise SystemExit(0)
|
||||
|
||||
# 日K仅作辅助信号(会话按 IP 复用时错误 Key 也能通过, 不能作拒绝依据)
|
||||
stage("auth")
|
||||
aux = ""
|
||||
try:
|
||||
raw = native.get_his_dats(
|
||||
"600519.SH", "", "2026-01-01 00:00:00", "1d", "none", 3
|
||||
)
|
||||
from tdxaidata import tqs
|
||||
stage("imports")
|
||||
from app.plugins.tdxquant import client as tc
|
||||
|
||||
v = tqs._clean(raw)
|
||||
aux = "his_ok" if isinstance(v, dict) and v.get("Date") else "his_empty"
|
||||
except Exception as exc:
|
||||
aux = "his_exc"
|
||||
stage("materialize")
|
||||
dll = tc.materialize_lib(key, tmp)
|
||||
os.environ[tc.LIB_ENV] = str(dll)
|
||||
|
||||
stage("done", ok=True)
|
||||
print(json.dumps({"ok": True, "reason": "", "aux": aux}))
|
||||
except Exception as exc:
|
||||
stage("exception", reason=str(exc)[:300])
|
||||
print(json.dumps({"ok": False, "reason": str(exc)[:300]}))
|
||||
"""
|
||||
stage("load_dll")
|
||||
from tdxaidata.tdxaidata import _NativeTdxAiData
|
||||
|
||||
native = _NativeTdxAiData.get_instance()
|
||||
|
||||
# 连通性判定: 快照端点不依赖 token 鉴权, 拿到数据即证明
|
||||
# 服务器可达 + DLL 正常。轮询容忍 DLL 冷启动的未就绪窗口。
|
||||
stage("start")
|
||||
reachable = False
|
||||
for _ in range(3):
|
||||
try:
|
||||
if native.get_market_snapshot_batch("600519.SH", "Now"):
|
||||
reachable = True
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1.2)
|
||||
if not reachable:
|
||||
stage("done", ok=False)
|
||||
print(json.dumps({"ok": False, "reason": "行情服务器连接失败(网络不可达)"}))
|
||||
return 0
|
||||
|
||||
# 日K仅作辅助信号(会话按 IP 复用时错误 Key 也能通过, 不能作拒绝依据)
|
||||
stage("auth")
|
||||
aux = ""
|
||||
try:
|
||||
raw = native.get_his_dats(
|
||||
"600519.SH", "", "2026-01-01 00:00:00", "1d", "none", 3
|
||||
)
|
||||
from tdxaidata import tqs
|
||||
|
||||
v = tqs._clean(raw)
|
||||
aux = "his_ok" if isinstance(v, dict) and v.get("Date") else "his_empty"
|
||||
except Exception: # noqa: BLE001
|
||||
aux = "his_exc"
|
||||
|
||||
stage("done", ok=True)
|
||||
print(json.dumps({"ok": True, "reason": "", "aux": aux}))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
stage("exception", reason=str(exc)[:300])
|
||||
print(json.dumps({"ok": False, "reason": str(exc)[:300]}))
|
||||
return 0
|
||||
|
||||
|
||||
def _read_stage(tmp_dir: str) -> str:
|
||||
@@ -129,10 +137,17 @@ def run(api_key: str, timeout_s: float = 75.0) -> tuple[bool, str]:
|
||||
_PROBE_ENV_TMP: tmp_dir,
|
||||
"PYTHONPATH": str(backend_root),
|
||||
}
|
||||
if getattr(sys, "frozen", False):
|
||||
# 打包版 sys.executable 是应用本身, `-c` 会被 bootloader 无视并重启
|
||||
# 整个桌面应用 — 必须裸重执行自身, 由 desktop.main 的哨兵分发 worker
|
||||
cmd = [sys.executable]
|
||||
env[_PROBE_ENV_WORKER] = "1"
|
||||
else:
|
||||
cmd = [sys.executable, "-c", _SCRIPT]
|
||||
try:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", _SCRIPT],
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""TdxQuant 候选 Key 探测 — 子进程拉起方式与 worker 行为。
|
||||
|
||||
回归背景: 打包版 sys.executable 是应用本身, `-c` 被 PyInstaller bootloader
|
||||
无视 → 探测子进程重启整个桌面应用而非执行探测 (Key 永远验证失败)。
|
||||
拉起方式因此分叉: dev 用 `python -c`, frozen 用「裸重执行自身 + 哨兵」
|
||||
(desktop.main 最先分发到 run_worker), 两种入口共用同一份 worker 实现。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import app.desktop as desktop
|
||||
from app.plugins.tdxquant import probe
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, stdout: str = "", returncode: int = 0):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
def _capture_spawn(monkeypatch, frozen: bool) -> dict:
|
||||
seen: dict = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
seen["cmd"] = list(cmd)
|
||||
seen["env"] = dict(kwargs["env"])
|
||||
return _FakeProc(stdout=json.dumps({"ok": True, "reason": ""}))
|
||||
|
||||
monkeypatch.setattr(probe.subprocess, "run", fake_run)
|
||||
if frozen:
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
else:
|
||||
monkeypatch.delattr(sys, "frozen", raising=False)
|
||||
return seen
|
||||
|
||||
|
||||
def test_run_dev_spawns_python_dash_c(monkeypatch):
|
||||
seen = _capture_spawn(monkeypatch, frozen=False)
|
||||
ok, _ = probe.run("TDX-KEY")
|
||||
assert ok
|
||||
assert seen["cmd"][:2] == [sys.executable, "-c"]
|
||||
assert "run_worker" in seen["cmd"][2]
|
||||
# dev 路径不设哨兵 — 那是 frozen 重执行自身的分发机制
|
||||
assert probe._PROBE_ENV_WORKER not in seen["env"]
|
||||
|
||||
|
||||
def test_run_frozen_reexecs_self_with_sentinel(monkeypatch):
|
||||
seen = _capture_spawn(monkeypatch, frozen=True)
|
||||
ok, _ = probe.run("TDX-KEY")
|
||||
assert ok
|
||||
# 不带 -c: bootloader 会无视它并重启整个桌面应用
|
||||
assert seen["cmd"] == [sys.executable]
|
||||
assert seen["env"][probe._PROBE_ENV_WORKER] == "1"
|
||||
assert seen["env"][probe._PROBE_ENV_KEY] == "TDX-KEY"
|
||||
|
||||
|
||||
def _patch_worker_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(probe._PROBE_ENV_KEY, "TDX-KEY")
|
||||
monkeypatch.setenv(probe._PROBE_ENV_TMP, str(tmp_path))
|
||||
monkeypatch.setenv(probe._PROBE_ENV_WORKER, "1")
|
||||
monkeypatch.setattr(
|
||||
"app.plugins.tdxquant.client.materialize_lib",
|
||||
lambda key, tmp, **kw: tmp_path / "fake_lib.so",
|
||||
)
|
||||
monkeypatch.setattr(probe.time, "sleep", lambda s: None)
|
||||
|
||||
|
||||
def test_run_worker_unreachable_reports_json(monkeypatch, tmp_path, capsys):
|
||||
_patch_worker_env(monkeypatch, tmp_path)
|
||||
|
||||
class _FakeNative:
|
||||
def get_market_snapshot_batch(self, *a, **kw):
|
||||
raise RuntimeError("down")
|
||||
|
||||
import tdxaidata.tdxaidata as tdxmod
|
||||
|
||||
monkeypatch.setattr(
|
||||
tdxmod._NativeTdxAiData, "get_instance", classmethod(lambda cls: _FakeNative())
|
||||
)
|
||||
|
||||
assert probe.run_worker() == 0
|
||||
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||
assert payload["ok"] is False
|
||||
assert "连接失败" in payload["reason"]
|
||||
progress = json.loads((tmp_path / "progress.json").read_text(encoding="utf-8"))
|
||||
assert progress["stage"] == "done" and progress["ok"] is False
|
||||
|
||||
|
||||
def test_run_worker_ok_path(monkeypatch, tmp_path, capsys):
|
||||
_patch_worker_env(monkeypatch, tmp_path)
|
||||
|
||||
class _FakeNative:
|
||||
def get_market_snapshot_batch(self, *a, **kw):
|
||||
return {"price": 1.0}
|
||||
|
||||
def get_his_dats(self, *a, **kw):
|
||||
return "raw"
|
||||
|
||||
import tdxaidata.tdxaidata as tdxmod
|
||||
from tdxaidata import tqs
|
||||
|
||||
monkeypatch.setattr(
|
||||
tdxmod._NativeTdxAiData, "get_instance", classmethod(lambda cls: _FakeNative())
|
||||
)
|
||||
monkeypatch.setattr(tqs, "_clean", lambda raw: {"Date": ["2026-01-02"]})
|
||||
|
||||
assert probe.run_worker() == 0
|
||||
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||
assert payload["ok"] is True
|
||||
assert payload["aux"] == "his_ok"
|
||||
|
||||
|
||||
def test_run_worker_exception_reports_stage(monkeypatch, tmp_path, capsys):
|
||||
_patch_worker_env(monkeypatch, tmp_path)
|
||||
|
||||
def _boom(key, tmp, **kw):
|
||||
raise RuntimeError("materialize failed")
|
||||
|
||||
monkeypatch.setattr("app.plugins.tdxquant.client.materialize_lib", _boom)
|
||||
|
||||
assert probe.run_worker() == 0
|
||||
payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||
assert payload["ok"] is False
|
||||
assert "materialize failed" in payload["reason"]
|
||||
progress = json.loads((tmp_path / "progress.json").read_text(encoding="utf-8"))
|
||||
assert progress["stage"] == "exception"
|
||||
|
||||
|
||||
def test_desktop_main_dispatches_probe_worker(monkeypatch):
|
||||
monkeypatch.setenv("TDX_PROBE_WORKER", "1")
|
||||
monkeypatch.setattr(desktop, "_guard_streams", lambda: None)
|
||||
monkeypatch.setattr(desktop, "_setup_logging", lambda: None)
|
||||
monkeypatch.setattr("app.plugins.tdxquant.probe.run_worker", lambda: 7)
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise AssertionError("worker 模式不应触碰桌面启动流程")
|
||||
|
||||
monkeypatch.setattr(desktop, "_ensure_data_dir_writable", _boom)
|
||||
monkeypatch.setattr(desktop, "_acquire_single_instance", _boom)
|
||||
assert desktop.main() == 7
|
||||
|
||||
|
||||
def test_desktop_main_without_sentinel_starts_normally(monkeypatch):
|
||||
monkeypatch.delenv("TDX_PROBE_WORKER", raising=False)
|
||||
monkeypatch.setattr(desktop, "_guard_streams", lambda: None)
|
||||
monkeypatch.setattr(desktop, "_setup_logging", lambda: None)
|
||||
monkeypatch.setattr(desktop, "_ensure_data_dir_writable", lambda: None)
|
||||
# 单实例未抢到 → 正常退出, 不触碰后端/窗口
|
||||
monkeypatch.setattr(desktop, "_acquire_single_instance", lambda: False)
|
||||
assert desktop.main() == 0
|
||||
|
||||
|
||||
def test_pid_alive_posix_current_process():
|
||||
assert desktop._pid_alive(os.getpid()) is True
|
||||
@@ -49,6 +49,18 @@ for pkg in ("polars", "pyarrow", "duckdb", "fastexcel"):
|
||||
binaries += b
|
||||
hiddenimports += h
|
||||
|
||||
# tdxaidata (TdxQuant 插件依赖) — 后端对其全部懒导入, 必须显式声明:
|
||||
# 纯 Python 接口 (包 __init__ + tdxaidata.py) 进 PYZ; lib/ 是跨平台原生库
|
||||
# 合集 (win dll / mac dylib / linux so 同包), 运行时由 client.materialize_lib
|
||||
# 复制物化后 ctypes 加载, 属数据文件 — 只能整目录按 datas 复制。
|
||||
# 注意不能用 collect_all / collect_submodules: lib/ 下的 libTdxAiData.so
|
||||
# 会被当成 Python 扩展子模块收进 binaries, mac 构建的 macholib 解析
|
||||
# Linux ELF 直接崩。
|
||||
if (spec_tdx := find_spec("tdxaidata")) is not None and spec_tdx.origin:
|
||||
_tdx_pkg = Path(spec_tdx.origin).parent
|
||||
datas += [(str(_tdx_pkg / "lib"), "tdxaidata/lib")]
|
||||
hiddenimports += ["tdxaidata", "tdxaidata.tdxaidata"]
|
||||
|
||||
# Polars 的发行包名为 polars-runtime-32 / polars-runtime-compat, 但实际
|
||||
# Python 导入包带前导下划线。release.yml 安装 legacy-cpu 后必须收集二者,
|
||||
# 否则 onedir 产物无法在没有 AVX2/FMA 的旧 CPU 上加载兼容内核。
|
||||
|
||||
Reference in New Issue
Block a user