diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index f8088220..b824a0ee 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -18,11 +18,6 @@ from modules.host_sandbox_runner import ( build_host_sandbox_plan, host_sandbox_enabled, ) -from modules import sbx_debug # [SBX-DEBUG] -try: # [SBX-DEBUG] - sbx_debug.install_ctrl_event_probes("bg_manager") # [SBX-DEBUG] -except Exception: # [SBX-DEBUG] - pass # [SBX-DEBUG] TERMINAL_STATUSES = {"completed", "failed", "timeout", "cancelled"} @@ -294,7 +289,6 @@ class BackgroundCommandManager: rec["pid"] = process.pid rec["updated_at"] = time.time() self._processes[command_id] = process - sbx_debug.sbx_log("BG-SPAWNED", pid=getattr(process, "pid", None), command=str(command)[:200], timeout=timeout, console_pids=sbx_debug.console_pids()) # [SBX-DEBUG] def _reader(stream, collector, rec_key: str): try: @@ -327,8 +321,7 @@ class BackgroundCommandManager: except subprocess.TimeoutExpired: status = "timeout" message = f"命令执行超时 ({timeout}秒)" - # 跨平台终止:POSIX killpg(SIGINT→SIGKILL),Windows CTRL_BREAK→taskkill - sbx_debug.sbx_log_stack("BG-TIMEOUT-TERMINATE", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG] + # 跨平台终止:POSIX killpg(SIGINT→SIGKILL),Windows taskkill /F /T self._terminate_pid(process.pid) try: process.wait(timeout=2) @@ -436,23 +429,13 @@ class BackgroundCommandManager: return True def _terminate_pid(self, pid: Any) -> bool: - sbx_debug.sbx_log_stack("TERMINATE-PID", pid=pid) # [SBX-DEBUG] normalized = self._coerce_pid(pid) if not normalized: return False if os.name == "nt": - # Windows:无 killpg/SIGKILL。先尝试 CTRL_BREAK_EVENT 优雅中断 - # (子进程以 start_new_session=True 启动,即独立进程组), - # 超时后用 taskkill /F /T 强制终止整棵进程树,避免孙进程残留。 - try: - os.kill(normalized, signal.CTRL_BREAK_EVENT) - except Exception: - pass - deadline = time.time() + 2.0 - while time.time() < deadline: - if not self._is_pid_alive(normalized): - return True - time.sleep(0.05) + # Windows:无 killpg/SIGKILL。弃用 CTRL_BREAK_EVENT——实测该事件会 + # 投递到本进程自身(2026-07 WSL 沙箱排查,见 terminal_ops/run.py 注释), + # 直接 taskkill /F /T 强制终止整棵进程树,避免孙进程残留。 try: subprocess.run( ["taskkill", "/F", "/T", "/PID", str(normalized)], diff --git a/modules/sbx_debug.py b/modules/sbx_debug.py deleted file mode 100644 index 7195f557..00000000 --- a/modules/sbx_debug.py +++ /dev/null @@ -1,147 +0,0 @@ -# modules/sbx_debug.py - 沙箱崩溃排查的临时调试日志模块(诊断完成后移除) -# -# 双通道日志: -# 1) UDP 发送到独立监控进程(后端死亡前消息已送达,监控进程独立存活) -# 2) 文件追加 .wsl-exp/sbx_debug.log(每行立即落盘,保底冗余) -# 环境变量: -# SBX_DEBUG=0 关闭全部调试日志 -# SBX_DEBUG_PORT=9956 UDP 监控端口 -# SBX_DEBUG_SWALLOW_CTRL=0 控制台事件不吞掉(记录后恢复默认行为=真实死亡) - -from __future__ import annotations - -import datetime -import os -import signal -import socket -import threading -import traceback - -_ENABLED = os.environ.get("SBX_DEBUG", "1").strip().lower() not in {"0", "false", "no", "off"} -_PORT = int(os.environ.get("SBX_DEBUG_PORT", "8092") or "8092") # UDP,与后端 TCP 端口不冲突 -_SWALLOW = os.environ.get("SBX_DEBUG_SWALLOW_CTRL", "1").strip().lower() not in {"0", "false", "no", "off"} - -_LOCK = threading.Lock() -_SOCK = None -_PROBES_INSTALLED = False - - -def _log_path() -> str: - try: - root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - except Exception: - root = os.getcwd() - d = os.path.join(root, ".wsl-exp") - try: - os.makedirs(d, exist_ok=True) - except Exception: - pass - return os.path.join(d, "sbx_debug.log") - - -def _get_sock(): - global _SOCK - if _SOCK is None: - try: - _SOCK = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - except Exception: - _SOCK = False - return _SOCK or None - - -def sbx_log(event: str, **fields) -> None: - """写一条调试日志(任何异常都吞掉,绝不影响主流程)。""" - if not _ENABLED: - return - try: - ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3] - parts = [f"{k}={fields[k]!r}" for k in sorted(fields)] - line = f"[{ts}] [pid={os.getpid()}] [th={threading.current_thread().name}] {event}" - if parts: - line += " | " + " ".join(parts) - data = (line + "\n").encode("utf-8", "replace") - with _LOCK: - sock = _get_sock() - if sock is not None: - try: - sock.sendto(data, ("127.0.0.1", _PORT)) - except Exception: - pass - try: - with open(_log_path(), "a", encoding="utf-8") as f: - f.write(line + "\n") - except Exception: - pass - except Exception: - pass - - -def sbx_log_stack(event: str, **fields) -> None: - """写一条带调用堆栈的调试日志。""" - try: - stack = "".join(traceback.format_stack(limit=14)[:-2]) - except Exception: - stack = "" - fields["stack"] = stack - sbx_log(event, **fields) - - -def console_pids(): - """返回当前控制台附加的进程 pid 列表(Windows),用于判断子进程是否共享后端控制台。""" - if os.name != "nt": - return [] - try: - import ctypes - arr = (ctypes.c_ulong * 64)() - got = ctypes.windll.kernel32.GetConsoleProcessList(arr, 64) - if not got: - return [] - return [int(arr[i]) for i in range(min(got, 64))] - except Exception: - return ["?"] - - -def install_ctrl_event_probes(tag: str = "backend") -> None: - """安装 SIGINT/SIGBREAK 探针:控制台事件到达时先记录日志。 - - SBX_DEBUG_SWALLOW_CTRL=1(默认):记录后吞掉事件,进程存活,可继续观察后续行为; - =0:记录后恢复原处理器语义(SIGINT→KeyboardInterrupt;SIGBREAK→默认终止)。 - 只能在主线程安装,失败静默。 - """ - global _PROBES_INSTALLED - if not _ENABLED or os.name != "nt" or _PROBES_INSTALLED: - return - if threading.current_thread() is not threading.main_thread(): - sbx_log("CTRL-PROBES-SKIPPED", reason="not main thread", tag=tag) - return - - def _make_handler(sig, name): - prev = signal.getsignal(sig) - - def _handler(signum, frame): - sbx_log_stack("CTRL-EVENT-RECEIVED", signal=name, tag=tag, swallowed=_SWALLOW) - if _SWALLOW: - return - if callable(prev): - prev(signum, frame) - return - try: - signal.signal(sig, signal.SIG_DFL) - os.kill(os.getpid(), sig) - except Exception: - os._exit(128 + int(signum)) - - return _handler - - try: - signal.signal(signal.SIGINT, _make_handler(signal.SIGINT, "SIGINT(CTRL_C)")) - except Exception: - pass - try: - sigbreak = getattr(signal, "SIGBREAK", None) - if sigbreak is not None: - signal.signal(sigbreak, _make_handler(sigbreak, "SIGBREAK(CTRL_BREAK)")) - except Exception: - pass - _PROBES_INSTALLED = True - sbx_log("CTRL-PROBES-INSTALLED", tag=tag, swallow=_SWALLOW, port=_PORT) diff --git a/modules/terminal_ops/run.py b/modules/terminal_ops/run.py index db50e813..e0e37549 100644 --- a/modules/terminal_ops/run.py +++ b/modules/terminal_ops/run.py @@ -40,11 +40,6 @@ from modules.host_sandbox_runner import ( build_host_sandbox_readonly_plan, host_sandbox_enabled, ) -from modules import sbx_debug # [SBX-DEBUG] -try: # [SBX-DEBUG] - sbx_debug.install_ctrl_event_probes("terminal_ops") # [SBX-DEBUG] -except Exception: # [SBX-DEBUG] - pass # [SBX-DEBUG] if TYPE_CHECKING: from modules.user_container_manager import ContainerHandle @@ -54,22 +49,38 @@ if TYPE_CHECKING: class RunMixin: """TerminalOperator run 能力 mixin。""" - async def _interrupt_subprocess(self, process) -> None: - """超时后的优雅中断。 + @staticmethod + async def _taskkill_tree(process) -> None: + """Windows:taskkill /F /T 强制终止整棵进程树,失败退化为 process.kill()。 - POSIX 用 killpg(SIGINT) 中断整个进程组;Windows 没有 killpg/SIGINT 语义, - 子进程以 start_new_session=True 启动(映射为 CREATE_NEW_PROCESS_GROUP), - 可用 CTRL_BREAK_EVENT 发送到该进程组,失败则直接强杀。 + 避免 shell 子进程被杀后孙进程残留为孤儿。 + """ + try: + await asyncio.to_thread( + subprocess.run, + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + timeout=5, + ) + return + except Exception: + pass + try: + process.kill() + except Exception: + pass + + async def _interrupt_subprocess(self, process) -> None: + """超时后的中断。 + + POSIX 用 killpg(SIGINT) 中断整个进程组。 + Windows 弃用 CTRL_BREAK_EVENT:实测(2026-07 WSL 沙箱排查)即使子进程以 + start_new_session=True(CREATE_NEW_PROCESS_GROUP)启动,控制台事件仍会 + 投递到本进程自身,把后端一并杀死(终端仅显示 ^C、无任何报错)。 + Windows 控制台事件没有安全的定向语义,超时场景直接 taskkill 杀整棵树。 """ - sbx_debug.sbx_log_stack("INTERRUPT-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG] if os.name == "nt": - try: - process.send_signal(signal.CTRL_BREAK_EVENT) - except Exception: - try: - process.kill() - except Exception: - pass + await self._taskkill_tree(process) return try: os.killpg(process.pid, signal.SIGINT) @@ -85,22 +96,8 @@ class RunMixin: POSIX 用 killpg(SIGKILL);Windows 没有 SIGKILL,用 taskkill /F /T 终止整棵进程树, 避免 shell 子进程被杀后孙进程残留为孤儿。 """ - sbx_debug.sbx_log_stack("KILL-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG] if os.name == "nt": - try: - await asyncio.to_thread( - subprocess.run, - ["taskkill", "/F", "/T", "/PID", str(process.pid)], - capture_output=True, - timeout=5, - ) - return - except Exception: - pass - try: - process.kill() - except Exception: - pass + await self._taskkill_tree(process) return try: os.killpg(process.pid, signal.SIGKILL) @@ -168,7 +165,6 @@ class RunMixin: network_permission: Optional[str] = None, ) -> Dict: start_ts = time.time() - sbx_debug.sbx_log("RUN-CMD-ENTER", command=str(command)[:200], timeout=timeout, mode=getattr(self, "host_execution_mode", None), sandbox_write=sandbox_write_access) # [SBX-DEBUG] try: process = None exec_cmd = None @@ -242,7 +238,6 @@ class RunMixin: os.close(seccomp_fd) except OSError: pass - sbx_debug.sbx_log("SANDBOX-SPAWNED", pid=getattr(process, "pid", None), argv0=(cmd_args[0] if cmd_args else None), argv_tail=str(cmd_args[-3:])[:160], console_pids=sbx_debug.console_pids()) # [SBX-DEBUG] elif use_host_sandbox: return { "success": False, @@ -307,13 +302,11 @@ class RunMixin: pass timed_out = False - sbx_debug.sbx_log("WAIT-BEGIN", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG] try: try: await asyncio.wait_for(process.wait(), timeout=timeout) except asyncio.TimeoutError: timed_out = True - sbx_debug.sbx_log_stack("TIMEOUT-INTERRUPT", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG] await self._interrupt_subprocess(process) try: await asyncio.wait_for(process.wait(), timeout=2) @@ -322,7 +315,6 @@ class RunMixin: await process.wait() except asyncio.CancelledError: # 用户主动停止任务或会话断开,立即终止子进程 - sbx_debug.sbx_log_stack("CANCELLED-KILL", pid=getattr(process, "pid", None)) # [SBX-DEBUG] await self._kill_subprocess(process) raise finally: @@ -331,7 +323,6 @@ class RunMixin: # (Task was destroyed but it is pending / unclosed transport 告警) await _finish_reader_tasks(force=timed_out) - sbx_debug.sbx_log("WAIT-END", pid=getattr(process, "pid", None), returncode=getattr(process, "returncode", None), timed_out=timed_out, stdout_len=sum(len(c) for c in stdout_buf), stderr_len=sum(len(c) for c in stderr_buf)) # [SBX-DEBUG] # 非超时场景下兜底再读一次,防止剩余缓冲未被读取 if not timed_out: try: @@ -398,7 +389,6 @@ class RunMixin: "elapsed_ms": int((time.time() - start_ts) * 1000) } except Exception as exc: - sbx_debug.sbx_log_stack("RUN-EXCEPTION", error=str(exc)[:200]) # [SBX-DEBUG] return { "success": False, "status": "error",