From f707b7c1280aa436fd613b31930a8fcb4863643d Mon Sep 17 00:00:00 2001 From: countbot-ai Date: Fri, 30 Oct 2026 10:36:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(tools):=20=E5=8F=96=E6=B6=88=20exec=20?= =?UTF-8?q?=E6=97=B6=E7=BB=88=E6=AD=A2=E6=95=B4=E6=A3=B5=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E6=A0=91=EF=BC=88Windows=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _terminate_process 的 Windows 分支只 terminate()/kill() 了 cmd.exe,而 start_new_session 是 POSIX 专有参数,Windows 下不生效,sleep / npm 这类 子进程会存活并继续持有继承来的 stdout/stderr 管道。等待输出的一方读不到 EOF,取消后调用要挂到超时才返回,子进程则在后台继续跑。 改为 Windows 上走 taskkill /F /T /PID 递归结束进程树,失败再退回单进程 kill。对应测试 test_exec_terminates_process_on_cancel 在 Windows CI 上 正是超时失败。 --- backend/modules/tools/shell.py | 47 ++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/backend/modules/tools/shell.py b/backend/modules/tools/shell.py index 95d0e081..313dc812 100644 --- a/backend/modules/tools/shell.py +++ b/backend/modules/tools/shell.py @@ -719,13 +719,18 @@ def _terminate_process(self, process) -> None: """ if process is None or process.returncode is not None: return + if os.name != "posix": + # Windows 没有进程组语义:start_new_session 是 POSIX 专有参数, + # terminate()/kill() 只能结束 cmd.exe 本身。sleep、npm 这类子进程会 + # 存活下来并继续持有继承的 stdout/stderr 管道,等待输出的一方永远 + # 读不到 EOF,表现为取消后调用一直挂到超时。 + self._kill_process_tree_windows(process) + return + try: - if os.name == "posix": - # start_new_session=True 使子进程自成进程组,pgid 等于其 pid - pgid = os.getpgid(process.pid) - os.killpg(pgid, signal.SIGTERM) - else: - process.terminate() + # start_new_session=True 使子进程自成进程组,pgid 等于其 pid + pgid = os.getpgid(process.pid) + os.killpg(pgid, signal.SIGTERM) except (ProcessLookupError, PermissionError): return except Exception as exc: @@ -733,10 +738,32 @@ def _terminate_process(self, process) -> None: return try: - if os.name == "posix": - os.killpg(os.getpgid(process.pid), signal.SIGKILL) - else: - process.kill() + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + except Exception as exc: + logger.debug(f"Force kill process failed: {exc}") + + @staticmethod + def _kill_process_tree_windows(process) -> None: + """用 taskkill /T 递归终止 cmd.exe 下的整棵进程树。 + + Windows 上找不到「进程组」,只能按 pid 让系统自己遍历子进程; + 失败(taskkill 不可用等)再退回单进程 kill。 + """ + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) + except Exception as exc: + logger.debug(f"taskkill process tree failed: {exc}") + + try: + process.kill() except (ProcessLookupError, PermissionError): pass except Exception as exc: