feat(windows): 沙箱最小根文件系统与崩溃排查调试埋点

This commit is contained in:
JOJO 2026-07-31 17:51:05 +08:00
parent 77be044c44
commit 04c9db8a5b
5 changed files with 206 additions and 37 deletions

View File

@ -252,15 +252,15 @@ class ModeMixin:
"- 命令解释器Linux bash例如可用 ls、cat、grep、find、git、python3、curl 等常见 Linux 工具)\n" "- 命令解释器Linux bash例如可用 ls、cat、grep、find、git、python3、curl 等常见 Linux 工具)\n"
f"- 当前工作区:{ws_win}Windows 视角)= {ws_wsl}(沙箱内视角)\n" f"- 当前工作区:{ws_win}Windows 视角)= {ws_wsl}(沙箱内视角)\n"
f"- 命令的工作目录已默认落在 {ws_wsl},操作工作区内文件请直接使用相对路径\n" f"- 命令的工作目录已默认落在 {ws_wsl},操作工作区内文件请直接使用相对路径\n"
"- 引用其他 Windows 路径时必须转换:盘符小写、反斜杠变正斜杠,例如 D:\\tools\\a.txt → /mnt/d/tools/a.txt\n" "- 沙箱内只能访问当前工作区:其他 Windows 路径(如 D:\\tools\\a.txt未挂载进沙箱、根本不存在需要操作时请把文件复制进工作区或请用户调整工作区\n"
"- 【禁止】Windows 程序在沙箱内不存在cmd、powershell、bat 脚本、.exe、Windows 版 python/node 均无法运行\n" "- 【禁止】Windows 程序在沙箱内不存在cmd、powershell、bat 脚本、.exe、Windows 版 python/node 均无法运行\n"
"- 【禁止】不要写 Windows 风格路径bash 会把反斜杠当作转义符\n" "- 【禁止】不要写 Windows 风格路径bash 会把反斜杠当作转义符\n"
"\n" "\n"
"### 文件权限\n" "### 文件权限\n"
"- 工作区内:可读可写\n" "- 工作区内:可读可写\n"
"- 工作区外:全部只读(包括 Windows 各盘与 Linux 系统目录),写入会报 Read-only file system\n" "- 工作区外:数据在沙箱内不存在Windows 各盘与其余目录均未挂载),访问报 No such file or directory仅通往工作区的空壳目录链可见\n"
"- /tmp 与 HOME 目录可写npm/pip 等工具的缓存可正常使用)\n" "- Linux 系统目录(/bin /usr /lib /etc 等,沙箱自带工具链):只读\n"
"- 用户敏感目录(~/.ssh 等凭据)已被屏蔽,不可读取" "- /tmp 与 HOME 目录可写npm/pip 等工具的缓存可正常使用)"
) )
def _build_windows_mode_rules(self, mode: str) -> str: def _build_windows_mode_rules(self, mode: str) -> str:
@ -268,7 +268,7 @@ class ModeMixin:
if mode == "sandbox": if mode == "sandbox":
return ( return (
"- 所有命令默认在 WSL2 Linux 沙箱中执行\n" "- 所有命令默认在 WSL2 Linux 沙箱中执行\n"
"- 命令报 Read-only file system / Permission denied:说明操作超出沙箱授权边界,这是用户刻意设置的。不要尝试绕过(换路径、提权等),应向用户说明并请求调整权限或切换执行环境\n" "- 命令报 Read-only file system / Permission denied,或访问工作区外路径报 No such file or directory:说明操作超出沙箱授权边界,这是用户刻意设置的。不要尝试绕过(换路径、提权等),应向用户说明并请求调整权限或切换执行环境\n"
"- 命令报 command not found先检查是否误用了 Windows 程序Linux 工具缺失时可建议用户安装\n" "- 命令报 command not found先检查是否误用了 Windows 程序Linux 工具缺失时可建议用户安装\n"
"- 需要 Windows 原生工具链的任务(如为 Windows 版 node 安装依赖):明确告知用户需切换到“完全访问权限”执行" "- 需要 Windows 原生工具链的任务(如为 Windows 版 node 安装依赖):明确告知用户需切换到“完全访问权限”执行"
) )
@ -297,11 +297,11 @@ class ModeMixin:
"你后续的终端命令将不再在 Windows 中执行,而是在 WSL2 Linux 沙箱中以 bash 执行。请立即调整命令写法:\n" "你后续的终端命令将不再在 Windows 中执行,而是在 WSL2 Linux 沙箱中以 bash 执行。请立即调整命令写法:\n"
"\n" "\n"
"1. 解释器变为 Linux bash使用 ls、cat、grep 等 Linux 命令cmd、powershell、.exe 等 Windows 程序从此刻起不可用\n" "1. 解释器变为 Linux bash使用 ls、cat、grep 等 Linux 命令cmd、powershell、.exe 等 Windows 程序从此刻起不可用\n"
f"2. 路径必须转换:当前工作区为 {ws_wsl}(即 Windows 的 {ws_win}),工作目录已默认落在此处,工作区内操作请用相对路径;其他 Windows 路径按 D:\\a\\b → /mnt/d/a/b 转换\n" f"2. 路径必须转换:当前工作区为 {ws_wsl}(即 Windows 的 {ws_win}),工作目录已默认落在此处,工作区内操作请用相对路径;沙箱内只能访问工作区,其他 Windows 路径均未挂载、无法读写\n"
"3. 禁止再写 Windows 风格路径,反斜杠会被 bash 当作转义符\n" "3. 禁止再写 Windows 风格路径,反斜杠会被 bash 当作转义符\n"
"4. 写权限:仅工作区、/tmp、HOME 可写,其余全部只读\n" "4. 写权限:仅工作区、/tmp、HOME 可写Linux 系统目录只读;其余路径在沙箱内不存在\n"
f"{network_part}" f"{network_part}"
f"{final_no}. 遇到 Read-only file system / Permission denied 不要绕过,向用户说明并请求调整\n" f"{final_no}. 遇到 Read-only file system / Permission denied / 区外路径 No such file or directory 不要绕过,向用户说明并请求调整\n"
"\n" "\n"
"此前的命令如果是按 Windows 环境编写的,请按上述规则改写后重新执行。" "此前的命令如果是按 Windows 环境编写的,请按上述规则改写后重新执行。"
) )

