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:
shy3130
2026-10-13 18:37:29 +08:00
parent 25fd5a0fad
commit 506d66f1ef
4 changed files with 272 additions and 67 deletions
+26 -5
View File
@@ -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:
+77 -62
View File
@@ -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,
+157
View File
@@ -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
+12
View File
@@ -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 上加载兼容内核。