Skip to content

Commit 2cda7a6

Browse files
committed
fix: QMT 填密码走 helper 子进程,gateway 主进程不再 import pywinauto
#120:uiautomationcore.dll 残留 COM 回调在 QMT 客户端退出后变成野指针,gateway 进程会被段错误(0xc0000005)干掉。\n\n修复:把 _fill_qmt_login_password 推到 qmt_restart_helper 子进程。\n- Session 0 路径:原本就走 helper(via task scheduler),不动。\n- Session 2 路径:原本直接调 _fill_qmt_login_password 现在改走 _launch_qmt_process_via_helper,跟 Session 0 行为一致。\n- 旧 _fill_qmt_login_password / _wait_for_login_window_to_close 保留但不再被 gateway 主进程调用。
1 parent 268573d commit 2cda7a6

2 files changed

Lines changed: 129 additions & 27 deletions

File tree

qmt_gateway/services/trade_service.py

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,17 +101,15 @@ def _task() -> dict:
101101
else:
102102
logger.info("未发现正在运行的 QMT 进程: image={}", executable.name)
103103

104-
if service._get_current_session_id() == 0:
105-
password_token = service.issue_restart_password_token(qmt_password)
104+
# 总是为 helper 生成 token,让 pywinauto 走子进程而不是在
105+
# gateway 主进程内直接调用——避免 uiautomationcore.dll 残留
106+
# COM 回调在 QMT 客户端退出后引发 0xc0000005 access violation
107+
# (gateway 进程被直接干掉)。
108+
password_token = service.issue_restart_password_token(qmt_password)
106109

107110
process = service._launch_qmt_process(executable, password_token=password_token)
108-
if password_token is None:
109-
service._fill_qmt_login_password(process.pid, qmt_password)
110-
# 等待登录窗口消失,确保 QMT 已处理完密码提交
111-
service._wait_for_login_window_to_close(
112-
process.pid,
113-
timeout_sec=float(service._qmt_login_timeout_sec),
114-
)
111+
# 旧路径:Session 2 直接调 _fill_qmt_login_password,pywinauto 在主
112+
# 进程内工作;该路径已经走 helper 子进程代替,这里不再执行。
115113