View File

@ -18,6 +18,11 @@ from modules.host_sandbox_runner import (
build_host_sandbox_plan, build_host_sandbox_plan,
host_sandbox_enabled, 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"} TERMINAL_STATUSES = {"completed", "failed", "timeout", "cancelled"}
@ -289,6 +294,7 @@ class BackgroundCommandManager:
rec["pid"] = process.pid rec["pid"] = process.pid
rec["updated_at"] = time.time() rec["updated_at"] = time.time()
self._processes[command_id] = process 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): def _reader(stream, collector, rec_key: str):
try: try:
@ -322,6 +328,7 @@ class BackgroundCommandManager:
status = "timeout" status = "timeout"
message = f"命令执行超时 ({timeout}秒)" message = f"命令执行超时 ({timeout}秒)"
# 跨平台终止POSIX killpg(SIGINT→SIGKILL)Windows CTRL_BREAK→taskkill # 跨平台终止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]
self._terminate_pid(process.pid) self._terminate_pid(process.pid)
try: try:
process.wait(timeout=2) process.wait(timeout=2)
@ -429,6 +436,7 @@ class BackgroundCommandManager:
return True return True
def _terminate_pid(self, pid: Any) -> bool: def _terminate_pid(self, pid: Any) -> bool:
sbx_debug.sbx_log_stack("TERMINATE-PID", pid=pid) # [SBX-DEBUG]
normalized = self._coerce_pid(pid) normalized = self._coerce_pid(pid)
if not normalized: if not normalized:
return False return False

