340 lines
14 KiB
Rust
340 lines
14 KiB
Rust
//! Shell 执行器 — 通过 tokio::process 执行 shell 命令
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use std::process::Stdio;
|
||
|
||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||
|
||
/// Shell 命令执行结果
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ShellResult {
|
||
/// 标准输出
|
||
pub stdout: String,
|
||
/// 标准错误
|
||
pub stderr: String,
|
||
/// 退出码
|
||
pub exit_code: Option<i32>,
|
||
/// 执行耗时(毫秒)
|
||
pub duration_ms: u64,
|
||
}
|
||
|
||
/// Shell 类型选择
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum ShellType {
|
||
/// Windows cmd.exe (默认 Windows)
|
||
Cmd,
|
||
/// PowerShell 5.x(Windows 自带,不支持 && 运算符)
|
||
PowerShell,
|
||
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
|
||
Pwsh,
|
||
/// Unix sh (默认非 Windows)
|
||
Sh,
|
||
}
|
||
|
||
impl Default for ShellType {
|
||
fn default() -> Self {
|
||
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
||
// 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。
|
||
// 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
|
||
// 真实探测在异步入口 `execute()` 中调用 `probe_pwsh().await`。
|
||
if cfg!(windows) {
|
||
if probe_pwsh_cached() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||
} else {
|
||
ShellType::Sh
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 读取 probe_pwsh 的缓存值(未探测返回 false)。供同步路径 `Default` 使用。
|
||
fn probe_pwsh_cached() -> bool {
|
||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
CACHE.get().copied().unwrap_or(false)
|
||
}
|
||
|
||
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||
///
|
||
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||
///
|
||
/// CR-XX:异步化 —— 在异步上下文中通过 `tokio::task::spawn_blocking` 执行阻塞探测,
|
||
/// 避免阻塞 tokio runtime。结果仍由 OnceLock 全局共享,只探测一次。
|
||
async fn probe_pwsh() -> bool {
|
||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
if let Some(cached) = CACHE.get() {
|
||
return *cached;
|
||
}
|
||
let result = tokio::task::spawn_blocking(|| {
|
||
let mut cmd = std::process::Command::new("pwsh");
|
||
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::process::CommandExt;
|
||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||
}
|
||
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||
})
|
||
.await
|
||
.unwrap_or(false);
|
||
// 多任务竞态时以先到者为准,均等价
|
||
let _ = CACHE.set(result);
|
||
result
|
||
}
|
||
|
||
/// Shell 命令执行请求
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ShellRequest {
|
||
/// 要执行的命令
|
||
pub command: String,
|
||
/// 工作目录
|
||
pub working_dir: Option<String>,
|
||
/// 环境变量
|
||
pub env: std::collections::HashMap<String, String>,
|
||
/// 超时时间(秒),None 表示不超时
|
||
pub timeout_secs: Option<u64>,
|
||
/// Shell 类型(默认:Windows→Cmd, 非Windows→Sh)
|
||
#[serde(default)]
|
||
pub shell_type: Option<ShellType>,
|
||
}
|
||
|
||
/// 输出流类型(回调 on_output 用)
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum StreamKind {
|
||
Stdout,
|
||
Stderr,
|
||
}
|
||
|
||
impl StreamKind {
|
||
/// 序列化为稳定字符串标识(emit 事件 stream 字段用)
|
||
pub fn as_str(&self) -> &'static str {
|
||
match self {
|
||
StreamKind::Stdout => "stdout",
|
||
StreamKind::Stderr => "stderr",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 构造已配置好(stdio piped + kill_on_drop + CREATE_NO_WINDOW + cwd + env)的子进程 Command。
|
||
///
|
||
/// execute() 与 execute_streaming() 共用同一构造逻辑(单真相源,DRY):
|
||
/// shell 类型选择 / kill_on_drop / Windows 无窗 / cwd / env 全在此。差异仅在后续如何消费 stdout/stderr。
|
||
fn build_command(request: ShellRequest) -> tokio::process::Command {
|
||
let shell_type = request.shell_type.unwrap_or_default();
|
||
let mut cmd = match shell_type {
|
||
ShellType::PowerShell => {
|
||
let mut c = tokio::process::Command::new("powershell");
|
||
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
c
|
||
}
|
||
ShellType::Pwsh => {
|
||
// PS7(支持 && 运算符),同 PS5 参数语义。-NoProfile 避加载用户 profile(速度+确定性)
|
||
let mut c = tokio::process::Command::new("pwsh");
|
||
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
c
|
||
}
|
||
ShellType::Cmd => {
|
||
let mut c = tokio::process::Command::new("cmd");
|
||
c.arg("/C").arg(&request.command);
|
||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
c
|
||
}
|
||
ShellType::Sh => {
|
||
let mut c = tokio::process::Command::new("sh");
|
||
c.arg("-c").arg(&request.command);
|
||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||
c
|
||
}
|
||
};
|
||
|
||
// CR-15-1: kill_on_drop(true) 让 Command 被 drop 时主动 kill 子进程。
|
||
// 配合 tokio::time::timeout 超时场景:超时 drop future → Command 析构 → kill 子进程,
|
||
// 不再让超时后的命令变孤儿继续后台跑(长 hang 命令/死循环仍占资源)。
|
||
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
|
||
cmd.kill_on_drop(true);
|
||
|
||
// Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
|
||
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
|
||
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
|
||
#[cfg(windows)]
|
||
{
|
||
cmd.creation_flags(0x0800_0000);
|
||
}
|
||
|
||
if let Some(dir) = &request.working_dir {
|
||
cmd.current_dir(dir);
|
||
}
|
||
|
||
for (key, value) in &request.env {
|
||
cmd.env(key, value);
|
||
}
|
||
cmd
|
||
}
|
||
|
||
/// 执行 Shell 命令(等 exit 一次性返回,非流式)
|
||
///
|
||
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
|
||
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
|
||
///
|
||
/// 需要执行中实时获取 stdout/stderr 行(如 run_command 进度展示)用 [`execute_streaming`]。
|
||
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
||
let start = std::time::Instant::now();
|
||
|
||
// 探测 pwsh(惰性 + OnceLock 全局缓存,只探一次),使后续 ShellType::default() 可读取缓存
|
||
#[cfg(windows)]
|
||
let _ = probe_pwsh().await;
|
||
|
||
// 先取走 build_command 之外的引用字段(超时错误信息 + timeout 判定),再 move request
|
||
let command_for_err = request.command.clone();
|
||
let timeout_secs = request.timeout_secs;
|
||
let mut cmd = build_command(request);
|
||
|
||
let output = match timeout_secs {
|
||
Some(secs) => tokio::time::timeout(
|
||
std::time::Duration::from_secs(secs),
|
||
cmd.output(),
|
||
)
|
||
.await
|
||
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
|
||
None => cmd.output().await?,
|
||
};
|
||
|
||
let duration = start.elapsed().as_millis() as u64;
|
||
|
||
Ok(ShellResult {
|
||
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||
exit_code: output.status.code(),
|
||
duration_ms: duration,
|
||
})
|
||
}
|
||
|
||
/// 流式执行 Shell 命令 —— spawn 后逐行读 stdout/stderr,每行回调 on_output。
|
||
///
|
||
/// 治 run_command 执行中黑盒:execute() 等 exit 才返回整块 stdout/stderr,长命令(cargo/npm 构建)
|
||
/// 期间前端只看 Started→Completed,中间进度不可见。本函数 spawn 子进程后并发逐行读两条流,
|
||
/// 每读到一行回调 `on_output(kind, line)`(调用方可 emit 事件给前端实时展示),仍等进程 exit
|
||
/// 后返回完整 ShellResult(与 execute() 同形,调用方无需感知差异)。
|
||
///
|
||
/// 4性:
|
||
/// - 合理机制:spawn + BufReader::lines() 逐行,不丢未换行结尾的末段(read_to_end 兜底)
|
||
/// - 简洁:与 execute() 共用 build_command(单真相源,shell/kill_on_drop/cwd/env 不重复)
|
||
/// - 可靠兜底:timeout_secs 仍生效(超时 drop future → kill_on_drop 杀进程,返回 Err);
|
||
/// on_output 回调 Err 不影响主流程(调用方 emit 失败静默吞)
|
||
/// - 健壮边界:stdout/stderr 各独立任务并发读,互不阻塞;无管道死锁(piped + 同时消费)
|
||
pub async fn execute_streaming<F>(request: ShellRequest, mut on_output: F) -> anyhow::Result<ShellResult>
|
||
where
|
||
F: FnMut(StreamKind, &str) + Send,
|
||
{
|
||
let start = std::time::Instant::now();
|
||
|
||
#[cfg(windows)]
|
||
let _ = probe_pwsh().await;
|
||
|
||
// 先取走引用字段,再 move request 进 build_command
|
||
let command_for_err = request.command.clone();
|
||
let timeout_secs = request.timeout_secs;
|
||
let mut cmd = build_command(request);
|
||
let inner = async {
|
||
let mut child = cmd.spawn()?;
|
||
// 取出 piped 的 stdout/stderr handle(None → 视为已关,读为空,不影响主流程)
|
||
let stdout = child.stdout.take();
|
||
let stderr = child.stderr.take();
|
||
|
||
// mpsc 通道:读 task 把 (kind, line) 推过来,主 task 在 wait 期间 drain 并调 on_output。
|
||
// 用通道而非直接共享 on_output:FnMut 不可 clone,两读 task 无法各持一份;通道解耦读写,
|
||
// 回调集中在主 task 单点调用(顺序确定、无锁、回调内阻塞不影响读循环)。
|
||
let (tx, mut rx) = tokio::sync::mpsc::channel::<(StreamKind, String)>(64);
|
||
|
||
let mut tasks: Vec<tokio::task::JoinHandle<()>> = Vec::with_capacity(2);
|
||
if let Some(out) = stdout {
|
||
let tx = tx.clone();
|
||
tasks.push(tokio::spawn(async move {
|
||
let mut reader = BufReader::new(out).lines();
|
||
while let Ok(Some(line)) = reader.next_line().await {
|
||
if tx.send((StreamKind::Stdout, line)).await.is_err() {
|
||
break; // 接收端 drop(主 task 结束)→ 停止读
|
||
}
|
||
}
|
||
}));
|
||
}
|
||
if let Some(err) = stderr {
|
||
let tx = tx.clone();
|
||
tasks.push(tokio::spawn(async move {
|
||
let mut reader = BufReader::new(err).lines();
|
||
while let Ok(Some(line)) = reader.next_line().await {
|
||
if tx.send((StreamKind::Stderr, line)).await.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
}));
|
||
}
|
||
// 主 task 不再 send → drop tx(读 task send 失败即退出)
|
||
drop(tx);
|
||
|
||
// 完整输出累积(主 task 单点写,无锁)。
|
||
let mut stdout_buf = String::new();
|
||
let mut stderr_buf = String::new();
|
||
|
||
// wait + drain 并行:边等进程退出边消费输出行(防管道写满阻塞致子进程 hang)。
|
||
let wait_fut = child.wait();
|
||
tokio::pin!(wait_fut);
|
||
let status: std::process::ExitStatus = loop {
|
||
tokio::select! {
|
||
// 进程退出 → 跳出循环,继续 drain 通道内残余行
|
||
status = &mut wait_fut => {
|
||
let status = status?;
|
||
// drain 剩余行(读 task 在管道 EOF 后 send 完最后批次即退出,rx 返 None 闭合)
|
||
while let Some((kind, line)) = rx.recv().await {
|
||
match kind {
|
||
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
|
||
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
|
||
}
|
||
on_output(kind, &line);
|
||
}
|
||
break status;
|
||
}
|
||
// 收到一行 → 累积 + 回调
|
||
Some((kind, line)) = rx.recv() => {
|
||
match kind {
|
||
StreamKind::Stdout => { stdout_buf.push_str(&line); stdout_buf.push('\n'); }
|
||
StreamKind::Stderr => { stderr_buf.push_str(&line); stderr_buf.push('\n'); }
|
||
}
|
||
on_output(kind, &line);
|
||
}
|
||
}
|
||
};
|
||
// 防御性 join 读 task(此时必已 EOF 退出,仅保险;失败静默不阻断)
|
||
for t in tasks {
|
||
let _ = t.await;
|
||
}
|
||
Ok::<ShellResult, anyhow::Error>(ShellResult {
|
||
stdout: stdout_buf,
|
||
stderr: stderr_buf,
|
||
exit_code: status.code(),
|
||
duration_ms: 0, // 外层统一填
|
||
})
|
||
};
|
||
|
||
let result = match timeout_secs {
|
||
Some(secs) => tokio::time::timeout(std::time::Duration::from_secs(secs), inner)
|
||
.await
|
||
.map_err(|_| anyhow::anyhow!("命令执行超时({}s): {}", secs, command_for_err))??,
|
||
None => inner.await?,
|
||
};
|
||
|
||
let duration = start.elapsed().as_millis() as u64;
|
||
Ok(ShellResult {
|
||
stdout: result.stdout,
|
||
stderr: result.stderr,
|
||
exit_code: result.exit_code,
|
||
duration_ms: duration,
|
||
})
|
||
}
|