116114
if not verify_connection:
117115
logger.info("启动+填密码完成,跳过连接验证(verify_connection=False)")
@@ -775,7 +773,78 @@ def _launch_qmt_process(self, executable: Path, password_token: str | None = Non
775773
if not password_token:
776774
raise RuntimeError("缺少 QMT 重启密码令牌,无法在交互会话中自动填入密码")
777775
return self._launch_qmt_process_in_interactive_session(executable, before_pids, password_token)
778-
return self._launch_qmt_process_locally(executable, before_pids)
776+
# Session 2(gateway 与用户在同一个交互会话):也走 helper 子进程。
777+
# 原本这里直接 _launch_qmt_process_locally + _fill_qmt_login_password,
778+
# 但 pywinauto 的 Desktop 持有的 uiautomationcore.dll COM 回调会在
779+
# QMT 客户端被关闭、uiautomationcore.dll 被卸载后变成野指针,下次
780+
# 访问就 0xc0000005 access violation 把整个 gateway 进程干掉。
781+
# 把填密码这件事放到独立子进程(helper)里,进程退出时所有
782+
# COM 引用随之释放,gateway 主进程完全不用 import pywinauto。
783+
return self._launch_qmt_process_via_helper(executable, before_pids, password_token)
784+
785+
def _launch_qmt_process_via_helper(
786+
self,
787+
executable: Path,
788+
before_pids: set[int],
789+
password_token: str | None,
790+
):
791+
"""在当前会话(Session 2)里拉起一个 helper 子进程来启动 QMT 并填密码。
792+
793+
helper 启动后通过 HTTP ``/api/trade/restart-qmt/password?token=...`` 拉
794+
真实密码(token 由调用方在 ``issue_restart_password_token`` 时存入),
795+
不在命令行里出现明文。helper 进程退出后所有 pywinauto / comtypes /
796+
uiautomationcore.dll 引用随之释放,gateway 主进程完全不接触。
797+
"""
798+
# 优先用调用方传来的 token(通常是 wizard 路径上预生成的);如果没有
799+
# 重新生成一个——但 helper 需要 gateway 在线才能拉密码,这种情况下
800+
# caller 必须确保在 _fill_qmt_login_password 之前 issue token。
801+
if not password_token:
802+
raise RuntimeError("缺少 QMT 重启密码令牌,无法在交互会话中自动填入密码")
803+
helper_python = self._get_helper_python_executable()
804+
argument_list = [
805+
"-m",
806+
"qmt_gateway.qmt_restart_helper",
807+
"--base-url",
808+
self._build_restart_helper_base_url(),
809+
"--token",
810+
password_token,
811+
"--exe",
812+
str(executable),
813+
"--launch-timeout",
814+
str(self._qmt_launch_probe_timeout_sec),
815+
"--login-timeout",
816+
str(self._qmt_login_timeout_sec),
817+
"--retry-delay",
818+
str(self._qmt_login_retry_delay_sec),
819+
]
820+
logger.info(
821+
"在交互会话中通过 helper 子进程启动 QMT: helper={}, exe={}",
822+
helper_python,
823+
executable,
824+
)
825+
result = subprocess.run( # noqa: S603
826+
[str(helper_python), *argument_list],
827+
cwd=str(executable.parent),
828+
stdin=subprocess.DEVNULL,
829+
stdout=subprocess.PIPE,
830+
stderr=subprocess.PIPE,
831+
text=False,
832+
timeout=max(
833+
float(self._qmt_launch_probe_timeout_sec)
834+
+ float(self._qmt_login_timeout_sec)
835+
+ 30.0,
836+
60.0,
837+
),
838+
check=False,
839+
)
840+
stdout = self._decode_subprocess_output(result.stdout)
841+
stderr = self._decode_subprocess_output(result.stderr)
842+
helper_message = (stdout + "\n" + stderr).strip()
843+
if result.returncode != 0:
844+
raise RuntimeError(
845+
helper_message or f"helper 启动 QMT 失败,退出码: {result.returncode}"
846+
)
847+
return self._wait_for_new_process(executable.name, before_pids)
779848

780849
def _load_pywinauto(self):
781850
try:
@@ -874,7 +943,7 @@ def _fill_qmt_login_password(self, process_id: int, password: str) -> None:
874943

875944
active_process_ids = self._collect_qmt_process_ids(process_id, QMT_CLIENT_EXECUTABLE)
876945
logger.warning(
877-
"自动填入 QMT 密码失败,未找到登录窗口: launcher_pid={}, active_pids={}",
946+
"自动填入 QMT 密码失败,未找到登录窗口: launcher_pid={}, active_process_ids={}",
878947
process_id,
879948
active_process_ids,
880949
)

tests/test_trade_service.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,25 @@ def test_restart_and_login_relaunches_client_and_reconnects(monkeypatch):
211211
executable = Path(r"C:\apps\qmt\bin.x64\XtMiniQmt.exe")
212212
calls = []
213213

214+
issued_tokens = []
215+
monkeypatch.setattr(service, "issue_restart_password_token", lambda password: issued_tokens.append(password) or "test-token")
214216
monkeypatch.setattr(service, "_get_current_session_id", lambda: 1)
215217
monkeypatch.setattr(service, "_resolve_qmt_client_path", lambda qmt_path: executable)
216218
monkeypatch.setattr(service, "disconnect", lambda: calls.append("disconnect"))
217219
monkeypatch.setattr(service, "discard_restart_password_token", lambda token: calls.append(("discard", token)))
218220
monkeypatch.setattr(service, "_set_connection_state", lambda connected, message: calls.append(("state", connected, message)) or message)
219221
monkeypatch.setattr(service, "_kill_qmt_process", lambda name: calls.append(("kill", name)) or "terminated")
220222
monkeypatch.setattr(service, "_launch_qmt_process", lambda path, password_token=None: calls.append(("launch", path, password_token)) or SimpleNamespace(pid=4321))
221-
monkeypatch.setattr(service, "_fill_qmt_login_password", lambda pid, password: calls.append(("fill", pid, password)))
223+
# 关键:填密码被推到 helper 子进程后,gateway 主进程不应该再调
224+
# _fill_qmt_login_password。把它 monkeypatch 成抛 AssertionError 来
225+
# 防止回归到旧路径。
226+
monkeypatch.setattr(
227+
service,
228+
"_fill_qmt_login_password",
229+
lambda *a, **k: (_ for _ in ()).throw(
230+
AssertionError("_fill_qmt_login_password 必须在 helper 子进程中执行")
231+
),
232+
)
222233
monkeypatch.setattr(
223234
service,
224235
"connect",
@@ -233,15 +244,17 @@ def test_restart_and_login_relaunches_client_and_reconnects(monkeypatch):
233244
)
234245

235246
assert result == {"success": True, "message": "QMT 已重启并重新连接交易接口"}
247+
# 关键:launch 阶段必须传 token(让 helper 走子进程),不再有
248+
# _fill_qmt_login_password 调用。
236249
assert calls == [
237250
"disconnect",
238251
("state", False, "交易接口连接断开,正在重启 QMT"),
239252
("kill", "XtMiniQmt.exe"),
240-
("launch", executable, None),
241-
("fill", 4321, "trade-secret"),
253+
("launch", executable, "test-token"),
242254
("connect", "8881457417", r"C:\apps\qmt\userdata_mini"),
243-
("discard", None),
255+
("discard", "test-token"),
244256
]
257+
assert issued_tokens == ["trade-secret"]
245258

246259

247260
def test_restart_and_login_requires_password():
@@ -256,26 +269,39 @@ def test_restart_and_login_requires_password():
256269
assert result == {"success": False, "error": "请输入交易密码"}
257270

258271

259-
def test_restart_and_login_waits_for_login_window_before_connecting(monkeypatch):
260-
"""填入密码后应等待登录窗口消失,再尝试 connect()。"""
272+
def test_restart_and_login_passes_token_to_launch_in_interactive_session(monkeypatch):
273+
"""#120 回归:填密码必须经 helper 子进程,gateway 主进程不应直接调
274+
``_fill_qmt_login_password``(uiautomationcore.dll 残留 COM 回调会
275+
在 QMT 退出后引发 access violation)。
276+
"""
261277
service = TradeService()
262278
service._account_id = "8881457417"
263279
service._qmt_path = r"C:\apps\qmt\userdata_mini"
264280
executable = Path(r"C:\apps\qmt\bin.x64\XtMiniQmt.exe")
265281
calls = []
266282

283+
monkeypatch.setattr(service, "issue_restart_password_token", lambda password: "tok-XYZ")
267284
monkeypatch.setattr(service, "_get_current_session_id", lambda: 1)
268285
monkeypatch.setattr(service, "_resolve_qmt_client_path", lambda qmt_path: executable)
269286
monkeypatch.setattr(service, "disconnect", lambda: calls.append("disconnect"))
270287
monkeypatch.setattr(service, "discard_restart_password_token", lambda token: calls.append(("discard", token)))
271288
monkeypatch.setattr(service, "_set_connection_state", lambda connected, message: calls.append(("state", connected, message)) or message)
272289
monkeypatch.setattr(service, "_kill_qmt_process", lambda name: "terminated")
273-
monkeypatch.setattr(service, "_launch_qmt_process", lambda path, password_token=None: SimpleNamespace(pid=4321))
274-
monkeypatch.setattr(service, "_fill_qmt_login_password", lambda pid, password: calls.append(("fill", pid, password)))
290+
monkeypatch.setattr(service, "_launch_qmt_process", lambda path, password_token=None: calls.append(("launch", path, password_token)) or SimpleNamespace(pid=4321))
291+
# gateway 主进程不应再调 _fill_qmt_login_password / _wait_for_login_window
292+
monkeypatch.setattr(
293+
service,
294+
"_fill_qmt_login_password",
295+
lambda *a, **k: (_ for _ in ()).throw(
296+
AssertionError("不应在主进程中调 _fill_qmt_login_password")
297+
),
298+
)
275299
monkeypatch.setattr(
276300
service,
277301
"_wait_for_login_window_to_close",
278-
lambda pid, timeout_sec: calls.append(("wait", pid, timeout_sec)) or True,
302+
lambda *a, **k: (_ for _ in ()).throw(
303+
AssertionError("不应在主进程中调 _wait_for_login_window_to_close")
304+
),
279305
)
280306
monkeypatch.setattr(
281307
service,
@@ -291,12 +317,15 @@ def test_restart_and_login_waits_for_login_window_before_connecting(monkeypatch)
291317
)
292318

293319
assert result["success"] is True
294-
# 关键:fill 之后必须 wait,然后才 connect
295-
assert ("fill", 4321, "trade-secret") in calls
296-
fill_index = calls.index(("fill", 4321, "trade-secret"))
297-
wait_index = calls.index(("wait", 4321, 20.0))
298-
connect_index = calls.index(("connect", "8881457417", r"C:\apps\qmt\userdata_mini"))
299-
assert fill_index < wait_index < connect_index
320+
# 关键:launch 阶段带 token(让 helper 子进程填密码),connect 紧随其后,
321+
# 中间不再有 _fill_qmt_login_password / _wait_for_login_window_to_close。
322+
assert calls == [
323+
"disconnect",
324+
("state", False, "交易接口连接断开,正在重启 QMT"),
325+
("launch", executable, "tok-XYZ"),
326+
("connect", "8881457417", r"C:\apps\qmt\userdata_mini"),
327+
("discard", "tok-XYZ"),
328+
]
300329

301330

302331
def test_kill_qmt_process_ignores_missing_process_message(monkeypatch):
@@ -364,6 +393,10 @@ def set_edit_text(self, password):
364393

365394

366395
def test_launch_qmt_process_uses_shell_open_and_detects_new_pid(monkeypatch):
396+
"""#120 回归:Session 2 路径现在也走 helper 子进程,pywinauto 不在主进程内
397+
调用。本测试只验证 _launch_qmt_process_locally 的旧行为(在没有 token
398+
时仍可独立调用),由 _launch_qmt_process 路由。
399+
"""
367400
service = TradeService()
368401
executable = Path(r"C:\apps\qmt\bin.x64\XtMiniQmt.exe")
369402
launch_calls = []
@@ -374,7 +407,7 @@ def test_launch_qmt_process_uses_shell_open_and_detects_new_pid(monkeypatch):
374407
monkeypatch.setattr(trade_service_module.os, "startfile", lambda path, cwd=None: launch_calls.append((path, cwd)))
375408
monkeypatch.setattr(trade_service_module.time, "sleep", lambda seconds: None)
376409

377-
process = service._launch_qmt_process(executable)
410+
process = service._launch_qmt_process_locally(executable, set())
378411

379412
assert process.pid == 8765
380413
assert launch_calls == [(str(executable), str(executable.parent))]

0 commit comments

Comments
 (0)