View File

@ -7,12 +7,11 @@ import shutil
import subprocess import subprocess
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional
from modules.host_sandbox_policy import ( from modules.host_sandbox_policy import (
get_macos_writable_paths, get_macos_writable_paths,
get_macos_deny_read_paths, get_macos_deny_read_paths,
get_macos_deny_read_regexes, get_macos_deny_read_regexes,
get_windows_deny_read_paths,
) )
@ -395,13 +394,21 @@ def _build_linux_common_plan(
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
# WindowsWSL2 + bubblewrap 沙箱 # WindowsWSL2 + bubblewrap 沙箱
# #
# 设计要点(依据 .wsl-poc 概念验证,见 wsl2-sandbox-poc-report.md # 设计要点(依据 .wsl-poc 与 .wsl-exp 两轮实验,见 wsl2-sandbox-poc-report.md
# 与项目记忆 wsl_sandbox_minimal_root
# - 使用专用沙箱发行版(默认 astrion-sandbox必须关闭 interop # - 使用专用沙箱发行版(默认 astrion-sandbox必须关闭 interop
# 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机; # 否则沙箱内可经 cmd.exe 逃逸到 Windows 宿主机;
# - 隔离原语与 Linux 方案同构bwrap --unshare-all + ro-bind / + 工作区 bind # - 最小根文件系统:只挂载发行版的 Linux 系统目录(/bin /sbin /usr /lib /etc
# 纯工具链、无用户数据)+ 工作区 bind不挂载 / 本身与任何 /mnt/<盘>
# 工作区外的数据在命名空间内根本不存在(报 No such file or directory
# bwrap 是挂载命名空间构造器而非访问过滤器,因此可以实现 macOS Seatbelt
# 做不到的“指令正常运行 + 默认拒绝的完美读限制”;
# - bwrap 为挂载点自动创建的中间父目录是会话内可写 tmpfs不落盘、无安全
# 问题但缺报错语义),启动后先 mount -o remount,ro / 恢复只读报错;
# - 网络档位full → --share-netrestricted仅回环/ none → 不 share-net # - 网络档位full → --share-netrestricted仅回环/ none → 不 share-net
# unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted # unshare-net 下 lo 自动可用,语义对齐 macOS 的 restricted
# - 敏感路径用 --tmpfs目录/ --ro-bind /dev/null文件掩蔽 # - 敏感路径掩蔽清单windows_deny_read_paths不再需要数据根本不进命名
# 空间;该清单仍由 server/chat/permission.py 用于原生读工具的禁读判断;
# - wsl.exe 的 localhost 代理警告经 stderr_ignore_regexes 由执行器过滤。 # - wsl.exe 的 localhost 代理警告经 stderr_ignore_regexes 由执行器过滤。
# ────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────
@ -468,26 +475,6 @@ def _ensure_wsl_sandbox_distro() -> str:
return name return name
def _windows_deny_read_targets() -> List[Tuple[str, bool]]:
"""敏感路径掩蔽清单:返回 ``(wsl路径, 是否目录)``,仅包含实际存在的路径。"""
targets: List[Tuple[str, bool]] = []
for raw in get_windows_deny_read_paths():
try:
p = Path(raw).expanduser()
except Exception:
continue
if not p.is_absolute():
continue
try:
if p.is_dir():
targets.append((_win_path_to_wsl(p), True))
elif p.exists():
targets.append((_win_path_to_wsl(p), False))
except OSError:
continue
return targets
def _build_windows_bwrap_argv( def _build_windows_bwrap_argv(
ws_wsl: str, ws_wsl: str,
shell_cmd: List[str], shell_cmd: List[str],
@ -504,16 +491,26 @@ def _build_windows_bwrap_argv(
# full → 共享网络restricted仅回环与 none → unshare-netlo 仍可用) # full → 共享网络restricted仅回环与 none → unshare-netlo 仍可用)
if permission == NETWORK_PERMISSION_FULL: if permission == NETWORK_PERMISSION_FULL:
argv.append("--share-net") argv.append("--share-net")
argv += ["--ro-bind", "/", "/"] # 最小根文件系统:只挂沙箱发行版的 Linux 系统目录(纯工具链、无用户数据)。
# 不挂载 / 本身与任何 /mnt/<盘>——工作区外的数据在命名空间内不存在。
# 注意:若日后改用 glibc 发行版(如 Ubuntu需补 --ro-bind /lib64 /lib64。
for sysdir in ("/bin", "/sbin", "/usr", "/lib", "/etc"):
argv += ["--ro-bind", sysdir, sysdir]
argv += (["--ro-bind"] if readonly else ["--bind"]) + [ws_wsl, ws_wsl] argv += (["--ro-bind"] if readonly else ["--bind"]) + [ws_wsl, ws_wsl]
for target, is_dir in _windows_deny_read_targets():
argv += ["--tmpfs", target] if is_dir else ["--ro-bind", "/dev/null", target]
argv += [ argv += [
"--chdir", ws_wsl, "--chdir", ws_wsl,
"--proc", "/proc", "--proc", "/proc",
"--dev", "/dev", "--dev", "/dev",
"--tmpfs", "/tmp", "--tmpfs", "/tmp",
"--tmpfs", "/var/tmp",
"--dir", "/root",
"--", "--",
# bwrap 为挂载点自动创建的中间父目录(如 /mnt/e是会话内可写 tmpfs
# (写入不落盘、退出即消失,无安全问题),但写入不报错、与 mac 的审批
# 关键词语义不一致;启动后先把根 remount 为只读,再 exec 真正的命令。
# $0="bwrap-sh" 仅作占位,$@ 从 shell_cmd 开始exec "$@" 按 argv 原样
# 透传,避免对用户命令做字符串拼接(切勿加 shift否则会丢掉 argv[0])。
"bash", "-c", 'mount -o remount,ro / 2>/dev/null; exec "$@"', "bwrap-sh",
*shell_cmd, *shell_cmd,
] ]
return argv return argv
@ -534,8 +531,11 @@ def _build_windows_wsl_plan(
argv = _build_windows_bwrap_argv(ws_wsl, shell_cmd, readonly, network_permission) argv = _build_windows_bwrap_argv(ws_wsl, shell_cmd, readonly, network_permission)
plan_env = dict(env or {}) plan_env = dict(env or {})
plan_env["WSL_UTF8"] = "1" plan_env["WSL_UTF8"] = "1"
# 必须用 -eexec不经默认 shell而非 ---- 形式会把尾部交给 /bin/sh 重新解析,
# 带空格/引号的参数会被拆散bash -c 后的位置参数全部丢失),-e 原样传递 argv
# .wsl-exp/test_argprobe2.py 实测P4/P6(--) 参数丢失P5/P8(-e) 完整)。
return SandboxPlan( return SandboxPlan(
command=[wsl, "-d", distro, "--", *argv], command=[wsl, "-d", distro, "-e", *argv],
env=plan_env, env=plan_env,
cwd=str(work_path), cwd=str(work_path),
stderr_ignore_regexes=list(_WSL_STDERR_IGNORE), stderr_ignore_regexes=list(_WSL_STDERR_IGNORE),

147
modules/sbx_debug.py Normal file
View File

@ -0,0 +1,147 @@
# 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 = "<no 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记录后恢复原处理器语义SIGINTKeyboardInterruptSIGBREAK默认终止
只能在主线程安装失败静默
"""
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)

View File

@ -40,6 +40,11 @@ from modules.host_sandbox_runner import (
build_host_sandbox_readonly_plan, build_host_sandbox_readonly_plan,
host_sandbox_enabled, 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: if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle from modules.user_container_manager import ContainerHandle
@ -56,6 +61,7 @@ class RunMixin:
子进程以 start_new_session=True 启动映射为 CREATE_NEW_PROCESS_GROUP 子进程以 start_new_session=True 启动映射为 CREATE_NEW_PROCESS_GROUP
可用 CTRL_BREAK_EVENT 发送到该进程组失败则直接强杀 可用 CTRL_BREAK_EVENT 发送到该进程组失败则直接强杀
""" """
sbx_debug.sbx_log_stack("INTERRUPT-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG]
if os.name == "nt": if os.name == "nt":
try: try:
process.send_signal(signal.CTRL_BREAK_EVENT) process.send_signal(signal.CTRL_BREAK_EVENT)
@ -79,6 +85,7 @@ class RunMixin:
POSIX killpg(SIGKILL)Windows 没有 SIGKILL taskkill /F /T 终止整棵进程树 POSIX killpg(SIGKILL)Windows 没有 SIGKILL taskkill /F /T 终止整棵进程树
避免 shell 子进程被杀后孙进程残留为孤儿 避免 shell 子进程被杀后孙进程残留为孤儿
""" """
sbx_debug.sbx_log_stack("KILL-SUBPROCESS", pid=getattr(process, "pid", None), nt=(os.name == "nt")) # [SBX-DEBUG]
if os.name == "nt": if os.name == "nt":
try: try:
await asyncio.to_thread( await asyncio.to_thread(
@ -161,6 +168,7 @@ class RunMixin:
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
) -> Dict: ) -> Dict:
start_ts = time.time() 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: try:
process = None process = None
exec_cmd = None exec_cmd = None
@ -234,6 +242,7 @@ class RunMixin:
os.close(seccomp_fd) os.close(seccomp_fd)
except OSError: except OSError:
pass 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: elif use_host_sandbox:
return { return {
"success": False, "success": False,
@ -298,11 +307,13 @@ class RunMixin:
pass pass
timed_out = False timed_out = False
sbx_debug.sbx_log("WAIT-BEGIN", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG]
try: try:
try: try:
await asyncio.wait_for(process.wait(), timeout=timeout) await asyncio.wait_for(process.wait(), timeout=timeout)
except asyncio.TimeoutError: except asyncio.TimeoutError:
timed_out = True timed_out = True
sbx_debug.sbx_log_stack("TIMEOUT-INTERRUPT", pid=getattr(process, "pid", None), timeout=timeout) # [SBX-DEBUG]
await self._interrupt_subprocess(process) await self._interrupt_subprocess(process)
try: try:
await asyncio.wait_for(process.wait(), timeout=2) await asyncio.wait_for(process.wait(), timeout=2)
@ -311,6 +322,7 @@ class RunMixin:
await process.wait() await process.wait()
except asyncio.CancelledError: except asyncio.CancelledError:
# 用户主动停止任务或会话断开,立即终止子进程 # 用户主动停止任务或会话断开,立即终止子进程
sbx_debug.sbx_log_stack("CANCELLED-KILL", pid=getattr(process, "pid", None)) # [SBX-DEBUG]
await self._kill_subprocess(process) await self._kill_subprocess(process)
raise raise
finally: finally:
@ -319,6 +331,7 @@ class RunMixin:
# Task was destroyed but it is pending / unclosed transport 告警) # Task was destroyed but it is pending / unclosed transport 告警)
await _finish_reader_tasks(force=timed_out) 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: if not timed_out:
try: try:
@ -385,6 +398,7 @@ class RunMixin:
"elapsed_ms": int((time.time() - start_ts) * 1000) "elapsed_ms": int((time.time() - start_ts) * 1000)
} }
except Exception as exc: except Exception as exc:
sbx_debug.sbx_log_stack("RUN-EXCEPTION", error=str(exc)[:200]) # [SBX-DEBUG]
return { return {
"success": False, "success": False,
"status": "error", "status": "error",